social-media-shorts-automation

---
name: social-media-shorts-automation
description: Design, generate, review, publish, and optimize automated short-form social videos across YouTube Shorts, TikTok, Instagram Reels, and related channels.
version: 1.0.0
tags: [shorts, tiktok, youtube-shorts, reels, creator-monetization, content-automation, social-media]
---

# Social-Media Shorts Automation

Use this skill when building or operating systems that generate short-form social videos, especially faceless or semi-automated pipelines for YouTube Shorts, TikTok, Instagram Reels, Facebook Reels, Pinterest video pins, X video, or LinkedIn video.

## Core principle

Treat the system as a **Shorts Growth Engine** / **semi-automatic Retention Studio**, not merely a renderer. A working video generator can still fail if it produces generic, low-retention, AI-slop content. Optimize for audience identity, hook quality, retention mechanics, visual mechanism reveals, platform fit, and analytics feedback. For the user's strongest strategic direction, see `references/retention-studio-strategy.md`.

For this user's AutoShorts workflows, approval previews should be German for concept comprehension, while final social-media videos should remain English unless explicitly changed. Treat preview legibility as a quality gate: readable font sizes, pixel-width-aware line wrapping, no clipped words, and no overcrowded cards.

For the user's TrueTraceShorts / AutoShortsBot strategy, do **not** default to a narrow AI/prompt-tips channel. The current strategic direction is broader: explain hidden digital everyday mechanisms — AI, work, attention, decisions, scams, productivity, workflows, social search — and end each video with one small system rule that fixes the outcome. Use the frame `Trace the hidden system. Fix the outcome.` AI should remain an important pillar, but normally only about 30–40% of the topic mix, not the whole product. See `references/truetrace-strategic-neuausrichtung.md`.

## Retention-Studio / Mechanism-Reveal Pipeline

For quality-first automated Shorts, especially Hidden Money & AI Systems concepts, prefer the pipeline documented in `references/retention-studio-pipeline-pattern.md`:

```text
strategy → scored ideas → mechanism explainer → shot plan → keyframe/I2V prompt plan → seeded clip jobs → clip review → deterministic final renderer
```

When implementing a new repo or core pipeline, first scaffold the approval-first typed artifact chain in `references/approval-first-retention-studio-core.md`:

```text
ContentPillar → ContentBrief → ScriptDraft → VideoCandidate → TestBatch → ReviewBatch → Telegram ReviewDelivery → ShotPlan
```

Hard rule for this class: AI-video clips provide photorealistic texture and motion only. Captions, numbers, labels, arrows, UI text, price/risk/margin layers, and factual claims are renderer-owned. Final content should keep `static_card_limit = 0`, use visual changes every 1–2 seconds, and reject technically successful clips that do not serve the retention beat.

Before video generation, build an inspectable content-to-review spine: `ContentBrief → ScriptDraft → CandidateDraft → VideoCandidate → TestBatch → ReviewBatch → Telegram ReviewDelivery`. Keep it deterministic, side-effect free, and human-approval-first so hooks, retention beats, visual concepts, policy notes, and review copy can be optimized before spending GPU/API time. See `references/content-to-review-pipeline.md` for the implementation pattern and guardrails.

Hard rule for this class: AI-video clips provide photorealistic texture and motion only. Captions, numbers, labels, arrows, UI text, price/risk/margin layers, and factual claims are renderer-owned. Final content should keep `static_card_limit = 0`, use visual changes every 1–2 seconds, and reject technically successful clips that do not serve the retention beat.

## Recommended workflow

1. **Define channel position**
   - Niche(s): e.g. life optimization, AI life systems, Christian faith/FaithTok.
   - Target audience and emotional problem.
   - Language and tone.
   - Whether the brand is faceless, AI-persona-led, or creator-led.
   - No-go topics and theological/ethical boundaries if faith-related.

2. **Generate test ideas before code-heavy automation**
   - Create 20–30 candidate ideas across 2–4 content pillars.
   - For each: hook, script kernel, visual concept, caption, CTA, risk/quality score.
   - Select a small first batch (e.g. 3 per pillar) for real testing.
   - Before renderers or external AI APIs, add a deterministic content-development core: `ContentPillar → ContentBrief → ScriptDraft → CandidateDraft → VideoCandidate`. See `references/content-development-core.md`.

3. **Use hook-first, retention-led scripting**
   - First 1–2 seconds must contain a clear tension, identity statement, pain point, contrarian line, or immediate self-recognition.
   - Prefer one idea per video over generic “4 tips” unless the list format is the explicit series.
   - Avoid generic advice viewers have already heard unless framed with a new angle, concrete payoff, or strong story.
   - For TrueTraceShorts, prefer the structure `visible fail → identification → mechanism → fix → before/after → memorable takeaway` over generic AI-tip narration. Every video must answer: what goes wrong, why it happens, what rule fixes it, what sentence sticks, why viewers save/share it, and why they follow the series.
   - Reward hooks with direct address, specificity, open loops, tension, and a visible payoff; reject slow intros such as “In this video...”.
   - See `references/retention-led-shorts.md` for scoring signals, penalties, and voiceover finalization rules.

4. **Quality gate before rendering**
   Score drafts on:
   - hook strength
   - novelty
   - emotional pull
   - clarity
   - retention risk
   - comment/share potential
   - AI-slop risk
   - platform/policy risk
   - first-frame clarity
   - mechanism value
   - practical payoff
   - save/share/follow reason
   - trust/human texture
   - series fit and saturation defense
   Render only drafts above the chosen threshold, e.g. 8/10.
   For TrueTraceShorts, use the 100-point scoring and hard blockers in `references/truetrace-strategic-neuausrichtung.md`: block generic AI hooks, pure text first frames, unsourced claims, missing before/after, missing human texture, unclear AI disclosure, and candidates too similar to recent videos.

5. **Visual strategy**
   - Avoid obvious AI artifacts, fake UI text, illegible generated lettering, generic charts, and soulless stock visuals.
   - Prefer high-contrast kinetic captions, visible hook text in first seconds, relatable B-roll, screen recordings for AI/tutorial content, phone/screen close-ups, before/after transformations, cards, and timer visuals.
   - Use pattern interrupts every 2–3 seconds for retention.
   - Before rendering, generate a shot plan from the finalized voiceover: first-frame hook, pattern interrupt, core explanation, screen/card visual, CTA, caption beats, and asset type.
   - Before writing a renderer, convert shot plans into deterministic render manifests: machine-readable timeline items, asset placeholders, caption tracks, style constraints, and approval constraints. See `references/render-manifest-pipeline.md`.
   - Treat this as a deterministic intermediate artifact, not optional prose. See `references/retention-render-planning.md` for the five-scene shot-plan pattern and renderer-facing fields.
   - For the first video output, build a manifest-driven preview renderer before any AI-video or polished asset pipeline: first create a side-effect-free local preview plan, then write deterministic PNG scene cards, then build a side-effect-free FFmpeg concat plan, and only then call FFmpeg from an explicit renderer boundary. The boundary should report side effects such as `write_png`, `write_concat`, `call_ffmpeg`, and `write_mp4`; keep generated PNG/MP4 artifacts out of Git. See `references/manifest-preview-renderer.md`.
   - When the user rejects static/slideshow output, add an `asset_plan.v2` layer that maps each scene to motion-led local asset modes such as `cinematic_ai_video`, `screen_recording_simulation`, `mechanism_animation`, or `kinetic_text_overlay` with no baked text. If the user requires a free workflow, do not depend on paid video-credit tools; use local procedural motion and optional free NVIDIA LLM text refinement. See `references/ai-video-asset-planning.md`.
   - For hybrid workflows, add an imported-clip assembly layer: provider handoff prompts create short manual AI-video clips, humans drop them into deterministic `data/imported_ai_clips/<slug>/scene_XX.mp4` paths, and the final renderer combines imported clips with local mechanism animations/captions. See `references/imported-ai-clip-assembly.md`.
   - For local Wan2.2 quality workflows, prefer keyframe/image-to-video over raw text-to-video: generate/select a clean keyframe, run native Wan2.2 I2V variants, review clips bluntly for subject clarity/motion/artifacts, then add all labels/numbers/captions via deterministic renderer. See `references/wan-i2v-retention-studio.md`.
   - When using NVIDIA Build/API Catalog for keyframes, prefer hosted `ai.api.nvidia.com` calls before considering self-hosted NIM deployment; implement a configurable model fallback chain, store the key as a runtime secret, and inspect/clean logos, emblems, license plates, and fake text before I2V. See `references/nvidia-keyframe-generation.md`.

6. **Social-media draft and content-preview approval packages before TTS/rendering polish**
   - Before TTS, platform APIs, or polished video generation, build a typed `SocialMediaDraft` from the approved candidate plus render manifest/preview.
   - Include platform targets, title variants, caption, hashtags, CTA, hook, duration, quality/risk summary, and preview reference.
   - Render this into a Telegram-safe approval message and wrap it in a side-effect-free delivery plan with `requires_human_approval=True` and `side_effects=()`.
   - After a local MP4 timing preview exists, combine the MP4 path plus social draft review into a side-effect-free content preview approval package. This package should carry `approval_language=de`, `final_video_language=en`, explicit approval commands, no external calls, and no side effects.
   - Add local demo CLIs that print metadata first, then review text; draft-only CLIs must not write files, call renderers, call Telegram, or access credentials. MP4 preview CLIs may write local artifacts and call FFmpeg only at explicit renderer boundaries that report side effects.
   - See `references/social-draft-approval-package.md` and `references/content-automation-patterns.md` for concrete artifact sequence, user-specific language rules, and external workflow patterns.

7. **Human approval loop before posting**
   - Send preview to the user through the requested channel, usually Telegram.
   - Include: video or preview reference, title, caption, hashtags, platform recommendation, duration, quality/risk score.
   - When sending multiple preview videos, verify and label distinct artifacts before delivery: candidate ID/title, absolute path, duration, and preferably checksum/hash. If several previews are visually similar timing cards, do not spam them as separate unlabeled media; send a concise indexed list first or bundle with clear labels so the user can tell them apart.
   - Wait for explicit approval such as `Freigabe alle`, `Freigabe YouTube`, `Freigabe TikTok`, `Ablehnen`, or `Ändern: ...`.
   - Do not publish automatically unless the user has explicitly authorized that workflow.

