"""Strategic Spine V2 models for TrueTraceShorts.

Side-effect-free editorial spine: typed candidate artifacts, hard gates,
calibrated 100-point scoring, deterministic ReviewPackageV2, and small demo
fixtures. 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 json
import re
from typing import Any, Mapping

from autoshorts.ideas.candidate import ValidationResult

SUPPORTED_PLATFORMS_V2 = ("youtube_shorts", "tiktok", "instagram_reels", "linkedin_manual")
LEGACY_PLATFORM_MAP = {"youtube": "youtube_shorts", "instagram": "instagram_reels", "tiktok": "tiktok"}
ANTI_EXAMPLE_REQUIRED_SERIES = ("ai_output_autopsy", "workflow_teardown", "digital_red_flags", "decision_design")

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"

class RenderBackendCandidate(StrEnum):
    PYTHON_MOTION = "python_motion"
    REMOTION_SPIKE_CANDIDATE = "remotion_spike_candidate"

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

    def to_dict(self) -> dict[str, str]:
        return {"statement": self.statement, "expected_winning_metric": self.expected_winning_metric}

    @classmethod
    def from_dict(cls, data: Mapping[str, Any]) -> "ExperimentHypothesis":
        return cls(statement=str(data["statement"]), expected_winning_metric=str(data["expected_winning_metric"]))

@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[Any, ...]
    ai_disclosure_required: AIDisclosureDecision
    experiment_hypothesis: ExperimentHypothesis
    expected_winning_metric: str
    platform_fit: dict[str, str]
    risks: tuple[str, ...]
    visual_structure: str
    anti_example: str = ""
    plausible_nonsense_example: str = ""
    concrete_scene: str = ""
    human_texture_note: str = ""
    linkedin_title: str = ""
    linkedin_caption: str = ""
    render_backend_candidate: RenderBackendCandidate | str = RenderBackendCandidate.PYTHON_MOTION
    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", "breaks", "fails", "conflict", "pulled")

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 _platform_id(platform: str) -> str:
    return LEGACY_PLATFORM_MAP.get(platform, platform)

def _claim_text(claim: Any) -> str:
    return str(getattr(claim, "text", claim))

def _combined_text(candidate: VideoCandidateV2) -> str:
    return " ".join((candidate.title, candidate.hook, candidate.first_frame, candidate.visible_fail, candidate.anti_example, candidate.plausible_nonsense_example, candidate.concrete_scene, 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 candidate.series_id in ANTI_EXAMPLE_REQUIRED_SERIES and _is_blank(candidate.anti_example) and _is_blank(candidate.plausible_nonsense_example):
        errors.append("anti_example is required for this series")
    if candidate.series_id == "attention_traps" and _is_blank(candidate.concrete_scene) and _is_blank(candidate.anti_example):
        errors.append("concrete_scene is required for attention_traps")

    if candidate.human_texture != HumanTexture.NONE and _is_blank(candidate.human_texture_note):
        errors.append("human_texture_note is required")

    if not candidate.platforms:
        errors.append("at least one platform is required")
    for platform in candidate.platforms:
        platform_id = _platform_id(platform)
        if platform_id not in SUPPORTED_PLATFORMS_V2:
            errors.append(f"unsupported platform: {platform}")
        if platform_id not in candidate.platform_fit:
            errors.append(f"platform_fit missing for {platform_id}")
    if "linkedin_manual" in tuple(_platform_id(p) for p in candidate.platforms):
        if _is_blank(candidate.linkedin_caption):
            errors.append("linkedin_caption is required for linkedin_manual")
        if _is_blank(candidate.linkedin_title):
            errors.append("linkedin_title is required for linkedin_manual")

    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)

    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_failed = any(
        _is_blank(value)
        for value in (candidate.visible_fail, candidate.first_frame, candidate.mechanism, candidate.fix, candidate.before_after, candidate.takeaway, candidate.save_reason, candidate.share_reason, candidate.follow_reason)
    )
    hard_gate_failed = hard_gate_failed or not _has_visible_first_frame_conflict(candidate.first_frame)
    hard_gate_failed = hard_gate_failed or not series_validation.is_valid
    hard_gate_failed = hard_gate_failed or candidate.ai_disclosure_required == AIDisclosureDecision.UNCLEAR
    hard_gate_failed = hard_gate_failed or not candidate.claim_risk
    if hard_gate_failed:
        errors.append("candidate is not renderable until all hard gates pass")

    return ValidationResult(is_valid=not errors, errors=tuple(dict.fromkeys(errors)))

@dataclass(frozen=True)
class ScoreComponent:
    points: int
    max_points: int
    rationale: str

    def to_dict(self) -> dict[str, int | str]:
        return {"points": self.points, "max_points": self.max_points, "rationale": self.rationale}

@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
    breakdown: dict[str, ScoreComponent]
    reject_reasons: tuple[str, ...] = ()

    @property
    def total(self) -> int:
        return sum(component.points for component in self.breakdown.values())

    @property
    def render_allowed(self) -> bool:
        return self.total >= 75 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 = 13 if candidate.visible_fail and candidate.audience_need else 0
        first_frame = 10 if _has_visible_first_frame_conflict(candidate.first_frame) else 0
        hook = 10 if ("because" in candidate.hook.casefold() or "?" in candidate.hook or "trap" in text) else 6
        if _has_generic_ai_slop(text):
            hook = min(hook, 3)
        mechanism = 11 if candidate.mechanism and (candidate.anti_example or candidate.concrete_scene or candidate.plausible_nonsense_example) else 8 if candidate.mechanism else 0
        payoff = 13 if candidate.fix and candidate.takeaway else 0
        has_conversion = bool(candidate.save_reason and candidate.share_reason and candidate.follow_reason)
        retention = 8 if candidate.before_after and "motion-led" in candidate.visual_structure.casefold() and has_conversion else 4 if candidate.before_after else 0
        share_save = 9 if has_conversion else 0
        texture_map = {HumanTexture.REAL_EXAMPLE: 8, HumanTexture.MINI_CASE: 7, HumanTexture.TESTED_PROMPT: 8, HumanTexture.SCREEN_RECORDING: 8, HumanTexture.PERSONAL_OBSERVATION: 6, HumanTexture.SOURCE_BASED_CLAIM: 4, HumanTexture.NONE: 0}
        texture = texture_map[candidate.human_texture]
        if _is_blank(candidate.human_texture_note):
            texture = min(texture, 2)
        series_fit = 4 if SeriesRegistry.default().validate_series_id(candidate.series_id).is_valid else 0
        saturation = {SaturationRisk.LOW: 2, SaturationRisk.MEDIUM: 1, SaturationRisk.HIGH: 0}[candidate.saturation_risk]

        if share_save < 9:
            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")

        breakdown = {
            "problem_urgency": ScoreComponent(problem, 15, "Visible everyday fail with recognizable audience need."),
            "first_frame_clarity": ScoreComponent(first_frame, 12, "First frame shows a conflict without relying on audio."),
            "hook_curiosity": ScoreComponent(hook, 12, "Hook opens a mechanism loop; generic hooks lose points."),
            "mechanism_value": ScoreComponent(mechanism, 12, "Mechanism is tied to anti-example/concrete scene."),
            "practical_payoff": ScoreComponent(payoff, 15, "Fix and takeaway are actionable."),
            "retention_path": ScoreComponent(retention, 10, "Before/after plus motion-led path."),
            "share_save_potential": ScoreComponent(share_save, 10, "Save, share, and follow reasons are explicit."),
            "human_texture": ScoreComponent(texture, 8, "Concrete human texture, not just an enum."),
            "series_fit": ScoreComponent(series_fit, 4, "Known TrueTrace series."),
            "saturation_defense": ScoreComponent(saturation, 2, "Saturation risk penalty applied."),
        }
        return cls(problem, first_frame, hook, mechanism, payoff, retention, share_save, texture, series_fit, saturation, breakdown, tuple(dict.fromkeys(reject_reasons)))

    def to_dict(self) -> dict[str, Any]:
        return {"total": self.total, "render_allowed": self.render_allowed, "reject_reasons": list(self.reject_reasons), "breakdown": {key: value.to_dict() for key, value in self.breakdown.items()}}

@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[Any, ...]
    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
    anti_example: str = ""
    concrete_scene: str = ""
    human_texture_note: str = ""
    linkedin_title: str = ""
    linkedin_caption: str = ""
    render_backend_candidate: str = "python_motion"

    def to_dict(self) -> dict[str, Any]:
        return {
            "candidate_id": self.candidate_id,
            "version": self.version,
            "script_hash": self.script_hash,
            "media_hash": self.media_hash,
            "series": self.series,
            "audience_need": _enum_value(self.audience_need),
            "emotion_before": _enum_value(self.emotion_before),
            "emotion_after": _enum_value(self.emotion_after),
            "hook": self.hook,
            "first_frame": self.first_frame,
            "visible_fail": self.visible_fail,
            "anti_example": self.anti_example,
            "concrete_scene": self.concrete_scene,
            "mechanism": self.mechanism,
            "fix": self.fix,
            "before_after": self.before_after,
            "takeaway": self.takeaway,
            "save_reason": self.save_reason,
            "share_reason": self.share_reason,
            "follow_reason": self.follow_reason,
            "claims": [_claim_text(claim) for claim in self.claims],
            "claim_risk": _enum_value(self.claim_risk),
            "ai_disclosure": _enum_value(self.ai_disclosure),
            "platform_fit": dict(self.platform_fit),
            "linkedin_title": self.linkedin_title,
            "linkedin_caption": self.linkedin_caption,
            "risks": list(self.risks),
            "experiment_hypothesis": self.experiment_hypothesis.to_dict(),
            "expected_winning_metric": self.expected_winning_metric,
            "human_texture_note": self.human_texture_note,
            "render_backend_candidate": self.render_backend_candidate,
            "score": self.score.to_dict(),
            "approval_command": self.approval_command,
        }

    def to_json(self) -> str:
        return json.dumps(self.to_dict(), ensure_ascii=False, separators=(",", ":"))

    @classmethod
    def from_dict(cls, data: Mapping[str, Any]) -> "ReviewPackageV2":
        score_data = data["score"]
        bd = score_data["breakdown"]
        breakdown = {key: ScoreComponent(int(value["points"]), int(value["max_points"]), str(value["rationale"])) for key, value in bd.items()}
        score = StrategyScoreV2(
            problem_urgency=breakdown["problem_urgency"].points,
            first_frame_clarity=breakdown["first_frame_clarity"].points,
            hook_curiosity=breakdown["hook_curiosity"].points,
            mechanism_value=breakdown["mechanism_value"].points,
            practical_payoff=breakdown["practical_payoff"].points,
            retention_path=breakdown["retention_path"].points,
            share_save_potential=breakdown["share_save_potential"].points,
            trust_human_texture=breakdown["human_texture"].points,
            series_fit=breakdown["series_fit"].points,
            saturation_defense=breakdown["saturation_defense"].points,
            breakdown=breakdown,
            reject_reasons=tuple(score_data.get("reject_reasons", ())),
        )
        return cls(
            candidate_id=str(data["candidate_id"]), version=str(data["version"]), script_hash=str(data["script_hash"]), media_hash=data.get("media_hash"), series=str(data["series"]), audience_need=AudienceNeed(data["audience_need"]), emotion_before=ViewerEmotion(data["emotion_before"]), emotion_after=ViewerEmotion(data["emotion_after"]), hook=str(data["hook"]), first_frame=str(data["first_frame"]), visible_fail=str(data["visible_fail"]), mechanism=str(data["mechanism"]), fix=str(data["fix"]), before_after=str(data["before_after"]), takeaway=str(data["takeaway"]), save_reason=str(data["save_reason"]), share_reason=str(data["share_reason"]), follow_reason=str(data["follow_reason"]), claims=tuple(data.get("claims", ())), claim_risk=ClaimRisk(data["claim_risk"]), ai_disclosure=AIDisclosureDecision(data["ai_disclosure"]), platform_fit=dict(data["platform_fit"]), risks=tuple(data.get("risks", ())), experiment_hypothesis=ExperimentHypothesis.from_dict(data["experiment_hypothesis"]), expected_winning_metric=str(data["expected_winning_metric"]), approval_command=str(data["approval_command"]), score=score, anti_example=str(data.get("anti_example", "")), concrete_scene=str(data.get("concrete_scene", "")), human_texture_note=str(data.get("human_texture_note", "")), linkedin_title=str(data.get("linkedin_title", "")), linkedin_caption=str(data.get("linkedin_caption", "")), render_backend_candidate=str(data.get("render_backend_candidate", "python_motion"))
        )

def _script_hash(candidate: VideoCandidateV2) -> str:
    payload = "\n".join((candidate.title, candidate.hook, candidate.first_frame, candidate.visible_fail, candidate.anti_example, candidate.plausible_nonsense_example, candidate.concrete_scene, 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)
    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=SeriesRegistry.default().get(candidate.series_id).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),
        anti_example=candidate.anti_example or candidate.plausible_nonsense_example,
        concrete_scene=candidate.concrete_scene,
        human_texture_note=candidate.human_texture_note,
        linkedin_title=candidate.linkedin_title,
        linkedin_caption=candidate.linkedin_caption,
        render_backend_candidate=_enum_value(candidate.render_backend_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(_claim_text(claim) for claim in 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 "-"
    breakdown = "\n".join(f"- {name}: {component.points}/{component.max_points} — {component.rationale}" for name, component in package.score.breakdown.items())
    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"Anti Example: {package.anti_example or '-'}", f"Concrete Scene: {package.concrete_scene or '-'}", 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"LinkedIn Title: {package.linkedin_title or '-'}", f"LinkedIn Caption: {package.linkedin_caption or '-'}", f"Risks: {risks}", f"Experiment Hypothesis: {package.experiment_hypothesis.statement}", f"Expected Winning Metric: {package.expected_winning_metric}", f"ScoreV2: {package.score.total}/100", "Score Breakdown:", breakdown, "", 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 _base_platform_fit() -> dict[str, str]:
    return {"youtube_shorts": "strong", "tiktok": "strong", "instagram_reels": "medium", "linkedin_manual": "manual B2B caption; check silent-autoplay readability"}

def build_demo_candidates_v2(limit: int | None = None) -> tuple[VideoCandidateV2, ...]:
    common = dict(version="v2.0.0", platforms=("youtube_shorts", "tiktok", "instagram_reels", "linkedin_manual"), duration_seconds=38, viewer_identity="office_worker", saturation_risk=SaturationRisk.MEDIUM, claim_risk=ClaimRisk.LOW, ai_disclosure_required=AIDisclosureDecision.NO, expected_winning_metric="saves_per_view", platform_fit=_base_platform_fit(), render_backend_candidate=RenderBackendCandidate.PYTHON_MOTION)
    candidates = (
        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.", 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 summary says 'great discussion' but misses the launch decision.", anti_example="A useless AI summary saying great discussion with no owner, blocker, or 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, human_texture_note="messy meeting transcript with launch decision", 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", linkedin_title="A better way to brief AI meeting summaries", linkedin_caption="Most meeting summaries fail because they protect no decision. Name the decision first, then ask for blockers, owners, risks and next steps."),
        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.", anti_example="A seemingly normal fake invoice where the payment route changed under urgency.", 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, human_texture_note="fake invoice with changed payment route", 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", linkedin_title="A quick invoice red-flag check for teams", linkedin_caption="A fake invoice does not need to be perfect. It only needs to rush you. Check domain, urgency and payment route before acting."),
        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.", anti_example="A client email missing the required address makes the automation stop silently.", 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, human_texture_note="automation input missing required client email", 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", linkedin_title="The automation check most teams skip", linkedin_caption="If your workflow only handles the happy path, it is not reliable automation. Add an exception path before scaling it."),
        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.", emotion_before=ViewerEmotion.CONFUSED, emotion_after=ViewerEmotion.IN_CONTROL, 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.", anti_example="A vague 'what should I do?' prompt produces confident advice with no decision criteria.", 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, human_texture_note="vague decision request from a planning meeting", 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", linkedin_title="Stop asking tools for opinions", linkedin_caption="Before asking for advice, define the decision and what evidence would change it."),
        VideoCandidateV2(**common, candidate_id="attention-traps-next-thought-35s", title="Your phone chooses the next question", series_id="attention_traps", audience_need=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.", concrete_scene="phone opened for one task, feed redirects attention", 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, human_texture_note="phone opened for one task, feed redirects attention", 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", linkedin_title="A simple attention rule for app switching", linkedin_caption="Before opening an app, name the one action and exit condition. Otherwise the feed chooses the next question."),
    )
    return candidates[:limit] if limit is not None else candidates


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

/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/strategy/spine_v2.py was last read with offset/limit pagination (partial view). Re-read the whole file before overwriting it.


"""Minimal manual claim gate for V2 candidates."""

from __future__ import annotations

from dataclasses import dataclass
from enum import StrEnum
from typing import Any

from autoshorts.ideas.candidate import ValidationResult
from autoshorts.strategy.spine_v2 import ClaimRisk, VideoCandidateV2, _is_blank

class ClaimType(StrEnum):
    OPINION = "opinion"
    EXPERIENCE = "experience"
    SOURCED_FACT = "sourced_fact"
    RISKY_CLAIM = "risky_claim"

@dataclass(frozen=True)
class Claim:
    text: str
    claim_type: ClaimType
    source: str = ""
    risk: ClaimRisk = ClaimRisk.LOW
    note: str = ""

def validate_claims_for_candidate(candidate: VideoCandidateV2) -> ValidationResult:
    errors: list[str] = []
    claims: tuple[Any, ...] = candidate.claims
    if not claims:
        errors.append("claims are required")
    for claim in claims:
        if isinstance(claim, str):
            continue
        text = getattr(claim, "text", "")
        claim_type = getattr(claim, "claim_type", None)
        source = getattr(claim, "source", "")
        risk = getattr(claim, "risk", ClaimRisk.LOW)
        if _is_blank(text):
            errors.append("claim text is required")
        if claim_type == ClaimType.EXPERIENCE and _is_blank(candidate.human_texture_note):
            errors.append("experience claim requires human_texture_note")
        if claim_type == ClaimType.SOURCED_FACT and _is_blank(source):
            errors.append("source is required for sourced_fact")
        if claim_type == ClaimType.RISKY_CLAIM and _is_blank(source):
            errors.append("source is required for risky_claim")
        if risk == ClaimRisk.HIGH and _is_blank(source):
            errors.append("high risk claim requires source")
    return ValidationResult(is_valid=not errors, errors=tuple(dict.fromkeys(errors)))

Explanation:
**Planning module creation**

I’m focusing on creating modules and fixing old tests. I need to address claims about how it now handles tuples without issues. There are some legacy platform names, and it looks like I need to handle absent LinkedIn fields. I also see that I have to validate mappings from YouTube to YouTube Shorts, but the platform fit is missing for YouTube Shorts in the old helper. Should I adapt the validate function to accept legacy platform fit? I’ll check both platform ID and platform in fit later. First, I’ll tackle the modules.


"""Minimal AI-disclosure review model for pre-render gates."""

from __future__ import annotations

from dataclasses import dataclass
from enum import StrEnum

from autoshorts.ideas.candidate import ValidationResult

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

class SyntheticMediaType(StrEnum):
    NONE = "none"
    TEXT_ONLY_ASSISTANCE = "text_only_assistance"
    ABSTRACT_BACKGROUND = "abstract_background"
    REALISTIC_PERSON = "realistic_person"
    REALISTIC_EVENT = "realistic_event"
    REALISTIC_VOICE = "realistic_voice"
    UNCLEAR = "unclear"

@dataclass(frozen=True)
class AIDisclosureReview:
    youtube: DisclosureValue
    tiktok: DisclosureValue
    instagram: DisclosureValue
    linkedin: DisclosureValue
    reason: str
    synthetic_media_type: SyntheticMediaType
    manual_review_required: bool = False

    @classmethod
    def text_only_assistance(cls, reason: str) -> "AIDisclosureReview":
        return cls(
            youtube=DisclosureValue.NO,
            tiktok=DisclosureValue.NO,
            instagram=DisclosureValue.NO,
            linkedin=DisclosureValue.NO,
            reason=reason,
            synthetic_media_type=SyntheticMediaType.TEXT_ONLY_ASSISTANCE,
            manual_review_required=False,
        )

def validate_ai_disclosure_review(review: AIDisclosureReview) -> ValidationResult:
    errors: list[str] = []
    if DisclosureValue.UNCLEAR in (review.youtube, review.tiktok, review.instagram, review.linkedin):
        errors.append("unclear disclosure blocks rendering")
    if review.synthetic_media_type == SyntheticMediaType.UNCLEAR:
        errors.append("unclear synthetic media type blocks rendering")
    if review.synthetic_media_type in {
        SyntheticMediaType.REALISTIC_PERSON,
        SyntheticMediaType.REALISTIC_EVENT,
        SyntheticMediaType.REALISTIC_VOICE,
    } and not review.manual_review_required:
        errors.append("realistic synthetic media requires manual_review_required")
    if not (review.reason or "").strip():
        errors.append("disclosure reason is required")
    return ValidationResult(is_valid=not errors, errors=tuple(dict.fromkeys(errors)))


"""Convert V2 strategic briefs and script inputs into V2 candidates."""

from __future__ import annotations

from dataclasses import dataclass
import re
import unicodedata
from typing import Any

from autoshorts.strategy.spine_v2 import (
    ContentBriefV2,
    RenderBackendCandidate,
    VideoCandidateV2,
    _platform_id,
)

@dataclass(frozen=True)
class ScriptInputV2:
    title: str
    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
    human_texture_note: str
    claims: tuple[Any, ...]
    platform_fit: dict[str, str]
    risks: tuple[str, ...]
    visual_structure: str
    anti_example: str = ""
    plausible_nonsense_example: str = ""
    concrete_scene: str = ""
    linkedin_title: str = ""
    linkedin_caption: str = ""
    render_backend_candidate: RenderBackendCandidate | str = RenderBackendCandidate.PYTHON_MOTION
    series_id_override: str | None = None
    human_texture: object | None = None
    saturation_risk: object | None = None

def _slugify(text: str, *, max_words: int = 8) -> str:
    normalized = unicodedata.normalize("NFKD", text or "")
    ascii_text = normalized.encode("ascii", "ignore").decode("ascii")
    tokens = re.findall(r"[a-z0-9]+", ascii_text.casefold())
    return "-".join(tokens[:max_words]) or "untitled"

def convert_to_candidate_v2(brief: ContentBriefV2, script: ScriptInputV2, *, version: str = "v2.0.0") -> VideoCandidateV2:
    series_id = script.series_id_override if script.series_id_override is not None else brief.series_id
    platforms = tuple(_platform_id(platform) for platform in brief.platforms)
    return VideoCandidateV2(
        candidate_id=f"{series_id.replace('_', '-')}-{_slugify(script.title)}-{brief.target_duration_seconds}s",
        version=version,
        title=script.title,
        series_id=series_id,
        platforms=platforms,
        duration_seconds=brief.target_duration_seconds,
        audience_need=brief.audience_need,
        job_to_be_done=brief.job_to_be_done,
        viewer_identity=brief.viewer_identity,
        emotion_before=brief.emotion_before,
        emotion_after=brief.emotion_after,
        hook=script.hook,
        first_frame=script.first_frame,
        visible_fail=script.visible_fail,
        mechanism=script.mechanism,
        fix=script.fix,
        before_after=script.before_after,
        takeaway=script.takeaway,
        save_reason=script.save_reason,
        share_reason=script.share_reason,
        follow_reason=script.follow_reason,
        search_intent_phrase=brief.search_intent_phrase,
        saturation_risk=script.saturation_risk if script.saturation_risk is not None else brief.saturation_risk,
        human_texture=script.human_texture if script.human_texture is not None else brief.human_texture,
        claim_risk=brief.claim_risk,
        claims=script.claims,
        ai_disclosure_required=brief.ai_disclosure_required,
        experiment_hypothesis=brief.experiment_hypothesis,
        expected_winning_metric=brief.experiment_hypothesis.expected_winning_metric,
        platform_fit={_platform_id(k): v for k, v in script.platform_fit.items()},
        risks=script.risks,
        visual_structure=script.visual_structure,
        anti_example=script.anti_example,
        plausible_nonsense_example=script.plausible_nonsense_example,
        concrete_scene=script.concrete_scene,
        human_texture_note=script.human_texture_note,
        linkedin_title=script.linkedin_title,
        linkedin_caption=script.linkedin_caption,
        render_backend_candidate=script.render_backend_candidate,
    )


LSP diagnostics introduced by this edit:
<diagnostics file="/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/content/to_candidate_v2.py">
ERROR [80:25] Argument of type "object | SaturationRisk" cannot be assigned to parameter "saturation_risk" of type "SaturationRisk" in function "__init__"
  Type "object | SaturationRisk" is not assignable to type "SaturationRisk"
    "object" is not assignable to "SaturationRisk" [reportArgumentType] (Pyright)
ERROR [81:23] Argument of type "object | HumanTexture" cannot be assigned to parameter "human_texture" of type "HumanTexture" in function "__init__"
  Type "object | HumanTexture" is not assignable to type "HumanTexture"
    "object" is not assignable to "HumanTexture" [reportArgumentType] (Pyright)
</diagnostics>


"""V2 review workflow classification."""

from __future__ import annotations

from dataclasses import dataclass
from typing import Literal

from autoshorts.strategy.spine_v2 import (
    StrategyScoreV2,
    VideoCandidateV2,
    build_review_package_v2,
    validate_candidate_v2,
)

ReviewStatusV2 = Literal["ready_for_review", "blocked", "invalid"]

@dataclass(frozen=True)
class ReviewItemV2:
    candidate: VideoCandidateV2
    status: ReviewStatusV2
    reasons: tuple[str, ...]
    score: StrategyScoreV2
    review_package: object | None = None

@dataclass(frozen=True)
class ReviewBatchV2:
    ready_for_review: tuple[ReviewItemV2, ...]
    blocked: tuple[ReviewItemV2, ...]
    invalid: tuple[ReviewItemV2, ...]
    side_effects: tuple[str, ...] = ()
    external_calls: tuple[str, ...] = ()

def classify_candidate_v2(candidate: VideoCandidateV2) -> ReviewItemV2:
    validation = validate_candidate_v2(candidate)
    score = StrategyScoreV2.from_candidate(candidate)
    if any(reason.startswith("unknown series_id") or reason.startswith("unsupported platform") for reason in validation.errors):
        return ReviewItemV2(candidate=candidate, status="invalid", reasons=validation.errors, score=score)
    if not validation.is_valid or not score.render_allowed:
        reasons = tuple(dict.fromkeys((*validation.errors, *score.reject_reasons)))
        return ReviewItemV2(candidate=candidate, status="blocked", reasons=reasons, score=score)
    return ReviewItemV2(candidate=candidate, status="ready_for_review", reasons=(), score=score, review_package=build_review_package_v2(candidate))

def build_review_batch_v2(candidates: tuple[VideoCandidateV2, ...]) -> ReviewBatchV2:
    ready: list[ReviewItemV2] = []
    blocked: list[ReviewItemV2] = []
    invalid: list[ReviewItemV2] = []
    for candidate in candidates:
        item = classify_candidate_v2(candidate)
        if item.status == "ready_for_review":
            ready.append(item)
        elif item.status == "blocked":
            blocked.append(item)
        else:
            invalid.append(item)
    return ReviewBatchV2(tuple(ready), tuple(blocked), tuple(invalid))


"""Strict parser for ReviewPackageV2 approval commands."""

from __future__ import annotations

from dataclasses import dataclass
from typing import Literal

from autoshorts.strategy.spine_v2 import ReviewPackageV2

ApprovalActionV2 = Literal["approve", "blocked", "needs_clarification", "reject", "revise"]

@dataclass(frozen=True)
class ApprovalDecisionV2:
    action: ApprovalActionV2
    reason: str = ""

def parse_approval_command_v2(command: str, package: ReviewPackageV2) -> ApprovalDecisionV2:
    parts = (command or "").strip().split()
    if not parts:
        return ApprovalDecisionV2("needs_clarification", "empty command")
    verb = parts[0].upper()
    if verb == "REJECT":
        return ApprovalDecisionV2("reject")
    if verb == "REVISE":
        return ApprovalDecisionV2("revise")
    if verb != "APPROVE":
        return ApprovalDecisionV2("needs_clarification", "not an approval command")
    if len(parts) != 4:
        return ApprovalDecisionV2("blocked", "approval command must be APPROVE <candidate_id> <version> <script_hash>")
    _, candidate_id, version, script_hash = parts
    if candidate_id != package.candidate_id:
        return ApprovalDecisionV2("blocked", "candidate_id mismatch")
    if version != package.version:
        return ApprovalDecisionV2("blocked", "version mismatch")
    if script_hash != package.script_hash:
        return ApprovalDecisionV2("blocked", "script_hash mismatch")
    return ApprovalDecisionV2("approve")


"""Demo CLI for V2 review batches. No media, sends, or platform calls."""

from __future__ import annotations

import argparse
import json
import sys
from typing import Sequence, TextIO

from autoshorts.review.workflow_v2 import ReviewBatchV2, build_review_batch_v2
from autoshorts.strategy.spine_v2 import build_demo_candidates_v2, render_review_package_v2

def build_demo_review_batch_v2(*, limit: int = 3) -> ReviewBatchV2:
    return build_review_batch_v2(build_demo_candidates_v2(limit=min(limit, 3)))

def _batch_to_dict(batch: ReviewBatchV2) -> dict[str, object]:
    return {
        "ready_for_review": [item.review_package.to_dict() for item in batch.ready_for_review if item.review_package is not None],
        "blocked": [{"candidate_id": item.candidate.candidate_id, "reasons": list(item.reasons), "score": item.score.to_dict()} for item in batch.blocked],
        "invalid": [{"candidate_id": item.candidate.candidate_id, "reasons": list(item.reasons), "score": item.score.to_dict()} for item in batch.invalid],
        "side_effects": list(batch.side_effects),
        "external_calls": list(batch.external_calls),
    }

def main(argv: Sequence[str] | None = None, *, stdout: TextIO | None = None) -> int:
    parser = argparse.ArgumentParser(description="Render a side-effect-free V2 review batch demo.")
    parser.add_argument("--json", action="store_true", help="Emit deterministic JSON instead of text")
    parser.add_argument("--limit", type=int, default=3, help="Max demo candidates, capped at 3")
    args = parser.parse_args(argv)
    out = stdout or sys.stdout
    batch = build_demo_review_batch_v2(limit=args.limit)
    if args.json:
        print(json.dumps(_batch_to_dict(batch), ensure_ascii=False, separators=(",", ":")), file=out)
    else:
        print("V2 Review Batch Demo", file=out)
        print("Side effects: none", file=out)
        print("External calls: none", file=out)
        for item in batch.ready_for_review:
            if item.review_package is not None:
                print("\n---", file=out)
                print(render_review_package_v2(item.review_package), file=out)
        for group_name, items in (("Blocked", batch.blocked), ("Invalid", batch.invalid)):
            if items:
                print(f"\n{group_name}:", file=out)
                for item in items:
                    print(f"- {item.candidate.candidate_id}: {'; '.join(item.reasons)}", file=out)
    return 0

if __name__ == "__main__":
    raise SystemExit(main())


LSP diagnostics introduced by this edit:
<diagnostics file="/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/cli/demo_review_batch_v2.py">
ERROR [20:50] Cannot access attribute "to_dict" for class "object"
  Attribute "to_dict" is unknown [reportAttributeAccessIssue] (Pyright)
ERROR [44:48] Argument of type "object" cannot be assigned to parameter "package" of type "ReviewPackageV2" in function "render_review_package_v2"
  "object" is not assignable to "ReviewPackageV2" [reportArgumentType] (Pyright)
</diagnostics>


# Rendering Backend Options

Sprint 1.5 decision: keep the current Python/Pillow/FFmpeg renderer active.

## Current active backend

- `python_motion`
- Python/Pillow writes deterministic preview frames.
- FFmpeg assembles local MP4 previews at explicit renderer boundaries.
- This remains the production path for now.

## Remotion spike candidate

Remotion is noted only as a later spike candidate, not an active migration.

Potential advantages:

- React/CSS-based motion language.
- Better UI-style animations and component reuse.
- Easier complex timeline composition for some motion-graphics patterns.

Risks:

- Adds Node/React stack to a currently Python-centered pipeline.
- More build/deploy complexity.
- More moving parts before the editorial gates, scoring, claims, disclosures, and analytics are stable.

## Decision

No Remotion implementation now. No renderer migration in Sprint 1.5. Revisit only after V2 strategy, review, claim gate, disclosure gate, and analytics loop are stable.


No linter for .md files


terminal

python -m pytest tests/test_strategy_spine_v2.py tests/test_strategy_spine_v2_operational.py -q


Output:
...F...F..F.......                                                       [100%]
=================================== FAILURES ===================================
______ test_strategy_score_v2_uses_100_point_model_and_blocks_low_scores _______

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

>       assert score.total == 100
E       AssertionError: assert 78 == 100
E        +  where 78 = StrategyScoreV2(problem_urgency=13, first_frame_clarity=10, hook_curiosity=10, mechanism_value=8, practical_payoff=13,...uman_texture_note is required', 'platform_fit missing for youtube_shorts', 'platform_fit missing for instagram_reels')).total

tests/test_strategy_spine_v2.py:138: AssertionError
___ test_build_demo_candidates_v2_returns_one_candidate_per_requested_series ___

    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",
        ]
E       AssertionError: assert ['ai_output_a...ention_traps'] == ['ai_output_a...ention_traps']
E
E         At index 2 diff: 'workflow_teardown' != 'decision_design'
E         Use -v to get more diff

tests/test_strategy_spine_v2.py:188: AssertionError
_________ test_review_package_v2_json_is_deterministic_and_roundtrips __________

    def test_review_package_v2_json_is_deterministic_and_roundtrips():
        package = build_review_package_v2(candidate())

        first = package.to_json()
        second = package.to_json()
        loaded = json.loads(first)

        assert first == second
        assert list(loaded.keys())[:4] == ["candidate_id", "version", "script_hash", "media_hash"]
        assert loaded["linkedin_caption"].startswith("Most AI meeting summaries")
>       assert package == package.from_dict(loaded)
E       AssertionError: assert ReviewPackage...ython_motion') == ReviewPackage...ython_motion')
E
E         Omitting 32 identical items, use -vv to show
E         Differing attributes:
E         ['claims']
E
E         Drill down into differing attribute claims:
E           claims: (Claim(text='Decision-targeted summaries preserve relevance better than generic summaries.', claim_type=<ClaimType.OPINION: 'opinion'>, source='', risk=<ClaimRisk.LOW: 'low'>, note=''),) != ('Decision-targeted summaries preserve relevance better than generic summaries.',)...
E
E         ...Full output truncated (2 lines hidden), use '-vv' to show

tests/test_strategy_spine_v2_operational.py:130: AssertionError
=========================== short test summary info ============================
FAILED tests/test_strategy_spine_v2.py::test_strategy_score_v2_uses_100_point_model_and_blocks_low_scores
FAILED tests/test_strategy_spine_v2.py::test_build_demo_candidates_v2_returns_one_candidate_per_requested_series
FAILED tests/test_strategy_spine_v2_operational.py::test_review_package_v2_json_is_deterministic_and_roundtrips
3 failed, 15 passed in 0.13s


patch

        if platform_id not in candidate.platform_fit:
            errors.append(f"platform_fit missing for {platform_id}")

        if platform_id not in candidate.platform_fit and platform not in candidate.platform_fit:
            errors.append(f"platform_fit missing for {platform_id}")

Explanation:
**Adjusting old tests**

I need to update the old tests to fit the new scores and order, but it seems like comparing the Claim object directly isn't working. Perhaps I should compare dictionaries instead? The user hinted that using from_dict could be sensible, so reconstructing strings from claims might be better.

I think it could work to store claim texts in ReviewPackage. This way, I might avoid issues with the old helper's validation and defaults. I could patch the tests accordingly since it looks like they need to align with the new requirements.
**Validating platform fit**

I need to validate the platform fit by using the platform_id not present in candidate.platform_fit. For the legacy tests, I can't use LinkedIn, which is good. I’m thinking that maybe for the old helper, I should lower the score to 78 due to rejection reasons tied to human notes and platform details.

It seems I might need to add a human_texture_note or anti_example to the tests. The user wants a maximum of three candidates, but the old tests expect five. I'll consider reordering the build_demo candidates. I should patch the old tests to accommodate the new requirement.
**Finalizing build order**

I need to ensure the CLI uses the first three candidates if the build order with decision as third doesn’t match the user's suggestion. I’m thinking it would be best to order them as AI, Digital, Workflow, then Decision, and Attention, and I'll update the old tests accordingly. Once that’s sorted, I’ll need to patch the build package claims and validate the platform fit with the old tests. And don't forget, the score should be set to 100 for that test!


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/strategy/spine_v2.py
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/strategy/spine_v2.py
@@ -291,7 +291,7 @@
         platform_id = _platform_id(platform)
         if platform_id not in SUPPORTED_PLATFORMS_V2:
             errors.append(f"unsupported platform: {platform}")
-        if platform_id not in candidate.platform_fit:
+        if platform_id not in candidate.platform_fit and platform not in candidate.platform_fit:
             errors.append(f"platform_fit missing for {platform_id}")
     if "linkedin_manual" in tuple(_platform_id(p) for p in candidate.platforms):
         if _is_blank(candidate.linkedin_caption):


claims=tuple(_claim_text(claim) for claim in candidate.claims),


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/strategy/spine_v2.py
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/autoshorts/strategy/spine_v2.py
@@ -545,7 +545,7 @@
         save_reason=candidate.save_reason,
         share_reason=candidate.share_reason,
         follow_reason=candidate.follow_reason,
-        claims=candidate.claims,
+        claims=tuple(_claim_text(claim) for claim in candidate.claims),
         claim_risk=candidate.claim_risk,
         ai_disclosure=candidate.ai_disclosure_required,
         platform_fit=candidate.platform_fit,


        visual_structure="motion-led mechanism funnel with transcript cards, not slideshow/text cards",
        anti_example="A useless AI summary with no decision target.",
        human_texture_note="messy meeting transcript with launch decision",
    )

        visual_structure="motion-led mechanism funnel with transcript cards, not slideshow/text cards",
    )


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/tests/test_strategy_spine_v2.py
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/tests/test_strategy_spine_v2.py
@@ -87,6 +87,8 @@
         platform_fit={"youtube": "strong", "tiktok": "strong", "instagram": "medium"},
         risks=("medium saturation",),
         visual_structure="motion-led mechanism funnel with transcript cards, not slideshow/text cards",
+        anti_example="A useless AI summary with no decision target.",
+        human_texture_note="messy meeting transcript with launch decision",
     )
     data.update(overrides)
     return VideoCandidateV2(**data)

/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/tests/test_strategy_spine_v2.py was last read with offset/limit pagination (partial view). Re-read the whole file before overwriting it.


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/tests/test_strategy_spine_v2.py
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/tests/test_strategy_spine_v2.py
@@ -137,7 +137,7 @@
     candidate = make_candidate()
     score = StrategyScoreV2.from_candidate(candidate)

-    assert score.total == 100
+    assert 75 <= score.total <= 93
     assert score.render_allowed
     assert score.problem_urgency == 15
     assert score.practical_payoff == 15


    assert score.problem_urgency == 13
    assert score.practical_payoff == 13

    assert score.problem_urgency == 15
    assert score.practical_payoff == 15


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/tests/test_strategy_spine_v2.py
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/tests/test_strategy_spine_v2.py
@@ -139,8 +139,8 @@

     assert 75 <= score.total <= 93
     assert score.render_allowed
-    assert score.problem_urgency == 15
-    assert score.practical_payoff == 15
+    assert score.problem_urgency == 13
+    assert score.practical_payoff == 13

     weak = StrategyScoreV2.from_candidate(make_candidate(save_reason="", share_reason="", follow_reason=""))
     assert weak.total < 80


        "workflow_teardown",
        "decision_design",

        "decision_design",
        "workflow_teardown",


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/tests/test_strategy_spine_v2.py
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/tests/test_strategy_spine_v2.py
@@ -190,8 +190,8 @@
     assert [candidate.series_id for candidate in candidates] == [
         "ai_output_autopsy",
         "digital_red_flags",
+        "workflow_teardown",
         "decision_design",
-        "workflow_teardown",
         "attention_traps",
     ]
     assert all(validate_candidate_v2(candidate).is_valid for candidate in candidates)


Output:
..................                                                       [100%]
18 passed in 0.13s


todo

Sprint-1.5 Tests für Brücke, Parser, JSON, Score, Claims, Disclosure, LinkedIn, CLI schreiben und RED verifizieren

Sprint-1.5 Implementierung: V2-Brücke, Workflow, CLI, JSON, Parser, Score-Kalibrierung, Claim/Disclosure Stubs, LinkedIn, Docs

Full pytest, CLI-Demo ausführen, Status und Beispielpackages sammeln

Explanation:
**Updating testing plans**

I need to focus on completing the full pytest to ensure everything is functioning properly. There’s also the consideration of older tests with potentially weird asserts, but they might be acceptable if they're under a total of 80. I definitely need to finish the test red, and I should make sure to update my to-do list accordingly. It's a bit of a juggling act, but I'll get it all sorted out!


Sprint-1-Stand prüfen, Tests laufen lassen, neue Dateien reviewen und committen


Output:
........................................................................ [ 37%]
........................................................................ [ 75%]
..............................................                           [100%]
190 passed in 32.34s


from hermes_tools import terminal
wd='/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot'
for name, cmd in [
('STATUS','git status --short'),
('BRANCH','git branch --show-current'),
('CHANGED','git diff --name-only && git ls-files --others --exclude-standard'),
('CLI_JSON','python -m autoshorts.cli.demo_review_batch_v2 --json'),
('CLI_TEXT','python -m autoshorts.cli.demo_review_batch_v2 --limit 3')]:
    print(f'## {name}')
    out=terminal(cmd, workdir=wd)['output']
    print(out[:20000])


Output:
## STATUS
M autoshorts/strategy/spine_v2.py
 M tests/test_strategy_spine_v2.py
?? autoshorts/claims/
?? autoshorts/cli/demo_review_batch_v2.py
?? autoshorts/compliance/
?? autoshorts/content/to_candidate_v2.py
?? autoshorts/review/approvals_v2.py
?? autoshorts/review/workflow_v2.py
?? docs/rendering_backend_options.md
?? tests/test_strategy_spine_v2_operational.py
## BRANCH
strategy-refactor-v2
## CHANGED
autoshorts/strategy/spine_v2.py
tests/test_strategy_spine_v2.py
autoshorts/claims/fact_gate.py
autoshorts/cli/demo_review_batch_v2.py
autoshorts/compliance/ai_disclosure.py
autoshorts/content/to_candidate_v2.py
autoshorts/review/approvals_v2.py
autoshorts/review/workflow_v2.py
docs/rendering_backend_options.md
tests/test_strategy_spine_v2_operational.py
## CLI_JSON
{"ready_for_review":[{"candidate_id":"ai-output-autopsy-decision-summary-38s","version":"v2.0.0","script_hash":"72129542b86819fd","media_hash":null,"series":"AI Output Autopsy","audience_need":"clarity","emotion_before":"confused","emotion_after":"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 summary says 'great discussion' but misses the launch decision.","anti_example":"A useless AI summary saying great discussion with no owner, blocker, or decision.","concrete_scene":"","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.","claims":["Decision-targeted summaries preserve relevance better than generic summaries."],"claim_risk":"low","ai_disclosure":"no","platform_fit":{"youtube_shorts":"strong","tiktok":"strong","instagram_reels":"medium","linkedin_manual":"manual B2B caption; check silent-autoplay readability"},"linkedin_title":"A better way to brief AI meeting summaries","linkedin_caption":"Most meeting summaries fail because they protect no decision. Name the decision first, then ask for blockers, owners, risks and next steps.","risks":["medium AI-tip saturation"],"experiment_hypothesis":{"statement":"Tests whether decision-target AI examples drive saves.","expected_winning_metric":"saves_per_view"},"expected_winning_metric":"saves_per_view","human_texture_note":"messy meeting transcript with launch decision","render_backend_candidate":"python_motion","score":{"total":86,"render_allowed":true,"reject_reasons":[],"breakdown":{"problem_urgency":{"points":13,"max_points":15,"rationale":"Visible everyday fail with recognizable audience need."},"first_frame_clarity":{"points":10,"max_points":12,"rationale":"First frame shows a conflict without relying on audio."},"hook_curiosity":{"points":10,"max_points":12,"rationale":"Hook opens a mechanism loop; generic hooks lose points."},"mechanism_value":{"points":11,"max_points":12,"rationale":"Mechanism is tied to anti-example/concrete scene."},"practical_payoff":{"points":13,"max_points":15,"rationale":"Fix and takeaway are actionable."},"retention_path":{"points":8,"max_points":10,"rationale":"Before/after plus motion-led path."},"share_save_potential":{"points":9,"max_points":10,"rationale":"Save, share, and follow reasons are explicit."},"human_texture":{"points":7,"max_points":8,"rationale":"Concrete human texture, not just an enum."},"series_fit":{"points":4,"max_points":4,"rationale":"Known TrueTrace series."},"saturation_defense":{"points":1,"max_points":2,"rationale":"Saturation risk penalty applied."}}},"approval_command":"APPROVE ai-output-autopsy-decision-summary-38s v2.0.0 72129542b86819fd"},{"candidate_id":"digital-red-flags-fake-invoice-35s","version":"v2.0.0","script_hash":"ace02532309afd60","media_hash":null,"series":"Digital Red Flags","audience_need":"safety","emotion_before":"anxious","emotion_after":"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.","anti_example":"A seemingly normal fake invoice where the payment route changed under urgency.","concrete_scene":"","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.","claims":["Urgency and payment-route changes are common scam warning signs."],"claim_risk":"low","ai_disclosure":"no","platform_fit":{"youtube_shorts":"strong","tiktok":"strong","instagram_reels":"medium","linkedin_manual":"manual B2B caption; check silent-autoplay readability"},"linkedin_title":"A quick invoice red-flag check for teams","linkedin_caption":"A fake invoice does not need to be perfect. It only needs to rush you. Check domain, urgency and payment route before acting.","risks":["avoid naming real brands"],"experiment_hypothesis":{"statement":"Tests whether red-flag safety content produces shares.","expected_winning_metric":"saves_per_view"},"expected_winning_metric":"saves_per_view","human_texture_note":"fake invoice with changed payment route","render_backend_candidate":"python_motion","score":{"total":87,"render_allowed":true,"reject_reasons":[],"breakdown":{"problem_urgency":{"points":13,"max_points":15,"rationale":"Visible everyday fail with recognizable audience need."},"first_frame_clarity":{"points":10,"max_points":12,"rationale":"First frame shows a conflict without relying on audio."},"hook_curiosity":{"points":10,"max_points":12,"rationale":"Hook opens a mechanism loop; generic hooks lose points."},"mechanism_value":{"points":11,"max_points":12,"rationale":"Mechanism is tied to anti-example/concrete scene."},"practical_payoff":{"points":13,"max_points":15,"rationale":"Fix and takeaway are actionable."},"retention_path":{"points":8,"max_points":10,"rationale":"Before/after plus motion-led path."},"share_save_potential":{"points":9,"max_points":10,"rationale":"Save, share, and follow reasons are explicit."},"human_texture":{"points":8,"max_points":8,"rationale":"Concrete human texture, not just an enum."},"series_fit":{"points":4,"max_points":4,"rationale":"Known TrueTrace series."},"saturation_defense":{"points":1,"max_points":2,"rationale":"Saturation risk penalty applied."}}},"approval_command":"APPROVE digital-red-flags-fake-invoice-35s v2.0.0 ace02532309afd60"},{"candidate_id":"workflow-teardown-exception-path-40s","version":"v2.0.0","script_hash":"e03c639358deaaf9","media_hash":null,"series":"Workflow Teardown","audience_need":"competence","emotion_before":"annoyed","emotion_after":"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.","anti_example":"A client email missing the required address makes the automation stop silently.","concrete_scene":"","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.","claims":["Automation reliability depends on handling exceptions, not only happy paths."],"claim_risk":"low","ai_disclosure":"no","platform_fit":{"youtube_shorts":"strong","tiktok":"strong","instagram_reels":"medium","linkedin_manual":"manual B2B caption; check silent-autoplay readability"},"linkedin_title":"The automation check most teams skip","linkedin_caption":"If your workflow only handles the happy path, it is not reliable automation. Add an exception path before scaling it.","risks":["avoid exposing private workflows"],"experiment_hypothesis":{"statement":"Tests whether workflow teardown examples attract builders.","expected_winning_metric":"saves_per_view"},"expected_winning_metric":"saves_per_view","human_texture_note":"automation input missing required client email","render_backend_candidate":"python_motion","score":{"total":83,"render_allowed":true,"reject_reasons":[],"breakdown":{"problem_urgency":{"points":13,"max_points":15,"rationale":"Visible everyday fail with recognizable audience need."},"first_frame_clarity":{"points":10,"max_points":12,"rationale":"First frame shows a conflict without relying on audio."},"hook_curiosity":{"points":6,"max_points":12,"rationale":"Hook opens a mechanism loop; generic hooks lose points."},"mechanism_value":{"points":11,"max_points":12,"rationale":"Mechanism is tied to anti-example/concrete scene."},"practical_payoff":{"points":13,"max_points":15,"rationale":"Fix and takeaway are actionable."},"retention_path":{"points":8,"max_points":10,"rationale":"Before/after plus motion-led path."},"share_save_potential":{"points":9,"max_points":10,"rationale":"Save, share, and follow reasons are explicit."},"human_texture":{"points":8,"max_points":8,"rationale":"Concrete human texture, not just an enum."},"series_fit":{"points":4,"max_points":4,"rationale":"Known TrueTrace series."},"saturation_defense":{"points":1,"max_points":2,"rationale":"Saturation risk penalty applied."}}},"approval_command":"APPROVE workflow-teardown-exception-path-40s v2.0.0 e03c639358deaaf9"}],"blocked":[],"invalid":[],"side_effects":[],"external_calls":[]}
## CLI_TEXT
V2 Review Batch Demo
Side effects: none
External calls: none

---
## ReviewPackageV2: ai-output-autopsy-decision-summary-38s
Candidate ID: ai-output-autopsy-decision-summary-38s
Version: v2.0.0
Script Hash: 72129542b86819fd
Media Hash: -
Series: AI Output Autopsy
Audience Need: clarity
Viewer Emotion Before: confused
Viewer Emotion After: 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 summary says 'great discussion' but misses the launch decision.
Anti Example: A useless AI summary saying great discussion with no owner, blocker, or decision.
Concrete Scene: -
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.
Claims: Decision-targeted summaries preserve relevance better than generic summaries.
Claim Risk: low
AI Disclosure: no
Platform Fit: youtube_shorts: strong; tiktok: strong; instagram_reels: medium; linkedin_manual: manual B2B caption; check silent-autoplay readability
LinkedIn Title: A better way to brief AI meeting summaries
LinkedIn Caption: Most meeting summaries fail because they protect no decision. Name the decision first, then ask for blockers, owners, risks and next steps.
Risks: medium AI-tip saturation
Experiment Hypothesis: Tests whether decision-target AI examples drive saves.
Expected Winning Metric: saves_per_view
ScoreV2: 86/100
Score Breakdown:
- problem_urgency: 13/15 — Visible everyday fail with recognizable audience need.
- first_frame_clarity: 10/12 — First frame shows a conflict without relying on audio.
- hook_curiosity: 10/12 — Hook opens a mechanism loop; generic hooks lose points.
- mechanism_value: 11/12 — Mechanism is tied to anti-example/concrete scene.
- practical_payoff: 13/15 — Fix and takeaway are actionable.
- retention_path: 8/10 — Before/after plus motion-led path.
- share_save_potential: 9/10 — Save, share, and follow reasons are explicit.
- human_texture: 7/8 — Concrete human texture, not just an enum.
- series_fit: 4/4 — Known TrueTrace series.
- saturation_defense: 1/2 — Saturation risk penalty applied.

Approval Command: `APPROVE ai-output-autopsy-decision-summary-38s v2.0.0 72129542b86819fd`
Reject Command: `REJECT ai-output-autopsy-decision-summary-38s <reason>`
Revise Command: `REVISE ai-output-autopsy-decision-summary-38s <field> <requested change>`

---
## ReviewPackageV2: digital-red-flags-fake-invoice-35s
Candidate ID: digital-red-flags-fake-invoice-35s
Version: v2.0.0
Script Hash: ace02532309afd60
Media Hash: -
Series: Digital Red Flags
Audience Need: safety
Viewer Emotion Before: anxious
Viewer Emotion After: 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.
Anti Example: A seemingly normal fake invoice where the payment route changed under urgency.
Concrete Scene: -
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.
Claims: Urgency and payment-route changes are common scam warning signs.
Claim Risk: low
AI Disclosure: no
Platform Fit: youtube_shorts: strong; tiktok: strong; instagram_reels: medium; linkedin_manual: manual B2B caption; check silent-autoplay readability
LinkedIn Title: A quick invoice red-flag check for teams
LinkedIn Caption: A fake invoice does not need to be perfect. It only needs to rush you. Check domain, urgency and payment route before acting.
Risks: avoid naming real brands
Experiment Hypothesis: Tests whether red-flag safety content produces shares.
Expected Winning Metric: saves_per_view
ScoreV2: 87/100
Score Breakdown:
- problem_urgency: 13/15 — Visible everyday fail with recognizable audience need.
- first_frame_clarity: 10/12 — First frame shows a conflict without relying on audio.
- hook_curiosity: 10/12 — Hook opens a mechanism loop; generic hooks lose points.
- mechanism_value: 11/12 — Mechanism is tied to anti-example/concrete scene.
- practical_payoff: 13/15 — Fix and takeaway are actionable.
- retention_path: 8/10 — Before/after plus motion-led path.
- share_save_potential: 9/10 — Save, share, and follow reasons are explicit.
- human_texture: 8/8 — Concrete human texture, not just an enum.
- series_fit: 4/4 — Known TrueTrace series.
- saturation_defense: 1/2 — Saturation risk penalty applied.

Approval Command: `APPROVE digital-red-flags-fake-invoice-35s v2.0.0 ace02532309afd60`
Reject Command: `REJECT digital-red-flags-fake-invoice-35s <reason>`
Revise Command: `REVISE digital-red-flags-fake-invoice-35s <field> <requested change>`

---
## ReviewPackageV2: workflow-teardown-exception-path-40s
Candidate ID: workflow-teardown-exception-path-40s
Version: v2.0.0
Script Hash: e03c639358deaaf9
Media Hash: -
Series: Workflow Teardown
Audience Need: competence
Viewer Emotion Before: annoyed
Viewer Emotion After: 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.
Anti Example: A client email missing the required address makes the automation stop silently.
Concrete Scene: -
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.
Claims: Automation reliability depends on handling exceptions, not only happy paths.
Claim Risk: low
AI Disclosure: no
Platform Fit: youtube_shorts: strong; tiktok: strong; instagram_reels: medium; linkedin_manual: manual B2B caption; check silent-autoplay readability
LinkedIn Title: The automation check most teams skip
LinkedIn Caption: If your workflow only handles the happy path, it is not reliable automation. Add an exception path before scaling it.
Risks: avoid exposing private workflows
Experiment Hypothesis: Tests whether workflow teardown examples attract builders.
Expected Winning Metric: saves_per_view
ScoreV2: 83/100
Score Breakdown:
- problem_urgency: 13/15 — Visible everyday fail with recognizable audience need.
- first_frame_clarity: 10/12 — First frame shows a conflict without relying on audio.
- hook_curiosity: 6/12 — Hook opens a mechanism loop; generic hooks lose points.
- mechanism_value: 11/12 — Mechanism is tied to anti-example/concrete scene.
- practical_payoff: 13/15 — Fix and takeaway are actionable.
- retention_path: 8/10 — Before/after plus motion-led path.
- share_save_potential: 9/10 — Save, share, and follow reasons are explicit.
- human_texture: 8/8 — Concrete human texture, not just an enum.
- series_fit: 4/4 — Known TrueTrace series.
- saturation_defense: 1/2 — Saturation risk penalty applied.

Approval Command: `APPROVE workflow-teardown-exception-path-40s v2.0.0 e03c639358deaaf9`
Reject Command: `REJECT workflow-teardown-exception-path-40s <reason>`
Revise Command: `REVISE workflow-teardown-exception-path-40s <field> <requested change>`


Explanation:
**Considering commit status**

I think I need to run a git diff stat and maybe mention the commit. I'm wondering if we should commit sprint 1.5 since the user didn’t ask about committing after. It might be fine to leave it uncommitted. The note should indicate "Abschluss: changed files." However, I'll check if the old sprint commit was created and confirm if V2 CLI works. Before finalizing, I’ll run git status after everything’s complete.


Sir, Sprint 1.5 ist erledigt — ohne 30-Tage-Batch, ohne Rendering, ohne Plattform-API, ohne Auto-Posting. V2 ist jetzt nicht mehr nur Insel, sondern operativ anschliessbar.

## Sprint-1-Commit

Erstellt:

`658c170 Add strategy spine v2 quality gates and review package`

Vor Commit geprüft:

- Branch: `strategy-refactor-v2`
- Pytest vor Commit: `180 passed`
- Review der Sprint-1-Dateien durchgeführt:
  - `autoshorts/strategy/spine_v2.py`
  - `tests/test_strategy_spine_v2.py`

## Geänderte Dateien in Sprint 1.5

Aktuell uncommitted nach Sprint 1.5:

```text
M  autoshorts/strategy/spine_v2.py
M  tests/test_strategy_spine_v2.py
?? autoshorts/claims/fact_gate.py
?? autoshorts/cli/demo_review_batch_v2.py
?? autoshorts/compliance/ai_disclosure.py
?? autoshorts/content/to_candidate_v2.py
?? autoshorts/review/approvals_v2.py
?? autoshorts/review/workflow_v2.py
?? docs/rendering_backend_options.md
?? tests/test_strategy_spine_v2_operational.py
```

## Pytest Ergebnis

Final:

```text
190 passed in 32.34s
```

## Was operativ angeschlossen wurde

### 1. V2 Candidate Bridge

Neu:

`autoshorts/content/to_candidate_v2.py`

Enthält:

- `ScriptInputV2`
- `convert_to_candidate_v2(ContentBriefV2, ScriptInputV2) -> VideoCandidateV2`

Eigenschaften:

- side-effect-free
- keine AI Calls
- keine Renderings
- keine Plattform-API
- unterstützt `linkedin_manual`
- erzeugt stabile Candidate IDs

### 2. Workflow V2

Neu:

`autoshorts/review/workflow_v2.py`

Klassifiziert `VideoCandidateV2` als:

- `ready_for_review`
- `blocked`
- `invalid`

Nutzt:

- `validate_candidate_v2`
- `StrategyScoreV2`
- `build_review_package_v2`

### 3. CLI Demo Review Batch V2

Neu:

`autoshorts/cli/demo_review_batch_v2.py`

Nutzbar über:

```bash
python -m autoshorts.cli.demo_review_batch_v2
python -m autoshorts.cli.demo_review_batch_v2 --json
```

Eigenschaften:

- maximal 3 Demo-Kandidaten
- ReviewPackageV2 als Telegram-sicherer Text
- JSON-Ausgabe verfügbar
- keine Videos
- keine Telegram-API
- keine Plattform-API
- `side_effects=[]`
- `external_calls=[]`

## ReviewPackageV2 JSON

Ergänzt:

- `to_dict()`
- `to_json()`
- `from_dict()`

Eigenschaften:

- deterministische Feldreihenfolge
- keine Timestamps
- JSON enthält:
  - Review-Daten
  - Score Breakdown
  - LinkedIn Caption
  - Approval Command
  - Claim Risk
  - AI Disclosure
  - Platform Fit

## Approval Parser V2

Neu:

`autoshorts/review/approvals_v2.py`

Pflichtformat:

```text
APPROVE <candidate_id> <version> <script_hash>
```

Geprüft wird:

- candidate_id exakt
- version exakt
- script_hash exakt
- falscher Hash blockiert
- fehlende Teile blockieren
- unklare Texte ergeben `needs_clarification`

## ScoreV2 kalibriert

Vorher waren Demo-Kandidaten zu oft `100/100`. Das ist jetzt korrigiert.

Neue Score-Verteilung der 3 Demo-Kandidaten:

- AI Output Autopsy: `86/100`
- Digital Red Flags: `87/100`
- Workflow Teardown: `83/100`

Score Breakdown ist jetzt im ReviewPackage enthalten:

- problem_urgency
- first_frame_clarity
- hook_curiosity
- mechanism_value
- practical_payoff
- retention_path
- share_save_potential
- human_texture
- series_fit
- saturation_defense

Punkteverlust sichtbar bei:

- schwacher Human Texture
- generischem Hook
- hoher Saturation
- schwachem First Frame

## Anti-Example / Human Texture

`VideoCandidateV2` ergänzt um:

- `anti_example`
- `plausible_nonsense_example`
- `concrete_scene`
- `human_texture_note`

Pflicht:

Für diese Serien ist ein Anti-Beispiel nötig:

- AI Output Autopsy
- Workflow Teardown
- Digital Red Flags
- Decision Design

Attention Traps darf stattdessen eine konkrete Alltagsszene nutzen.

Blocker:

- `human_texture != NONE`, aber `human_texture_note` leer → blockiert.

## Minimaler Claim Gate Stub

Neu:

`autoshorts/claims/fact_gate.py`

Enthält:

- `Claim`
- `ClaimType`
  - `opinion`
  - `experience`
  - `sourced_fact`
  - `risky_claim`
- `validate_claims_for_candidate(candidate)`

Regeln:

- opinion ohne Quelle erlaubt
- experience braucht `human_texture_note`
- sourced_fact braucht `source`
- risky_claim braucht `source`
- high risk ohne Quelle blockiert immer

Keine Websuche, keine APIs, keine automatische Quellenrecherche.

## Minimaler AI Disclosure Stub

Neu:

`autoshorts/compliance/ai_disclosure.py`

Enthält:

- `AIDisclosureReview`
- `DisclosureValue`
- `SyntheticMediaType`
  - none
  - text_only_assistance
  - abstract_background
  - realistic_person
  - realistic_event
  - realistic_voice
  - unclear

Regeln:

- unclear blockiert Rendering
- realistic_person / realistic_event / realistic_voice benötigen `manual_review_required=True`
- text_only_assistance kann `no` sein

Keine Plattform-API.

## LinkedIn manuell ergänzt

V2 unterstützt jetzt:

- `youtube_shorts`
- `tiktok`
- `instagram_reels`
- `linkedin_manual`

Für LinkedIn ergänzt:

- `linkedin_title`
- `linkedin_caption`
- B2B-orientierte Caption
- Hinweis über Platform Fit auf silent-autoplay/readability

Keine LinkedIn API. Kein OAuth. Kein Scheduling.

## Remotion nur als späterer Spike

Neu:

`docs/rendering_backend_options.md`

Festgehalten:

- aktueller Python/Pillow/FFmpeg-Renderer bleibt aktiv
- Remotion nur späterer Spike-Kandidat
- mögliche Vorteile: React/CSS Motion, bessere UI-Animation
- Risiken: Node/React Stack, Komplexität, Build/Deploy-Aufwand
- Entscheidung: keine Migration jetzt

Zusätzlich:

- `render_backend_candidate`
  - `python_motion`
  - `remotion_spike_candidate`

Keine Remotion-Implementierung.

## 3 Beispiel-Review-Packages, kurz

### 1. AI Output Autopsy

**Candidate:** `ai-output-autopsy-decision-summary-38s`
**Score:** `86/100`
**Status:** ready_for_review
**Hook:** Your AI summary is useless because it has no decision target.
**Anti Example:** useless AI summary with no owner, blocker, or decision.
**Approval:**
`APPROVE ai-output-autopsy-decision-summary-38s v2.0.0 72129542b86819fd`

### 2. Digital Red Flags

**Candidate:** `digital-red-flags-fake-invoice-35s`
**Score:** `87/100`
**Status:** ready_for_review
**Hook:** This invoice looks safe. The trap is in line three.
**Anti Example:** fake invoice with changed payment route under urgency.
**Approval:**
`APPROVE digital-red-flags-fake-invoice-35s v2.0.0 ace02532309afd60`

### 3. Workflow Teardown

**Candidate:** `workflow-teardown-exception-path-40s`
**Score:** `83/100`
**Status:** ready_for_review
**Hook:** Your automation didn't fail. You forgot the exception path.
**Anti Example:** client email missing required address makes automation stop silently.
**Approval:**
`APPROVE workflow-teardown-exception-path-40s v2.0.0 e03c639358deaaf9`

## Offene Punkte

- Sprint-1.5 Änderungen sind noch nicht committed.
- V2 ist per CLI nutzbar, aber bestehende V1-Review-Flows sind noch nicht vollständig migriert.
- Claim Gate ist Stub, keine Quellenrecherche.
- AI Disclosure ist Stub, keine Plattformprüfung.
- Kein Dashboard.
- Keine Analytics-Persistenz.
- Keine Visual Grammar.
- Keine Renderings.
- Keine Plattform-API.

## Vorschlag nächster kleiner Sprint

**Sprint 1.6: V2 Persistence & Audit Prep**

Klein halten:

- ReviewPackageV2 lokal als JSON-Datei speichern
- ApprovalDecisionV2 lokal auditierbar speichern
- kein Dashboard
- keine Plattform-API
- keine Renderings
- Candidate/Package Fingerprint stabilisieren
- Tests:
  - JSON write plan side-effect-free
  - explicit file-write boundary
  - approval cannot apply to changed script hash
  - package version mismatch blocks

Damit hätten wir die Grundlage für spätere manuelle Review-Historie, ohne schon Analytics oder Publishing aufzublasen.