8. **TTS / voice layer after draft approval**
   - Treat TTS as a later layer, not the starting point for a first post draft.
   - Decide voice, final voiceover script, words-per-second, scene timing, caption sync, audio format, provider, and separate voice approval only after the social draft package is reviewable.
   - This avoids spending TTS/rendering effort on unapproved hooks, captions, or platform positioning.
  - For final-near previews, keep one copy source of truth: final voiceover → script draft/retention beats → shot plan → top card text → scene captions → subtitle track → TTS audio. Do not mix old approval-card text with new spoken/subtitle text.
  - If the user says the result is too abstract/abgehoben, rewrite around a concrete beginner-friendly everyday failure example before polishing visuals. Prefer “one weird email arrives” / missing field / unclear request / wrong confident action over jargon such as `exception path`, `input boundary`, or `output standard`. See `references/beginner-friendly-audio-caption-alignment.md`.
  - If the user says subtitles are irritating or not simultaneous with voiceover, treat this as a blocking quality issue: use forced alignment or simplify captions before sending another final-near clip. Maintain three caption modes: word-by-word for fast hooks, phrase-by-phrase for complex explanations, and keyword-pulse for a calmer premium look.
  - For mechanism-reveal final renders, use the hybrid pattern in `references/final-motion-hybrid-rendering.md`: ChatGPT Image/Codex or similar tools create text-free scene backgrounds only; deterministic renderer owns cards, labels, arrows, subtitles, and FFmpeg assembly. Use compact phrase-boundary subtitle cues before 1080 export.

9. **Publish and measure**
   Track at minimum:
   - platform, video id/url, niche, format, hook, duration
   - views at 2h/24h/7d
   - likes, comments, shares, saves, follows
   - retention metrics if available
   Use results to generate the next batch.

## Platform duration/monetization notes

See `references/preview-media-delivery-guardrails.md` for avoiding duplicate-looking/unlabeled preview-video spam in Telegram review workflows.

See `references/audio-caption-preview-pipeline.md` for the approval-first EdgeTTS + ASS subtitles + FFmpeg review-MP4 pipeline, including actual-audio-duration probing and the explicit `preserve_video` vs `trim_to_audio` duration-mode guardrail.

See `references/final-motion-hybrid-rendering.md` for the final-near hybrid renderer pattern: ChatGPT Image/Codex or other AI tools provide text-free background/keyframe texture while deterministic renderer overlays own text, arrows, labels, captions, and final assembly. For quick prototypes, prefer ChatGPT Image/Codex backgrounds before local Qwen/ComfyUI unless privacy/cost/local-only constraints dominate. After a 1080 export, create a side-effect-free final approval package with media path, SHA256, platform metadata, social copy, quality notes, and approval commands before any delivery or platform step.

See `references/hybrid-scene-backgrounds-codex.md` for the concrete ChatGPT Image/Codex scene-background workflow: side-effect-free `SceneBackgroundPlan`, per-scene text-free backgrounds, renderer-owned overlays, and visual QA before review delivery. Prefer this over local Qwen/ComfyUI when the user allows Codex image generation and local GPU should be reserved for video/model work.

See `references/monetization-and-approval-notes.md` for current working notes captured from official YouTube/TikTok docs and the recommended approval/posting setup. See `references/retention-led-shorts.md` for retention scoring signals, no-faith/adjacent-pillar handling, and voiceover finalization rules. See `references/render-manifest-pipeline.md` for the script → shot plan → render manifest → renderer sequence that prevents generic AI slideshow output. See `references/manifest-preview-renderer.md` for the first deterministic Pillow/FFmpeg preview-renderer pattern. See `references/local-preview-frame-writer.md` for the staged `LocalPreviewPlan → PNG scene cards → FFmpeg concat plan` boundary before MP4 rendering. See `references/social-draft-approval-package.md` for the typed first-post approval package to build before TTS or platform APIs. See `references/content-automation-patterns.md` for public workflow patterns, queue/status/cost tracking, versioned approval, synthetic-content disclosure, originality gates, and the user's German-preview/English-final rule. See `references/ai-video-asset-planning.md` for motion-led final-render asset planning with Kling/Hailuo/Runway-style clips and screen simulations. See `references/imported-ai-clip-assembly.md` for hybrid assembly where manually generated provider clips are imported into a deterministic local renderer. See `references/nvidia-keyframe-generation.md` for NVIDIA-hosted keyframe generation, model fallback, secret handling, and pre-I2V logo/plate cleanup. Re-check official docs before making business-critical decisions because platform programs change often.

## Project/repo hygiene for automated video bots

- Keep source code in Git; keep generated videos, assets, `.env`, OAuth tokens, API keys, and downloaded secrets out of Git.
- Use `.gitignore` aggressively for outputs, media caches, local credentials, and temp files.
- Store platform credentials as runtime secrets; never print raw tokens.
- Build the repo as modular components: idea generation, scripting, rendering, review, publishing, analytics.
- For approval-first Retention Studio implementations, use a typed side-effect-free core before real I/O: `ContentBrief`, `ScriptDraft`, `VideoCandidate`, review batches, Telegram delivery plans, then shot plans. See `references/approval-first-retention-studio-core.md`.

## Sensitive-content positioning

When AI/productivity automation is the primary brand, be cautious about mixing in religious/FaithTok, health, finance, or other high-trust themes. If the user flags the combination as sensitive, pause that pillar rather than forcing it into the same testbatch. Replace it with a safer adjacent pillar (for example `attention_design`) and document the paused pillar in config so it can be revisited only with explicit approval and a separate brand strategy.

## Pitfalls

- Do not send several generated preview videos as unlabeled media attachments just because they are distinct files. Telegram/mobile previews can make timing-card MP4s look identical. Verify uniqueness (path + title + hash/duration) and provide labels or an indexed summary; if the user complains about duplicate-looking media, stop resending, verify artifacts, and continue the pipeline.
- Do not let final-near preview layers drift apart: if the top field, voiceover, and subtitles do not say the same thing, rebuild the render plan from one final script source before sending another clip.
- Do not make broad-audience AI-system shorts sound like architecture lectures. For early-AI users, convert abstract workflow concepts into concrete everyday failures first, then reveal the principle.
- Do not over-optimize renderer quality before testing whether the content format earns retention.
- Do not let German approval previews become unreadable technical cards: use pixel-width-aware wrapping, readable fonts, limited line counts, and visual checks for clipped/overcrowded text.
- Do not confuse approval language with final audience language. For this user, approval previews are German by default; final social videos remain English unless explicitly changed.
- Do not copy public automation workflows wholesale: keep their queue/status/cost tracking and template structure, but remove blind auto-publishing until a human approval/version lock exists.
- Do not mass-generate repetitive shorts without originality/inauthentic-content gates; this can harm monetization/account health and create AI slop.
- Do not jump directly from a script idea to ComfyUI/Wan/Qwen/Image/video generation. First build and inspect the content-to-review spine so weak hooks, generic scripts, policy issues, and static visual concepts are killed before render time.
- Do not assume local Qwen/ComfyUI is the best first image step for every final-near short. If the user permits ChatGPT Image/Codex, use it for text-free scene backgrounds/keyframes when the deterministic renderer owns labels/captions and local GPU should be saved for video/model work.
- Do not create a parallel candidate path for content experiments; convert content artifacts into the existing candidate factory input so IDs, hashtags, duration classes, quality gates, and pillar/platform validation stay centralized.
- Do not use `x or default` for optional content/script overrides in factories; explicit blank strings and zero durations must pass through to validation so tests can catch them.
- Do not assume TikTok monetization rules match YouTube: TikTok Creator Rewards emphasizes 1min+ eligible videos, while shorter videos may still be better for growth.
- Do not publish religious or health-adjacent content with overconfident claims; preserve humility, nuance, and platform safety.
- Do not rely on platform payout as the only monetization path; plan indirect monetization such as newsletter, templates, affiliates, digital products, or services.


Explanation:
**Inspecting Implementation Options**

I need to take a closer look at the situation. Maybe I could delegate some tasks for the implementation. It feels important to ensure everything is running smoothly, so I definitely want to self-verify what's being done. I think it’s all about starting with the right status and making sure I’m on track with the process. Let’s see how this unfolds!

todo

Projektstatus erheben: git status/branch, pytest, betroffene Module einschätzen

Arbeitsbranch strategy-refactor-v2 anlegen

Tests für Strategic Spine V2 schreiben und RED verifizieren

V2 Modelle, Gates, Scoring, Review Packages und Demo-Kandidaten implementieren

Pytest vollständig ausführen und Ergebnisse/Review-Packages sammeln


terminal

git status --short && printf '\nBRANCH=' && git branch --show-current && printf '\nPYTEST_START\n' && python -m pytest tests -q


Output:
BRANCH=feat/core-approval-quality-gates

PYTEST_START
........................................................................ [ 41%]
........................................................................ [ 83%]
............................                                             [100%]
172 passed in 33.22s


Explanation:
**Understanding git status**

I’m trying to figure out why the `git status` command is only showing the branch without any changes. It seems like I might be missing some modules. I need to do a bit of searching or reading to clarify this. Okay, I’ll make sure to focus on both the branch and any modules that might be relevant. I want to get a clearer picture of what’s going on with my git setup for better management!


Output: Zu neuem Branch 'strategy-refactor-v2' gewechselt


Explanation:
**Inspecting existing models**

I need to take the time to inspect the existing models thoroughly. There’s so much to consider, and I want to ensure that I understand their strengths and weaknesses. It’s important to look into their performance and how they compare to one another. Hopefully, this process will help me identify any improvements I can make or insights I can draw from them. Let’s make sure I get this right!


     1|"""Content briefs for retention-led short-form videos."""
     2|
     3|from __future__ import annotations
     4|
     5|from dataclasses import dataclass
     6|import re
     7|import unicodedata
     8|
     9|from autoshorts.config.pillars import DEFAULT_PILLARS, ContentPillar, find_pillar
    10|from autoshorts.ideas.candidate import SUPPORTED_PLATFORMS, ValidationResult
    11|
    12|
    13|@dataclass(frozen=True)
    14|class ContentBrief:
    15|    brief_id: str
    16|    pillar_id: str
    17|    topic: str
    18|    audience_problem: str
    19|    payoff: str
    20|    positioning: str
    21|    tone: tuple[str, ...]
    22|    visual_rules: tuple[str, ...]
    23|    no_go: tuple[str, ...]
    24|    platforms: tuple[str, ...]
    25|    target_duration_seconds: int = 35
    26|
    27|
    28|def _is_blank(value: str) -> bool:
    29|    return not (value or "").strip()
    30|
    31|
    32|def _slugify(text: str, *, max_words: int = 6) -> str:
    33|    normalized = unicodedata.normalize("NFKD", text or "")
    34|    ascii_text = normalized.encode("ascii", "ignore").decode("ascii")
    35|    tokens = re.findall(r"[a-z0-9]+", ascii_text.casefold())
    36|    return "-".join(tokens[:max_words]) or "untitled"
    37|
    38|
    39|def create_content_brief(
    40|    *,
    41|    pillar_id: str,
    42|    topic: str,
    43|    audience_problem: str,
    44|    payoff: str,
    45|    platforms: tuple[str, ...] | None = None,
    46|    target_duration_seconds: int = 35,
    47|    registry: tuple[ContentPillar, ...] = DEFAULT_PILLARS,
    48|) -> ContentBrief:
    49|    pillar = find_pillar(pillar_id, registry)
    50|    if pillar is None:
    51|        raise ValueError(f"unknown pillar: {pillar_id}")
    52|
    53|    return ContentBrief(
    54|        brief_id=f"{pillar_id}-{_slugify(topic)}",
    55|        pillar_id=pillar_id,
    56|        topic=topic,
    57|        audience_problem=audience_problem,
    58|        payoff=payoff,
    59|        positioning=pillar.positioning,
    60|        tone=pillar.tone,
    61|        visual_rules=pillar.visual_rules,
    62|        no_go=pillar.no_go,
    63|        platforms=platforms or pillar.platform_fit,
    64|        target_duration_seconds=target_duration_seconds,
    65|    )
    66|
    67|
    68|def validate_content_brief(brief: ContentBrief) -> ValidationResult:
    69|    errors: list[str] = []
    70|    required = {
    71|        "brief_id": brief.brief_id,
    72|        "pillar_id": brief.pillar_id,
    73|        "topic": brief.topic,
    74|        "audience_problem": brief.audience_problem,
    75|        "payoff": brief.payoff,
    76|        "positioning": brief.positioning,
    77|    }
    78|    for field_name, value in required.items():
    79|        if _is_blank(value):
    80|            errors.append(f"{field_name} is required")
    81|
    82|    if not brief.tone:
    83|        errors.append("at least one tone is required")
    84|    if not brief.visual_rules:
    85|        errors.append("at least one visual rule is required")
    86|    if not brief.no_go:
    87|        errors.append("at least one no-go is required")
    88|    if not 10 <= brief.target_duration_seconds <= 180:
    89|        errors.append("duration_seconds must be between 10 and 180")
    90|    if not brief.platforms:
    91|        errors.append("at least one platform is required")
    92|    for platform in brief.platforms:
    93|        if platform not in SUPPORTED_PLATFORMS:
    94|            errors.append(f"unsupported platform: {platform}")
    95|
    96|    return ValidationResult(is_valid=not errors, errors=tuple(errors))
    97|


     1|"""Video candidate domain model and validation."""
     2|
     3|from __future__ import annotations
     4|
     5|from dataclasses import dataclass
     6|
     7|from autoshorts.review.package import ReviewPackage
     8|from autoshorts.scripting.quality import ScriptQualityScore
     9|
    10|SUPPORTED_PLATFORMS = ("youtube", "tiktok", "instagram")
    11|
    12|
    13|@dataclass(frozen=True)
    14|class ValidationResult:
    15|    is_valid: bool
    16|    errors: tuple[str, ...] = ()
    17|
    18|
    19|@dataclass(frozen=True)
    20|class VideoCandidate:
    21|    candidate_id: str
    22|    pillar: str
    23|    title: str
    24|    platforms: tuple[str, ...]
    25|    duration_seconds: int
    26|    hook: str
    27|    script_outline: str
    28|    visual_concept: str
    29|    caption: str
    30|    cta: str
    31|    hashtags: tuple[str, ...]
    32|    quality_score: ScriptQualityScore
    33|    policy_notes: tuple[str, ...]
    34|    hypothesis: str
    35|
    36|    def to_review_package(self, preview_path: str | None = None) -> ReviewPackage:
    37|        return ReviewPackage(
    38|            candidate_id=self.candidate_id,
    39|            title=self.title,
    40|            pillar=self.pillar,
    41|            duration_seconds=self.duration_seconds,
    42|            hook=self.hook,
    43|            script_summary=self.script_outline,
    44|            visual_concept=self.visual_concept,
    45|            caption=self.caption,
    46|            hashtags=self.hashtags,
    47|            platforms=self.platforms,
    48|            quality_score=self.quality_score,
    49|            policy_notes=self.policy_notes,
    50|            preview_path=preview_path,
    51|        )
    52|
    53|
    54|def _is_blank(value: str) -> bool:
    55|    return not (value or "").strip()
    56|
    57|
    58|def validate_candidate(candidate: VideoCandidate) -> ValidationResult:
    59|    errors: list[str] = []
    60|
    61|    required_fields = {
    62|        "candidate_id": candidate.candidate_id,
    63|        "pillar": candidate.pillar,
    64|        "title": candidate.title,
    65|        "hook": candidate.hook,
    66|        "script_outline": candidate.script_outline,
    67|        "visual_concept": candidate.visual_concept,
    68|        "caption": candidate.caption,
    69|        "cta": candidate.cta,
    70|        "hypothesis": candidate.hypothesis,
    71|    }
    72|    for field_name, value in required_fields.items():
    73|        if _is_blank(value):
    74|            errors.append(f"{field_name} is required")
    75|
    76|    if not 10 <= candidate.duration_seconds <= 180:
    77|        errors.append("duration_seconds must be between 10 and 180")
    78|
    79|    if not candidate.platforms:
    80|        errors.append("at least one platform is required")
    81|    for platform in candidate.platforms:
    82|        if platform not in SUPPORTED_PLATFORMS:
    83|            errors.append(f"unsupported platform: {platform}")
    84|
    85|    return ValidationResult(is_valid=not errors, errors=tuple(errors))
    86|


     1|"""Strategy scoring for follower-converting short-form candidates."""
     2|
     3|from __future__ import annotations
     4|
     5|from dataclasses import dataclass
     6|
     7|from autoshorts.ideas.candidate import ValidationResult, VideoCandidate
     8|from autoshorts.strategy.series import FORBIDDEN_PUBLIC_TOPIC_PATTERNS
     9|
    10|MIN_SIGNAL_SCORE = 7
    11|MIN_AVERAGE_SCORE = 7.5
    12|GENERIC_AI_SLOP_PHRASES = (
    13|    "in this video",
    14|    "unlock your potential",
    15|    "unlock your full potential",
    16|    "best ai tools",
    17|    "ai tools you need",
    18|    "follow for more ai tips",
    19|    "everyone should use",
    20|)
    21|DISTINCTIVE_TERMS = (
    22|    "system",
    23|    "systems",
    24|    "workflow",
    25|    "boundary",
    26|    "operating system",
    27|    "quality gate",
    28|    "mechanism",
    29|)
    30|SERIES_NAMES = (
    31|    "Prompt vs System",
    32|    "AI System Autopsy",
    33|    "One Rule, One Example",
    34|    "AI Hype Court",
    35|)
    36|
    37|
    38|@dataclass(frozen=True)
    39|class StrategySignal:
    40|    score: int
    41|    rationale: str
    42|
    43|
    44|@dataclass(frozen=True)
    45|class ContentStrategyScore:
    46|    hook: StrategySignal
    47|    curiosity: StrategySignal
    48|    identity: StrategySignal
    49|    emotion: StrategySignal
    50|    trust: StrategySignal
    51|    follow_reason: StrategySignal
    52|    originality: StrategySignal
    53|    variety: StrategySignal
    54|    target_emotion: str
    55|    viewer_identity: str
    56|    future_value_promise: str
    57|    format_family: str
    58|    variation_axis: str
    59|
    60|    @property
    61|    def average_score(self) -> float:
    62|        signals = (
    63|            self.hook,
    64|            self.curiosity,
    65|            self.identity,
    66|            self.emotion,
    67|            self.trust,
    68|            self.follow_reason,
    69|            self.originality,
    70|            self.variety,
    71|        )
    72|        return sum(signal.score for signal in signals) / len(signals)
    73|
    74|
    75|@dataclass(frozen=True)
    76|class CandidateStrategyReview:
    77|    candidate_id: str
    78|    strategy_score: ContentStrategyScore
    79|    render_allowed: bool
    80|    reject_reasons: tuple[str, ...]
    81|    improvement_suggestions: tuple[str, ...]
    82|
    83|
    84|def _is_blank(value: str) -> bool:
    85|    return not (value or "").strip()
    86|
    87|
    88|def _visible_candidate_text(candidate: VideoCandidate) -> str:
    89|    return " ".join(
    90|        (
    91|            candidate.title,
    92|            candidate.hook,
    93|            candidate.script_outline,
    94|            candidate.visual_concept,
    95|            candidate.caption,
    96|            candidate.cta,
    97|        )
    98|    ).casefold()
    99|
   100|
   101|def _combined_candidate_text(candidate: VideoCandidate) -> str:
   102|    return " ".join(
   103|        (
   104|            _visible_candidate_text(candidate),
   105|            candidate.hypothesis,
   106|            " ".join(candidate.policy_notes),
   107|        )
   108|    ).casefold()
   109|
   110|
   111|def _has_distinctive_terms(text: str) -> bool:
   112|    return any(term in text for term in DISTINCTIVE_TERMS)
   113|
   114|
   115|def _has_generic_ai_slop(text: str) -> bool:
   116|    return any(phrase in text for phrase in GENERIC_AI_SLOP_PHRASES)
   117|
   118|
   119|def _infer_format_family(candidate: VideoCandidate) -> str:
   120|    text = _combined_candidate_text(candidate)
   121|    for series_name in SERIES_NAMES:
   122|        if series_name.casefold() in text:
   123|            return series_name
   124|    if "ai tools" in text:
   125|        return "AI Tools List"
   126|    if "prompt" in text and "system" in text:
   127|        return "Prompt vs System"
   128|    if "claim" in text and "verdict" in text:
   129|        return "AI Hype Court"
   130|    if "rule" in text and "example" in text:
   131|        return "One Rule, One Example"
   132|    if "failure" in text or "broken" in text:
   133|        return "AI System Autopsy"
   134|    return "Unclassified"
   135|
   136|
   137|def _score_signal(score: int, rationale: str) -> StrategySignal:
   138|    return StrategySignal(score=max(0, min(10, score)), rationale=rationale)
   139|
   140|
   141|def _forbidden_public_topic_matches(text: str) -> tuple[str, ...]:
   142|    matches: list[str] = []
   143|    for canonical_topic, patterns in FORBIDDEN_PUBLIC_TOPIC_PATTERNS.items():
   144|        if any(pattern in text for pattern in patterns):
   145|            matches.append(canonical_topic)
   146|    return tuple(matches)
   147|
   148|
   149|def validate_content_strategy_score(score: ContentStrategyScore) -> ValidationResult:
   150|    errors: list[str] = []
   151|    signals = {
   152|        "hook": score.hook,
   153|        "curiosity": score.curiosity,
   154|        "identity": score.identity,
   155|        "emotion": score.emotion,
   156|        "trust": score.trust,
   157|        "follow_reason": score.follow_reason,
   158|        "originality": score.originality,
   159|        "variety": score.variety,
   160|    }
   161|    for name, signal in signals.items():
   162|        if not 0 <= signal.score <= 10:
   163|            errors.append(f"{name} score must be between 0 and 10")
   164|        if _is_blank(signal.rationale):
   165|            errors.append(f"{name} rationale is required")
   166|
   167|    for field_name, value in {
   168|        "target_emotion": score.target_emotion,
   169|        "viewer_identity": score.viewer_identity,
   170|        "future_value_promise": score.future_value_promise,
   171|        "format_family": score.format_family,
   172|        "variation_axis": score.variation_axis,
   173|    }.items():
   174|        if _is_blank(value):
   175|            errors.append(f"{field_name} is required")
   176|
   177|    if score.hook.score < MIN_SIGNAL_SCORE:
   178|        errors.append("hook must be at least 7")
   179|    if score.curiosity.score < MIN_SIGNAL_SCORE:
   180|        errors.append("curiosity must be at least 7")
   181|    if score.identity.score < MIN_SIGNAL_SCORE:
   182|        errors.append("identity must be at least 7")
   183|    if score.follow_reason.score < MIN_SIGNAL_SCORE:
   184|        errors.append("follow_reason must be at least 7")
   185|    if score.originality.score < MIN_SIGNAL_SCORE:
   186|        errors.append("originality must be at least 7")
   187|    if score.average_score < MIN_AVERAGE_SCORE:
   188|        errors.append("average strategy score must be at least 7.5")
   189|
   190|    return ValidationResult(is_valid=not errors, errors=tuple(errors))
   191|
   192|
   193|def should_advance_strategy(score: ContentStrategyScore) -> bool:
   194|    return validate_content_strategy_score(score).is_valid
   195|
   196|
   197|def build_candidate_strategy_review(
   198|    candidate: VideoCandidate,
   199|    *,
   200|    recent_format_families: tuple[str, ...] = (),
   201|) -> CandidateStrategyReview:
   202|    text = _combined_candidate_text(candidate)
   203|    visible_text = _visible_candidate_text(candidate)
   204|    format_family = _infer_format_family(candidate)
   205|    generic_slop = _has_generic_ai_slop(visible_text)
   206|    distinctive = _has_distinctive_terms(visible_text)
   207|    forbidden_public_topics = _forbidden_public_topic_matches(visible_text)
   208|    repeated_recently = bool(recent_format_families) and recent_format_families.count(format_family) >= 2
   209|
   210|    strategy_score = ContentStrategyScore(
   211|        hook=_score_signal(
   212|            8 if not generic_slop else 5,
   213|            "Hook creates a concrete systems-vs-hacks tension."
   214|            if not generic_slop
   215|            else "Hook uses generic AI advice language.",
   216|        ),
   217|        curiosity=_score_signal(
   218|            8 if "?" in candidate.hook or "broken" in text or "because" in text or "if" in text else 6,
   219|            "Viewer has a specific open loop about the mechanism or fix.",
   220|        ),
   221|        identity=_score_signal(
   222|            9 if distinctive else 5,
   223|            "Targets serious AI workflow builders."
   224|            if distinctive
   225|            else "Audience identity is too broad.",
   226|        ),
   227|        emotion=_score_signal(
   228|            8 if not generic_slop else 6,
   229|            "Targets relief/frustration with shallow AI advice.",
   230|        ),
   231|        trust=_score_signal(
   232|            8 if distinctive else 6,
   233|            "Trust comes from concrete workflow/system language."
   234|            if distinctive
   235|            else "Needs more proof, constraints, or a concrete artifact.",
   236|        ),
   237|        follow_reason=_score_signal(
   238|            9 if distinctive else 4,
   239|            "Future value promise is repeatable AI systems."
   240|            if distinctive
   241|            else "Future value promise is not distinctive enough.",
   242|        ),
   243|        originality=_score_signal(
   244|            8 if not generic_slop and distinctive else 4,
   245|            "Mechanism-first angle avoids generic AI tips."
   246|            if not generic_slop and distinctive
   247|            else "Generic AI-slop language detected.",
   248|        ),
   249|        variety=_score_signal(
   250|            5 if repeated_recently else 8,
   251|            "Recent format repetition risk." if repeated_recently else "Format is fresh enough against recent context.",
   252|        ),
   253|        target_emotion="relief" if not generic_slop else "mild interest",
   254|        viewer_identity="serious AI workflow builders" if distinctive else "generic AI users",
   255|        future_value_promise="repeatable AI systems, not prompt tricks" if distinctive else "more AI tips",
   256|        format_family=format_family,
   257|        variation_axis="contrarian hook + mechanism reveal" if distinctive else "generic listicle",
   258|    )
   259|
   260|    validation = validate_content_strategy_score(strategy_score)
   261|

Use offset=261 to continue reading (showing 1-260 of 288 lines)


     1|"""Telegram review-package formatting for content candidates."""
     2|
     3|from __future__ import annotations
     4|
     5|from dataclasses import dataclass
     6|
     7|from autoshorts.scripting.quality import ScriptQualityScore, should_render
     8|
     9|_PLATFORM_LABELS = {
    10|    "youtube": "YouTube",
    11|    "tiktok": "TikTok",
    12|    "instagram": "Instagram",
    13|}
    14|
    15|
    16|@dataclass(frozen=True)
    17|class ReviewPackage:
    18|    """Everything Sir needs to approve, reject, or request changes.
    19|
    20|    This structure is deterministic by design: strategy/rendering code can fill
    21|    it, Telegram code can send the rendered text, and publishing code can wait
    22|    for an explicit parsed approval decision.
    23|    """
    24|
    25|    candidate_id: str
    26|    title: str
    27|    pillar: str
    28|    duration_seconds: int
    29|    hook: str
    30|    script_summary: str
    31|    visual_concept: str
    32|    caption: str
    33|    hashtags: tuple[str, ...]
    34|    platforms: tuple[str, ...]
    35|    quality_score: ScriptQualityScore
    36|    policy_notes: tuple[str, ...] = ()
    37|    preview_path: str | None = None
    38|
    39|
    40|def _join_or_dash(items: tuple[str, ...]) -> str:
    41|    return "; ".join(items) if items else "-"
    42|
    43|
    44|def _platform_names(platforms: tuple[str, ...]) -> str:
    45|    labels = [_PLATFORM_LABELS.get(platform, platform) for platform in platforms]
    46|    return ", ".join(labels) if labels else "-"
    47|
    48|
    49|def _approval_commands(platforms: tuple[str, ...]) -> list[str]:
    50|    commands = ["Freigabe alle"]
    51|    for platform in platforms:
    52|        label = _PLATFORM_LABELS.get(platform, platform.title())
    53|        commands.append(f"Freigabe {label}")
    54|    commands.extend(["Ablehnen", "Ändern: <Wunschänderung>"])
    55|    return commands
    56|
    57|
    58|def render_telegram_review_package(package: ReviewPackage) -> str:
    59|    """Render a concise Telegram-friendly review message."""
    60|
    61|    score = package.quality_score
    62|    render_allowed = should_render(score)
    63|    gate_line = "Render gate: READY" if render_allowed else "Render gate: BLOCKED — nicht automatisch rendern"
    64|    hashtags = " ".join(package.hashtags) if package.hashtags else "-"
    65|    preview = package.preview_path or "-"
    66|    commands = "\n".join(f"- `{command}`" for command in _approval_commands(package.platforms))
    67|
    68|    return "\n".join(
    69|        [
    70|            f"## Review: {package.candidate_id}",
    71|            f"Title: {package.title}",
    72|            f"Pillar: {package.pillar}",
    73|            f"Platforms: {_platform_names(package.platforms)}",
    74|            f"Duration: {package.duration_seconds}s",
    75|            "",
    76|            f"Hook: {package.hook}",
    77|            f"Script: {package.script_summary}",
    78|            f"Visual: {package.visual_concept}",
    79|            f"Caption: {package.caption}",
    80|            f"Hashtags: {hashtags}",
    81|            "",
    82|            f"Quality: {score.average_quality:.1f}/10",
    83|            f"AI-slop risk: {score.ai_slop_risk}/10",
    84|            f"Policy: {_join_or_dash(package.policy_notes)}",
    85|            gate_line,
    86|            f"Preview: {preview}",
    87|            "",
    88|            "Approval commands:",
    89|            commands,
    90|        ]
    91|    )
    92|


Explanation:
**Considering test implementation**

I’m thinking about implementing new modules without causing disturbances. I know I need Test Driven Development (TDD), so I'll start by writing several tests in one file. I have to figure out the import style first. I'm considering creating the file autoshorts/strategy/spine_v2.py. If the tests import anything missing, they'll fail, so I need to ensure everything’s implemented. Comprehensive tests are a must, and each test will need a helper for valid candidates.

import pytest

from autoshorts.strategy.spine_v2 import (
    AIDisclosureDecision,
    AudienceNeed,
    ClaimRisk,
    ContentBriefV2,
    ExperimentHypothesis,
    HumanTexture,
    ReviewPackageV2,
    SaturationRisk,
    SeriesRegistry,
    StrategyScoreV2,
    VideoCandidateV2,
    ViewerEmotion,
    build_demo_candidates_v2,
    build_review_package_v2,
    render_review_package_v2,
    validate_candidate_v2,
    validate_review_package_v2,
)

def make_hypothesis() -> ExperimentHypothesis:
    return ExperimentHypothesis(
        statement="Tests whether visible decision-target fixes drive saves.",
        expected_winning_metric="saves_per_view",
    )

def make_brief(**overrides) -> ContentBriefV2:
    data = dict(
        brief_id="decision-summary-brief",
        series_id="decision_design",
        topic="AI summaries without a decision target",
        audience_need=AudienceNeed.CLARITY,
        job_to_be_done="When I review a meeting, I want decision evidence, so I can choose the next action.",
        viewer_identity="office_worker",
        emotion_before=ViewerEmotion.CONFUSED,
        emotion_after=ViewerEmotion.IN_CONTROL,
        audience_problem="AI summaries hide the one decision that matters.",
        payoff="Add a decision target before asking for the summary.",
        search_intent_phrase="better meeting summary prompt",
        saturation_risk=SaturationRisk.MEDIUM,
        human_texture=HumanTexture.MINI_CASE,
        claim_risk=ClaimRisk.LOW,
        ai_disclosure_required=AIDisclosureDecision.NO,
        experiment_hypothesis=make_hypothesis(),
        platforms=("youtube", "tiktok", "instagram"),
        target_duration_seconds=38,
    )
    data.update(overrides)
    return ContentBriefV2(**data)

def make_candidate(**overrides) -> VideoCandidateV2:
    data = dict(
        candidate_id="decision-design-summary-target-38s",
        version="v2.0.0",
        title="Your AI summary has no decision target",
        series_id="decision_design",
        platforms=("youtube", "tiktok", "instagram"),
        duration_seconds=38,
        audience_need=AudienceNeed.CLARITY,
        job_to_be_done="When I review a meeting, I want decision evidence, so I can choose the next action.",
        viewer_identity="office_worker",
        emotion_before=ViewerEmotion.CONFUSED,
        emotion_after=ViewerEmotion.IN_CONTROL,
        hook="Your AI summary is useless because it has no decision target.",
        first_frame="Split-screen: 47-minute transcript vs. useless AI summary stamped USELESS.",
        visible_fail="The AI summary says 'great discussion' but misses the launch decision.",
        mechanism="Without a decision target, AI compresses every sentence equally.",
        fix="Before summarizing, state the decision and ask for blockers, owners, risks, and next steps.",
        before_after="Before: generic summary. After: decision evidence with risks and owner.",
        takeaway="Don't ask for a summary. Ask for decision evidence.",
        save_reason="Viewer can copy the decision-target sentence into the next meeting summary prompt.",
        share_reason="Every office worker has seen useless meeting summaries.",
        follow_reason="Follow for small systems that stop digital work from wasting your life.",
        search_intent_phrase="better meeting summary prompt",
        saturation_risk=SaturationRisk.MEDIUM,
        human_texture=HumanTexture.MINI_CASE,
        claim_risk=ClaimRisk.LOW,
        claims=("AI summaries need a decision target to preserve relevance.",),
        ai_disclosure_required=AIDisclosureDecision.NO,
        experiment_hypothesis=make_hypothesis(),
        expected_winning_metric="saves_per_view",
        platform_fit={"youtube": "strong", "tiktok": "strong", "instagram": "medium"},
        risks=("medium saturation",),
        visual_structure="motion-led mechanism funnel with transcript cards, not slideshow/text cards",
    )
    data.update(overrides)
    return VideoCandidateV2(**data)

def test_content_brief_v2_requires_strategic_spine_fields():
    brief = make_brief(series_id="", save_reason="") if False else None
    with pytest.raises(TypeError):
        ContentBriefV2(brief_id="missing-required")

    invalid = make_candidate(save_reason="")
    result = validate_candidate_v2(invalid)

    assert not result.is_valid
    assert "save_reason is required" in result.errors

def test_quality_gates_block_missing_mechanism_and_first_frame_conflict():
    candidate = make_candidate(mechanism="", first_frame="Plain title card with text only")

    result = validate_candidate_v2(candidate)

    assert not result.is_valid
    assert "mechanism is required" in result.errors
    assert "visible first-frame conflict is required" in result.errors
    assert "candidate is not renderable until all hard gates pass" in result.errors

def test_anti_ai_slop_blocks_generic_hooks_and_slideshow_structure():
    candidate = make_candidate(
        title="Top 5 AI Tools That Will Change Your Life",
        hook="Nobody talks about this AI tool will change your life.",
        visual_structure="pure slideshow with text cards and generic AI background",
        human_texture=HumanTexture.NONE,
    )

    result = validate_candidate_v2(candidate)

    assert not result.is_valid
    assert "generic AI-slop hook/title detected" in result.errors
    assert "videos without concrete human texture are blocked" in result.errors
    assert "pure slideshow/text-card structure is blocked" in result.errors

def test_strategy_score_v2_uses_100_point_model_and_blocks_low_scores():
    candidate = make_candidate()
    score = StrategyScoreV2.from_candidate(candidate)

    assert score.total == 90
    assert score.render_allowed
    assert score.problem_urgency == 15
    assert score.practical_payoff == 15

    weak = StrategyScoreV2.from_candidate(make_candidate(save_reason="", share_reason="", follow_reason=""))
    assert weak.total < 80
    assert not weak.render_allowed
    assert "share/save/follow potential below threshold" in weak.reject_reasons

def test_series_registry_accepts_new_series_and_rejects_unknown():
    registry = SeriesRegistry.default()

    assert registry.validate_series_id("digital_red_flags").is_valid
    assert registry.get("attention_traps").name == "Attention Traps"
    assert not registry.validate_series_id("top_ai_tools").is_valid

def test_review_package_v2_contains_hashes_strategy_risk_and_approval_command():
    candidate = make_candidate(media_hash="abc123")

    package = build_review_package_v2(candidate)
    validation = validate_review_package_v2(package)
    text = render_review_package_v2(package)

    assert validation.is_valid
    assert isinstance(package, ReviewPackageV2)
    assert package.script_hash
    assert package.media_hash == "abc123"
    assert "Candidate ID: decision-design-summary-target-38s" in text
    assert "Series: Decision Design" in text
    assert "Visible Fail:" in text
    assert "AI Disclosure: no" in text
    assert "Experiment Hypothesis:" in text
    assert "APPROVE decision-design-summary-target-38s v2.0.0" in text

def test_approval_command_is_version_and_hash_bound():
    package = build_review_package_v2(make_candidate())

    command = package.approval_command

    assert command.startswith("APPROVE decision-design-summary-target-38s v2.0.0 ")
    assert package.script_hash in command

def test_build_demo_candidates_v2_returns_one_candidate_per_requested_series():
    candidates = build_demo_candidates_v2()

    assert [candidate.series_id for candidate in candidates] == [
        "ai_output_autopsy",
        "digital_red_flags",
        "decision_design",
        "workflow_teardown",
        "attention_traps",
    ]
    assert all(validate_candidate_v2(candidate).is_valid for candidate in candidates)
    assert all(StrategyScoreV2.from_candidate(candidate).render_allowed for candidate in candidates)


LSP diagnostics introduced by this edit:
<diagnostics file="/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/tests/test_strategy_spine_v2.py">
ERROR [3:6] Import "autoshorts.strategy.spine_v2" could not be resolved [reportMissingImports] (Pyright)
</diagnostics>


python -m pytest tests/test_strategy_spine_v2.py -q


Output:
==================================== ERRORS ====================================
_______________ ERROR collecting tests/test_strategy_spine_v2.py _______________
ImportError while importing test module '/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/tests/test_strategy_spine_v2.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
../../../.local/share/uv/python/cpython-3.11.15-linux-x86_64-gnu/lib/python3.11/importlib/__init__.py:126: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
tests/test_strategy_spine_v2.py:3: in <module>
    from autoshorts.strategy.spine_v2 import (
E   ModuleNotFoundError: No module named 'autoshorts.strategy.spine_v2'
=========================== short test summary info ============================
ERROR tests/test_strategy_spine_v2.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.15s


Explanation:
**Implementing validation logic**

I need to import `ValidationResult`, along with dataclasses and enums. The goal is to set up validation, ensuring a score of exactly 90 is valid. Now, I'm thinking about how to calculate that—perhaps I'll assign full except for saturation 1? I’ll implement it. I’ve noticed the test has a strange variable that isn’t using `ContentBrief` for validation, plus it expects that saving is missing in the candidate. I also need to make `media_hash` optional in the `VideoCandidate` fields.

"""Strategic Spine V2 models for TrueTraceShorts.

This module keeps the new editorial strategy side-effect free: it defines typed
candidate artifacts, hard pre-render gates, strategy scoring, review package v2,
and deterministic demo candidates. No rendering, platform APIs, Telegram sends,
or file writes live here.
"""

from __future__ import annotations

from dataclasses import dataclass
from enum import StrEnum
import hashlib
import re

from autoshorts.ideas.candidate import SUPPORTED_PLATFORMS, ValidationResult

class AudienceNeed(StrEnum):
    BOREDOM = "boredom"
    URGENCY = "urgency"
    FEAR = "fear"
    COMPETENCE = "competence"
    CLARITY = "clarity"
    STATUS = "status"
    SAFETY = "safety"
    CURIOSITY = "curiosity"

class ViewerEmotion(StrEnum):
    CONFUSED = "confused"
    ANNOYED = "annoyed"
    OVERWHELMED = "overwhelmed"
    SKEPTICAL = "skeptical"
    CURIOUS = "curious"
    EMBARRASSED = "embarrassed"
    ANXIOUS = "anxious"
    RELIEVED = "relieved"
    SMARTER = "smarter"
    IN_CONTROL = "in_control"
    WARNED = "warned"
    MOTIVATED = "motivated"
    EQUIPPED = "equipped"

class SaturationRisk(StrEnum):
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"

class HumanTexture(StrEnum):
    NONE = "none"
    REAL_EXAMPLE = "real_example"
    MINI_CASE = "mini_case"
    TESTED_PROMPT = "tested_prompt"
    SCREEN_RECORDING = "screen_recording"
    PERSONAL_OBSERVATION = "personal_observation"
    SOURCE_BASED_CLAIM = "source_based_claim"

class ClaimRisk(StrEnum):
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"

class AIDisclosureDecision(StrEnum):
    YES = "yes"
    NO = "no"
    UNCLEAR = "unclear"

@dataclass(frozen=True)
class ExperimentHypothesis:
    statement: str
    expected_winning_metric: str

@dataclass(frozen=True)
class ContentBriefV2:
    brief_id: str
    series_id: str
    topic: str
    audience_need: AudienceNeed
    job_to_be_done: str
    viewer_identity: str
    emotion_before: ViewerEmotion
    emotion_after: ViewerEmotion
    audience_problem: str
    payoff: str
    search_intent_phrase: str
    saturation_risk: SaturationRisk
    human_texture: HumanTexture
    claim_risk: ClaimRisk
    ai_disclosure_required: AIDisclosureDecision
    experiment_hypothesis: ExperimentHypothesis
    platforms: tuple[str, ...]
    target_duration_seconds: int

@dataclass(frozen=True)
class SeriesDefinition:
    series_id: str
    name: str
    promise: str
    allowed_topic_clusters: tuple[str, ...]
    default_hook_pattern: str

@dataclass(frozen=True)
class SeriesRegistry:
    series: tuple[SeriesDefinition, ...]

    @classmethod
    def default(cls) -> "SeriesRegistry":
        return cls(
            series=(
                SeriesDefinition(
                    "ai_output_autopsy",
                    "AI Output Autopsy",
                    "Bad AI output -> hidden failure -> one rule.",
                    ("ai", "workflow", "decision"),
                    "This AI answer sounds right. That's the problem.",
                ),
                SeriesDefinition(
                    "digital_red_flags",
                    "Digital Red Flags",
                    "Normal-looking digital moment -> one warning signal.",
                    ("scam", "security", "digital_safety"),
                    "This looks safe. The trap is in line three.",
                ),
                SeriesDefinition(
                    "decision_design",
                    "Decision Design",
                    "Turn vague tool questions into decision evidence.",
                    ("decision", "worklife", "ai"),
                    "Don't ask what to do. Ask what would change your decision.",
                ),
                SeriesDefinition(
                    "workflow_teardown",
                    "Workflow Teardown",
                    "Broken workflow -> missing boundary/owner/exception path.",
                    ("workflow", "automation", "worklife"),
                    "Your workflow didn't fail. You forgot the exception path.",
                ),
                SeriesDefinition(
                    "attention_traps",
                    "Attention Traps",
                    "Everyday digital behavior -> attention mechanism -> control rule.",
                    ("attention", "productivity", "digital_habits"),
                    "You didn't lose focus. The app moved your next thought.",
                ),
                SeriesDefinition(
                    "search_smarter",
                    "Search Smarter",
                    "Platform search mistake -> better intent phrase.",
                    ("search", "algorithm", "literacy"),
                    "TikTok search is not Google. Stop typing like it is.",
                ),
                SeriesDefinition(
                    "worklife_systems",
                    "Worklife Systems",
                    "Worklife friction -> small system rule.",
                    ("meetings", "email", "calendar", "todos"),
                    "Your to-do list is not a plan. It's a guilt archive.",
                ),
            )
        )

    def get(self, series_id: str) -> SeriesDefinition:
        for series in self.series:
            if series.series_id == series_id:
                return series
        raise KeyError(series_id)

    def validate_series_id(self, series_id: str) -> ValidationResult:
        if not _is_blank(series_id) and any(series.series_id == series_id for series in self.series):
            return ValidationResult(is_valid=True)
        return ValidationResult(is_valid=False, errors=(f"unknown series_id: {series_id}",))

@dataclass(frozen=True)
class VideoCandidateV2:
    candidate_id: str
    version: str
    title: str
    series_id: str
    platforms: tuple[str, ...]
    duration_seconds: int
    audience_need: AudienceNeed
    job_to_be_done: str
    viewer_identity: str
    emotion_before: ViewerEmotion
    emotion_after: ViewerEmotion
    hook: str
    first_frame: str
    visible_fail: str
    mechanism: str
    fix: str
    before_after: str
    takeaway: str
    save_reason: str
    share_reason: str
    follow_reason: str
    search_intent_phrase: str
    saturation_risk: SaturationRisk
    human_texture: HumanTexture
    claim_risk: ClaimRisk
    claims: tuple[str, ...]
    ai_disclosure_required: AIDisclosureDecision
    experiment_hypothesis: ExperimentHypothesis
    expected_winning_metric: str
    platform_fit: dict[str, str]
    risks: tuple[str, ...]
    visual_structure: str
    media_hash: str | None = None

GENERIC_AI_SLOP_PATTERNS = (
    r"\btop\s*5\s+ai\s+tools\b",
    r"\bmake\s+money\s+with\s+ai\b",
    r"\bthis\s+ai\s+tool\s+will\s+change\s+your\s+life\b",
    r"\bnobody\s+(talks|is\s+talking)\s+about\s+this\b",
    r"\bunlock\s+your\s+(full\s+)?potential\b",
    r"\byou\s+won'?t\s+believe\b",
    r"\bbest\s+ai\s+tools\b",
)

SLIDESHOW_PATTERNS = (
    "pure slideshow",
    "text cards only",
    "only text cards",
    "generic ai background",
    "b-roll plus captions",
)

FIRST_FRAME_CONFLICT_TERMS = (
    "split-screen",
    "vs.",
    " vs ",
    "stamp",
    "stamped",
    "warning",
    "red flag",
    "trap",
    "useless",
    "broken",
    "fails",
    "conflict",
)

def _is_blank(value: str | None) -> bool:
    return not (value or "").strip()

def _enum_value(value: object) -> str:
    return value.value if isinstance(value, StrEnum) else str(value)

def _combined_text(candidate: VideoCandidateV2) -> str:
    return " ".join(
        (
            candidate.title,
            candidate.hook,
            candidate.first_frame,
            candidate.visible_fail,
            candidate.mechanism,
            candidate.fix,
            candidate.before_after,
            candidate.takeaway,
            candidate.visual_structure,
        )
    ).casefold()

def _has_visible_first_frame_conflict(first_frame: str) -> bool:
    text = (first_frame or "").casefold()
    if _is_blank(text):
        return False
    return any(term in text for term in FIRST_FRAME_CONFLICT_TERMS) and "plain title card" not in text

def _has_generic_ai_slop(text: str) -> bool:
    return any(re.search(pattern, text) for pattern in GENERIC_AI_SLOP_PATTERNS)

def _is_slideshow_structure(visual_structure: str) -> bool:
    text = (visual_structure or "").casefold()
    return any(pattern in text for pattern in SLIDESHOW_PATTERNS) and "motion-led" not in text

def validate_candidate_v2(
    candidate: VideoCandidateV2,
    *,
    registry: SeriesRegistry | None = None,
) -> ValidationResult:
    registry = registry or SeriesRegistry.default()
    errors: list[str] = []

    required_fields = {
        "candidate_id": candidate.candidate_id,
        "version": candidate.version,
        "title": candidate.title,
        "series_id": candidate.series_id,
        "job_to_be_done": candidate.job_to_be_done,
        "viewer_identity": candidate.viewer_identity,
        "hook": candidate.hook,
        "first_frame": candidate.first_frame,
        "visible_fail": candidate.visible_fail,
        "mechanism": candidate.mechanism,
        "fix": candidate.fix,
        "before_after": candidate.before_after,
        "takeaway": candidate.takeaway,
        "save_reason": candidate.save_reason,
        "share_reason": candidate.share_reason,
        "follow_reason": candidate.follow_reason,
        "search_intent_phrase": candidate.search_intent_phrase,
        "expected_winning_metric": candidate.expected_winning_metric,
        "visual_structure": candidate.visual_structure,
    }
    for field_name, value in required_fields.items():
        if _is_blank(value):
            errors.append(f"{field_name} is required")

    if not candidate.platforms:
        errors.append("at least one platform is required")
    for platform in candidate.platforms:
        if platform not in SUPPORTED_PLATFORMS:
            errors.append(f"unsupported platform: {platform}")
        if platform not in candidate.platform_fit:
            errors.append(f"platform_fit missing for {platform}")

    if not 10 <= candidate.duration_seconds <= 180:
        errors.append("duration_seconds must be between 10 and 180")

    series_validation = registry.validate_series_id(candidate.series_id)
    if not series_validation.is_valid:
        errors.extend(series_validation.errors)
    else:
        errors.append("") if False else None

    if not _has_visible_first_frame_conflict(candidate.first_frame):
        errors.append("visible first-frame conflict is required")

    if candidate.human_texture == HumanTexture.NONE:
        errors.append("videos without concrete human texture are blocked")

    if candidate.ai_disclosure_required == AIDisclosureDecision.UNCLEAR:
        errors.append("AI disclosure decision cannot be unclear before rendering")

    if candidate.claim_risk == ClaimRisk.HIGH and not candidate.claims:
        errors.append("high claim risk requires documented claims")
    if not candidate.claims:
        errors.append("at least one claim or claim note is required")

    text = _combined_text(candidate)
    if _has_generic_ai_slop(text):
        errors.append("generic AI-slop hook/title detected")
    if _is_slideshow_structure(candidate.visual_structure):
        errors.append("pure slideshow/text-card structure is blocked")

    hard_gate_fields = (
        candidate.visible_fail,
        candidate.first_frame,
        candidate.mechanism,
        candidate.fix,
        candidate.before_after,
        candidate.takeaway,
        candidate.save_reason,
        candidate.share_reason,
        candidate.follow_reason,
    )
    gate_failed = any(_is_blank(value) for value in hard_gate_fields)
    gate_failed = gate_failed or not _has_visible_first_frame_conflict(candidate.first_frame)
    gate_failed = gate_failed or not series_validation.is_valid
    gate_failed = gate_failed or candidate.ai_disclosure_required == AIDisclosureDecision.UNCLEAR
    gate_failed = gate_failed or not candidate.claim_risk
    if gate_failed:
        errors.append("candidate is not renderable until all hard gates pass")

    return ValidationResult(is_valid=not errors, errors=tuple(errors))

@dataclass(frozen=True)
class StrategyScoreV2:
    problem_urgency: int
    first_frame_clarity: int
    hook_curiosity: int
    mechanism_value: int
    practical_payoff: int
    retention_path: int
    share_save_potential: int
    trust_human_texture: int
    series_fit: int
    saturation_defense: int
    reject_reasons: tuple[str, ...] = ()

    @property
    def total(self) -> int:
        return sum(
            (
                self.problem_urgency,
                self.first_frame_clarity,
                self.hook_curiosity,
                self.mechanism_value,
                self.practical_payoff,
                self.retention_path,
                self.share_save_potential,
                self.trust_human_texture,
                self.series_fit,
                self.saturation_defense,
            )
        )

    @property
    def render_allowed(self) -> bool:
        return self.total >= 80 and not self.reject_reasons

    @classmethod
    def from_candidate(cls, candidate: VideoCandidateV2) -> "StrategyScoreV2":
        validation = validate_candidate_v2(candidate)
        text = _combined_text(candidate)
        reject_reasons: list[str] = []

        problem_urgency = 15 if candidate.visible_fail and candidate.audience_need else 0
        first_frame_clarity = 12 if _has_visible_first_frame_conflict(candidate.first_frame) else 0
        hook_curiosity = 12 if ("because" in candidate.hook.casefold() or "?" in candidate.hook or "trap" in text) else 6
        mechanism_value = 12 if candidate.mechanism else 0
        practical_payoff = 15 if candidate.fix and candidate.takeaway else 0
        retention_path = 10 if candidate.before_after and "motion-led" in candidate.visual_structure.casefold() else 6
        share_save_potential = 10 if candidate.save_reason and candidate.share_reason and candidate.follow_reason else 0
        trust_human_texture = 8 if candidate.human_texture != HumanTexture.NONE else 0
        series_fit = 4 if SeriesRegistry.default().validate_series_id(candidate.series_id).is_valid else 0
        saturation_defense = 2 if candidate.saturation_risk != SaturationRisk.HIGH else 0

        if share_save_potential < 10:
            reject_reasons.append("share/save/follow potential below threshold")
        if not validation.is_valid:
            reject_reasons.extend(validation.errors)
        if _has_generic_ai_slop(text):
            reject_reasons.append("generic AI-slop detected")

        return cls(
            problem_urgency=problem_urgency,
            first_frame_clarity=first_frame_clarity,
            hook_curiosity=hook_curiosity,
            mechanism_value=mechanism_value,
            practical_payoff=practical_payoff,
            retention_path=retention_path,
            share_save_potential=share_save_potential,
            trust_human_texture=trust_human_texture,
            series_fit=series_fit,
            saturation_defense=saturation_defense,
            reject_reasons=tuple(dict.fromkeys(reject_reasons)),
        )

@dataclass(frozen=True)
class ReviewPackageV2:
    candidate_id: str
    version: str
    script_hash: str
    media_hash: str | None
    series: str
    audience_need: AudienceNeed
    emotion_before: ViewerEmotion
    emotion_after: ViewerEmotion
    hook: str
    first_frame: str
    visible_fail: str
    mechanism: str
    fix: str
    before_after: str
    takeaway: str
    save_reason: str
    share_reason: str
    follow_reason: str
    claims: tuple[str, ...]
    claim_risk: ClaimRisk
    ai_disclosure: AIDisclosureDecision
    platform_fit: dict[str, str]
    risks: tuple[str, ...]
    experiment_hypothesis: ExperimentHypothesis
    expected_winning_metric: str
    approval_command: str
    score: StrategyScoreV2

def _script_hash(candidate: VideoCandidateV2) -> str:
    payload = "\n".join(
        (
            candidate.title,
            candidate.hook,
            candidate.first_frame,
            candidate.visible_fail,
            candidate.mechanism,
            candidate.fix,
            candidate.before_after,
            candidate.takeaway,
            candidate.save_reason,
            candidate.share_reason,
            candidate.follow_reason,
        )
    )
    return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]

def build_review_package_v2(candidate: VideoCandidateV2) -> ReviewPackageV2:
    script_hash = _script_hash(candidate)
    series_name = SeriesRegistry.default().get(candidate.series_id).name
    command = f"APPROVE {candidate.candidate_id} {candidate.version} {script_hash}"
    return ReviewPackageV2(
        candidate_id=candidate.candidate_id,
        version=candidate.version,
        script_hash=script_hash,
        media_hash=candidate.media_hash,
        series=series_name,
        audience_need=candidate.audience_need,
        emotion_before=candidate.emotion_before,
        emotion_after=candidate.emotion_after,
        hook=candidate.hook,
        first_frame=candidate.first_frame,
        visible_fail=candidate.visible_fail,
        mechanism=candidate.mechanism,
        fix=candidate.fix,
        before_after=candidate.before_after,
        takeaway=candidate.takeaway,
        save_reason=candidate.save_reason,
        share_reason=candidate.share_reason,
        follow_reason=candidate.follow_reason,
        claims=candidate.claims,
        claim_risk=candidate.claim_risk,
        ai_disclosure=candidate.ai_disclosure_required,
        platform_fit=candidate.platform_fit,
        risks=candidate.risks,
        experiment_hypothesis=candidate.experiment_hypothesis,
        expected_winning_metric=candidate.expected_winning_metric,
        approval_command=command,
        score=StrategyScoreV2.from_candidate(candidate),
    )

def validate_review_package_v2(package: ReviewPackageV2) -> ValidationResult:
    errors: list[str] = []
    for field_name, value in {
        "candidate_id": package.candidate_id,
        "version": package.version,
        "script_hash": package.script_hash,
        "series": package.series,
        "hook": package.hook,
        "first_frame": package.first_frame,
        "visible_fail": package.visible_fail,
        "mechanism": package.mechanism,
        "fix": package.fix,
        "before_after": package.before_after,
        "takeaway": package.takeaway,
        "save_reason": package.save_reason,
        "share_reason": package.share_reason,
        "follow_reason": package.follow_reason,
        "expected_winning_metric": package.expected_winning_metric,
        "approval_command": package.approval_command,
    }.items():
        if _is_blank(value):
            errors.append(f"{field_name} is required")
    if not package.claims:
        errors.append("claims are required")
    if package.ai_disclosure == AIDisclosureDecision.UNCLEAR:
        errors.append("AI disclosure cannot be unclear")
    if package.script_hash not in package.approval_command:
        errors.append("approval command must include script hash")
    return ValidationResult(is_valid=not errors, errors=tuple(errors))

def render_review_package_v2(package: ReviewPackageV2) -> str:
    claims = "; ".join(package.claims) if package.claims else "-"
    risks = "; ".join(package.risks) if package.risks else "-"
    platform_fit = "; ".join(f"{platform}: {fit}" for platform, fit in package.platform_fit.items())
    media_hash = package.media_hash or "-"
    return "\n".join(
        [
            f"## ReviewPackageV2: {package.candidate_id}",
            f"Candidate ID: {package.candidate_id}",
            f"Version: {package.version}",
            f"Script Hash: {package.script_hash}",
            f"Media Hash: {media_hash}",
            f"Series: {package.series}",
            f"Audience Need: {_enum_value(package.audience_need)}",
            f"Viewer Emotion Before: {_enum_value(package.emotion_before)}",
            f"Viewer Emotion After: {_enum_value(package.emotion_after)}",
            "",
            f"Hook: {package.hook}",
            f"First Frame: {package.first_frame}",
            f"Visible Fail: {package.visible_fail}",
            f"Mechanism: {package.mechanism}",
            f"Fix: {package.fix}",
            f"Before/After: {package.before_after}",
            f"Takeaway: {package.takeaway}",
            "",
            f"Save Reason: {package.save_reason}",
            f"Share Reason: {package.share_reason}",
            f"Follow Reason: {package.follow_reason}",
            f"Claims: {claims}",
            f"Claim Risk: {_enum_value(package.claim_risk)}",
            f"AI Disclosure: {_enum_value(package.ai_disclosure)}",
            f"Platform Fit: {platform_fit}",
            f"Risks: {risks}",
            f"Experiment Hypothesis: {package.experiment_hypothesis.statement}",
            f"Expected Winning Metric: {package.expected_winning_metric}",
            f"ScoreV2: {package.score.total}/100",
            "",
            f"Approval Command: `{package.approval_command}`",
            f"Reject Command: `REJECT {package.candidate_id} <reason>`",
            f"Revise Command: `REVISE {package.candidate_id} <field> <requested change>`",
        ]
    )

def _hypothesis(statement: str, metric: str = "saves_per_view") -> ExperimentHypothesis:
    return ExperimentHypothesis(statement=statement, expected_winning_metric=metric)

def build_demo_candidates_v2() -> tuple[VideoCandidateV2, ...]:
    common = dict(
        version="v2.0.0",
        platforms=("youtube", "tiktok", "instagram"),
        duration_seconds=38,
        viewer_identity="office_worker",
        emotion_before=ViewerEmotion.CONFUSED,
        emotion_after=ViewerEmotion.IN_CONTROL,
        saturation_risk=SaturationRisk.MEDIUM,
        claim_risk=ClaimRisk.LOW,
        ai_disclosure_required=AIDisclosureDecision.NO,
        expected_winning_metric="saves_per_view",
        platform_fit={"youtube": "strong", "tiktok": "strong", "instagram": "medium"},
    )
    return (
        VideoCandidateV2(
            **common,
            candidate_id="ai-output-autopsy-decision-summary-38s",
            title="Your AI summary has no decision target",
            series_id="ai_output_autopsy",
            audience_need=AudienceNeed.CLARITY,
            job_to_be_done="When I summarize a meeting, I want decision evidence, so I can act without rereading everything.",
            hook="Your AI summary is useless because it has no decision target.",
            first_frame="Split-screen: 47-minute transcript vs. useless AI summary stamped USELESS.",
            visible_fail="The summary says 'great discussion' but misses the launch decision.",
            mechanism="Without a decision target, AI compresses every sentence equally.",
            fix="Ask for blockers, owners, risks, and next steps tied to the decision.",
            before_after="Before: generic summary. After: decision evidence with owner and risk.",
            takeaway="Don't ask for a summary. Ask for decision evidence.",
            save_reason="Copyable sentence for the next meeting summary.",
            share_reason="Colleagues recognize the useless-summary problem.",
            follow_reason="Follow for small systems that make AI output useful.",
            search_intent_phrase="better meeting summary prompt",
            human_texture=HumanTexture.MINI_CASE,
            claims=("Decision-targeted summaries preserve relevance better than generic summaries.",),
            experiment_hypothesis=_hypothesis("Tests whether decision-target AI examples drive saves."),
            risks=("medium AI-tip saturation",),
            visual_structure="motion-led compression funnel with transcript cards and decision target",
        ),
        VideoCandidateV2(
            **common,
            candidate_id="digital-red-flags-fake-invoice-35s",
            title="This fake invoice looks normal",
            series_id="digital_red_flags",
            audience_need=AudienceNeed.SAFETY,
            job_to_be_done="When I receive an invoice, I want to spot pressure cues, so I do not pay a fake bill.",
            emotion_before=ViewerEmotion.ANXIOUS,
            emotion_after=ViewerEmotion.WARNED,
            hook="This invoice looks safe. The trap is in line three.",
            first_frame="Split-screen: normal invoice vs. red flag line with urgent payment warning.",
            visible_fail="The recipient trusts the logo but ignores the changed payment route.",
            mechanism="Scam messages borrow familiar context and add urgency before verification.",
            fix="Check sender domain, urgency, and payment route before acting.",
            before_after="Before: logo trust. After: domain, urgency, and bank route check.",
            takeaway="Urgency is not proof. It is the red flag.",
            save_reason="Viewer can reuse the three-check invoice rule.",
            share_reason="Anyone handling invoices can use the warning.",
            follow_reason="Follow for digital red flags before they cost you.",
            search_intent_phrase="how to spot fake invoice",
            human_texture=HumanTexture.REAL_EXAMPLE,
            claims=("Urgency and payment-route changes are common scam warning signs.",),
            experiment_hypothesis=_hypothesis("Tests whether red-flag safety content produces shares."),
            risks=("avoid naming real brands",),
            visual_structure="motion-led red flag scan with timer line and before/after checks",
        ),
        VideoCandidateV2(
            **common,
            candidate_id="decision-design-hidden-decision-38s",
            title="Your question hides the decision",
            series_id="decision_design",
            audience_need=AudienceNeed.CLARITY,
            job_to_be_done="When I ask a tool for advice, I want decision criteria, so I can choose confidently.",
            hook="Your question is bad because it hides the decision.",
            first_frame="Split-screen: vague question vs. highlighted missing decision route.",
            visible_fail="The tool gives advice but cannot know what decision is being protected.",
            mechanism="A vague question asks for an opinion instead of decision evidence.",
            fix="Name the decision, criteria, and what would change your mind.",
            before_after="Before: 'What should I do?' After: decision criteria and disconfirming evidence.",
            takeaway="Don't ask for an opinion. Ask what would change the decision.",
            save_reason="Viewer can reuse the decision-criteria template.",
            share_reason="Teams often ask vague questions before decisions.",
            follow_reason="Follow for decision systems that reduce digital noise.",
            search_intent_phrase="how to make better decisions with AI",
            human_texture=HumanTexture.MINI_CASE,
            claims=("Decision criteria make advice more actionable than vague opinion requests.",),
            experiment_hypothesis=_hypothesis("Tests whether decision design content converts followers."),
            risks=("abstract topic needs concrete example",),
            visual_structure="motion-led decision route with one path lighting up",
        ),
        VideoCandidateV2(
            **common,
            candidate_id="workflow-teardown-exception-path-40s",
            title="Your automation only knows the happy path",
            series_id="workflow_teardown",
            audience_need=AudienceNeed.COMPETENCE,
            job_to_be_done="When I automate a task, I want exceptions handled, so the workflow survives real inputs.",
            emotion_before=ViewerEmotion.ANNOYED,
            emotion_after=ViewerEmotion.EQUIPPED,
            hook="Your automation didn't fail. You forgot the exception path.",
            first_frame="Workflow line breaks at an unexpected email, with a red warning route.",
            visible_fail="The automation works on perfect inputs but stalls on one missing field.",
            mechanism="Happy-path automation assumes clean inputs and has no recovery route.",
            fix="Add an exception path: detect missing fields, ask for clarification, then continue.",
            before_after="Before: broken workflow. After: missing-field route and recovery step.",
            takeaway="If there is no exception path, it is not automation. It is hope.",
            save_reason="Builder can apply the exception-path checklist.",
            share_reason="Every automation builder has seen happy-path failures.",
            follow_reason="Follow for workflow teardowns before you automate the wrong thing.",
            search_intent_phrase="automation exception path example",
            human_texture=HumanTexture.TESTED_PROMPT,
            claims=("Automation reliability depends on handling exceptions, not only happy paths.",),
            experiment_hypothesis=_hypothesis("Tests whether workflow teardown examples attract builders."),
            risks=("avoid exposing private workflows",),
            visual_structure="motion-led broken workflow line with exception route appearing",
        ),
        VideoCandidateV2(
            **common,
            candidate_id="attention-traps-next-thought-35s",
            title="Your phone chooses the next question",
            series_id="attention_traps",
            audience_need=AudienceNeed.CONTROL if hasattr(AudienceNeed, "CONTROL") else AudienceNeed.CLARITY,
            job_to_be_done="When I open my phone, I want to keep my intent, so I do not lose the next 20 minutes.",
            emotion_before=ViewerEmotion.OVERWHELMED,
            emotion_after=ViewerEmotion.IN_CONTROL,
            hook="You didn't lose focus. The app moved your next thought.",
            first_frame="Cursor thought bubble pulled from work note toward app tiles with red attention trap warning.",
            visible_fail="The viewer opens the phone for one task and follows the feed's next question.",
            mechanism="Feeds win by offering the next question before you restate your own intent.",
            fix="Before opening the app, say the one action and the exit condition.",
            before_after="Before: open app, follow feed. After: intent card, one action, exit.",
            takeaway="Set the next question before the app sets it for you.",
            save_reason="Viewer can reuse the one-action exit rule.",
            share_reason="The lost-focus moment is widely relatable.",
            follow_reason="Follow for attention systems that give control back.",
            search_intent_phrase="how to stop getting distracted by phone",
            human_texture=HumanTexture.PERSONAL_OBSERVATION,
            claims=("Clear intent before app use can reduce aimless switching.",),
            experiment_hypothesis=_hypothesis("Tests whether attention-trap framing generates shares."),
            risks=("avoid medical or addiction claims",),
            visual_structure="motion-led attention capture with thought bubble and app tiles",
        ),
    )


LSP diagnostics introduced by this edit:
<diagnostics file="/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/strategy/spine_v2.py">
ERROR [640:15] Argument of type "str | tuple[Literal['youtube'], Literal['tiktok'], Literal['instagram']] | int | dict[str, str]" cannot be assigned to parameter "version" of type "str" in function "__init__"
  Type "str | tuple[Literal['youtube'], Literal['tiktok'], Literal['instagram']] | int | dict[str, str]" is not assignable to type "str"
    "int" is not assignable to "str" [reportArgumentType] (Pyright)
ERROR [640:15] Argument of type "str | tuple[Literal['youtube'], Literal['tiktok'], Literal['instagram']] | int | dict[str, str]" cannot be assigned to parameter "platforms" of type "tuple[str, ...]" in function "__init__"
  Type "str | tuple[Literal['youtube'], Literal['tiktok'], Literal['instagram']] | int | dict[str, str]" is not assignable to type "tuple[str, ...]"
    "int" is not assignable to "tuple[str, ...]" [reportArgumentType] (Pyright)
ERROR [640:15] Argument of type "str | tuple[Literal['youtube'], Literal['tiktok'], Literal['instagram']] | int | dict[str, str]" cannot be assigned to parameter "duration_seconds" of type "int" in function "__init__"
  Type "str | tuple[Literal['youtube'], Literal['tiktok'], Literal['instagram']] | int | dict[str, str]" is not assignable to type "int"
    "str" is not assignable to "int" [reportArgumentType] (Pyright)
ERROR [640:15] Argument of type "str | tuple[Literal['youtube'], Literal['tiktok'], Literal['instagram']] | int | dict[str, str]" cannot be assigned to parameter "viewer_identity" of type "str" in function "__init__"
  Type "str | tuple[Literal['youtube'], Literal['tiktok'], Literal['instagram']] | int | dict[str, str]" is not assignable to type "str"
    "int" is not assignable to "str" [reportArgumentType] (Pyright)
ERROR [640:15] Argument of type "str | tuple[Literal['youtube'], Literal['tiktok'], Literal['instagram']] | int | dict[str, str]" cannot be assigned to parameter "emotion_before" of type "ViewerEmotion" in function "__init__"
  Type "str | tuple[Literal['youtube'], Literal['tiktok'], Literal['instagram']] | int | dict[str, str]" is not assignable to type "ViewerEmotion"
    "int" is not assignable to "ViewerEmotion" [reportArgumentType] (Pyright)
ERROR [640:15] Argument of type "str | tuple[Literal['youtube'], Literal['tiktok'], Literal['instagram']] | int | dict[str, str]" cannot be assigned to parameter "emotion_after" of type "ViewerEmotion" in function "__init__"
  Type "str | tuple[Literal['youtube'], Literal['tiktok'], Literal['instagram']] | int | dict[str, str]" is not assignable to type "ViewerEmotion"
    "int" is not assignable to "ViewerEmotion" [reportArgumentType] (Pyright)
ERROR [640:15] Argument of type "str | tuple[Literal['youtube'], Literal['tiktok'], Literal['instagram']] | int | dict[str, str]" cannot be assigned to parameter "saturation_risk" of type "SaturationRisk" in function "__init__"
  Type "str | tuple[Literal['youtube'], Literal['tiktok'], Literal['instagram']] | int | dict[str, str]" is not assignable to type "SaturationRisk"
    "int" is not assignable to "SaturationRisk" [reportArgumentType] (Pyright)
ERROR [640:15] Argument of type "str | tuple[Literal['youtube'], Literal['tiktok'], Literal['instagram']] | int | dict[str, str]" cannot be assigned to parameter "claim_risk" of type "ClaimRisk" in function "__init__"
  Type "str | tuple[Literal['youtube'], Literal['tiktok'], Literal['instagram']] | int | dict[str, str]" is not assignable to type "ClaimRisk"
    "int" is not assignable to "ClaimRisk" [reportArgumentType] (Pyright)
ERROR [640:15] Argument of type "str | tuple[Literal['youtube'], Literal['tiktok'], Literal['instagram']] | int | dict[str, str]" cannot be assigned to parameter "ai_disclosure_required" of type "AIDisclosureDecision" in function "__init__"
  Type "str | tuple[Literal['youtube'], Literal['tiktok'], Literal['instagram']] | int | dict[s
…[truncated]


Explanation:
**Considering runtime tests**

I’m looking into whether the CONTROL hack works at runtime. It seems like a possibility, but I also need to check if the `hasattr` function is returning false. That's a good indicator to run some tests to confirm how it behaves. This setup might give me clearer insights into whether the hack is functioning as intended or if something else is going on. Testing is key here!