Six-skill presentation system: ingest talks into a rhetoric vault, run interactive clarification, generate a speaker profile, create presentations that match your documented patterns, produce the deck illustrations + thumbnail visual layer, and publish talk pages to a Jekyll shownotes site. Includes a 111-entry Presentation Patterns taxonomy (81 observable: 62 patterns + 19 antipatterns; 30 unobservable: 21 patterns + 9 antipatterns) for scoring, brainstorming, and go-live preparation.
—
—
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Low
Low-risk findings worth noting
#!/usr/bin/env python3
"""Outline schema — the source-of-truth document for a talk.
`outline.yaml` is the single hand-edited (well, agent-edited) artifact per talk.
`narrative.md`, `rhetorical-review.md`, `script.md`, and `slides.md` are
generated from it by separate extractor scripts.
Pattern IDs are validated against the closed enum discovered from
`references/patterns/{prepare,build,deliver}/*.md` at import time. Antipatterns
(`_anti_*.md`) are deliberately excluded — antipatterns surface as detections
in `rhetorical-review.md`, never as authored declarations. The observable
vs unobservable distinction within the patterns taxonomy is a Phase 6
checklist concern, not a schema concern; the schema accepts any non-antipattern.
"""
from __future__ import annotations
import json
import re
import sys
from enum import Enum
from pathlib import Path
from typing import Literal
import yaml
from pydantic import (
BaseModel,
ConfigDict,
Field,
ValidationError,
field_validator,
model_validator,
)
class _StrictModel(BaseModel):
"""Base for every outline model — rejects unknown fields.
`extra='forbid'` catches misspelled or unsupported YAML keys at load
time instead of silently dropping them, which is the right call for
a source-of-truth schema downstream extractors depend on.
"""
model_config = ConfigDict(extra="forbid")
_SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
# ── Pattern enum: discovered from the patterns directory ──────────────
_PATTERNS_DIR = Path(__file__).resolve().parent.parent / "references" / "patterns"
def _discover_pattern_ids() -> tuple[frozenset[str], frozenset[str]]:
"""Return (allowed_in_outline, antipattern_ids).
Walks all three phase directories. Any non-antipattern, non-index file
is allowed; antipatterns (file prefix `_anti_`) are rejected.
"""
allowed: set[str] = set()
antipatterns: set[str] = set()
for phase in ("prepare", "build", "deliver"):
for md in (_PATTERNS_DIR / phase).glob("*.md"):
stem = md.stem
if stem.startswith("_anti_"):
antipatterns.add(stem.removeprefix("_anti_"))
elif stem.startswith("_"):
continue
else:
allowed.add(stem)
return frozenset(allowed), frozenset(antipatterns)
PATTERN_IDS, ANTIPATTERN_IDS = _discover_pattern_ids()
ARCHITECTURE_IDS: frozenset[str] = frozenset(
{
"narrative-arc",
"sparkline",
"fourthought",
"triad",
"talklet",
"expansion-joints",
"lightning-talk",
"takahashi",
"cave-painting",
}
)
# ── Closed enums for instance metadata ───────────────────────────────
class AudienceSpread(str, Enum):
"""Whether the room is mixed in what it accepts as proof.
Drives `walk-around`'s cover-or-match decision. Homogeneity is a claim
about what persuades the room, never about job titles.
"""
heterogeneous = "heterogeneous"
homogeneous = "homogeneous"
class Register(str, Enum):
"""The four question archetypes a `walk-around` answers.
Herrmann's quadrant letters, kept as a recognizable handle. These name
kinds of question, never kinds of person — see
`references/patterns/prepare/walk-around.md`.
"""
a_precision = "A" # what exactly, and how do you know?
b_process = "B" # how does it work, in what order?
c_impact = "C" # who does this affect, and how will it land?
d_implication = "D" # where does this lead, what does it connect to?
class PunchFlavor(str, Enum):
personal = "personal"
unexpected = "unexpected"
novel = "novel"
challenging = "challenging"
humorous = "humorous"
class StarSubtype(str, Enum):
memorable_dramatization = "memorable-dramatization"
repeatable_sound_bite = "repeatable-sound-bite"
evocative_visual = "evocative-visual"
emotive_storytelling = "emotive-storytelling"
shocking_statistic = "shocking-statistic"
class ResistanceVector(str, Enum):
comfort_zone = "comfort-zone"
fear = "fear"
vulnerabilities = "vulnerabilities"
misunderstanding = "misunderstanding"
obstacles = "obstacles"
politics = "politics"
class MasterStoryBeat(str, Enum):
introduce = "introduce"
recall_1 = "recall-1"
recall_2 = "recall-2"
recall_3 = "recall-3"
class SlideFormat(str, Enum):
full = "FULL"
img_txt = "IMG+TXT"
exception = "EXCEPTION"
demo = "DEMO"
# Enum members shadow inherited str methods by design — `SlideFormat.title`
# resolves to the member, never to `str.title`. The assignment-type finding
# is a false positive on every str-Enum member named after a str method.
title = "TITLE" # pyright: ignore[reportAssignmentType]
class Composition(str, Enum):
"""Deck-wide illustration composition (see rules/title-overlay-rules.md).
`standard` overlays titles/footers at apply time over a reserved safe zone;
`poster-theatrical` bakes title + footer into the image as part of the scene.
Absence of the field means `standard`.
"""
standard = "standard"
poster_theatrical = "poster-theatrical"
class SafeZoneName(str, Enum):
upper_third = "upper_third"
middle_third = "middle_third"
lower_third = "lower_third"
left_half = "left_half"
right_half = "right_half"
class Renderer(str, Enum):
pptx = "pptx"
presenterm = "presenterm"
class AudienceTemperament(str, Enum):
doer = "doer"
supplier = "supplier"
influencer = "influencer"
innovator = "innovator"
# ── Pattern application: pattern id + per-pattern instance metadata ──
# Maps pattern id → set of instance-metadata fields it accepts.
# Empty set means the pattern carries no instance metadata.
_PATTERN_INSTANCE_FIELDS: dict[str, frozenset[str]] = {
"opening-punch": frozenset({"flavors"}),
"star-moment": frozenset({"subtype"}),
"inoculation": frozenset({"resistance_vector"}),
"master-story": frozenset({"story_id", "beat"}),
"foreshadowing": frozenset({"plant_id"}),
"call-to-adventure": frozenset({"big_idea_text"}),
"call-to-action": frozenset({"asks"}),
"walk-around": frozenset({"registers"}),
}
_ALL_INSTANCE_FIELDS: frozenset[str] = frozenset(
{
"flavors",
"subtype",
"resistance_vector",
"story_id",
"beat",
"plant_id",
"big_idea_text",
"asks",
"registers",
}
)
class AppliedPattern(_StrictModel):
"""One pattern application — either at talk level or slide level."""
id: str
flavors: list[PunchFlavor] | None = None
subtype: StarSubtype | None = None
resistance_vector: ResistanceVector | None = None
story_id: str | None = None
beat: MasterStoryBeat | None = None
plant_id: str | None = None
big_idea_text: str | None = None
asks: dict[AudienceTemperament, str] | None = None
# Which of the four question archetypes this walk-around application
# answers. The agent judges which registers a claim actually lands;
# check-rhetorical.py checks the union across the talk.
registers: list[Register] | None = Field(default=None, min_length=1)
@field_validator("id")
@classmethod
def _id_in_closed_set(cls, v: str) -> str:
if v in PATTERN_IDS:
return v
if v in ANTIPATTERN_IDS:
raise ValueError(
f"pattern id '{v}' is an antipattern — antipatterns are not "
f"declared in outline.yaml; they surface in rhetorical-review",
)
raise ValueError(
f"pattern id '{v}' not found in references/patterns/",
)
@model_validator(mode="after")
def _instance_data_matches_pattern(self) -> "AppliedPattern":
allowed = _PATTERN_INSTANCE_FIELDS.get(self.id, frozenset())
for field_name in _ALL_INSTANCE_FIELDS:
if getattr(self, field_name) is not None and field_name not in allowed:
raise ValueError(
f"pattern '{self.id}' does not accept '{field_name}' "
f"(accepted: {sorted(allowed) or 'none'})",
)
return self
# ── Slide-level structural ledger entries ────────────────────────────
class Callback(_StrictModel):
kind: Literal["plant", "pay"]
id: str
variation: str | None = None
class ProgressiveListEntry(_StrictModel):
id: str
item_index: int = Field(ge=1)
class RunningGagEntry(_StrictModel):
id: str
appearance_index: int = Field(ge=1)
class SafeZone(_StrictModel):
"""Per-slide title safe zone for standard-composition illustrations.
Reserves negative space in the generated image for an overlaid title;
`surface` optionally overrides the default backdrop description for the
zone. See rules/title-overlay-rules.md.
"""
zone: SafeZoneName
surface: str | None = None
class Build(_StrictModel):
step: int = Field(ge=0)
desc: str
# Backwards-erase prompt for `generate-illustrations.py --build`. `desc` is
# the additive, human-facing reveal ("Add the second pillar"); `erase` is the
# instruction that turns step N+1 into this step by removing what N+1 added,
# with explicit "Keep ..." clauses for everything that must persist. The
# final (full-image) step needs no erase prompt — it is copied from the base
# slide image. Optional so slides whose builds are only deck-budget
# placeholders validate; --build refuses any non-final step missing it.
erase: str | None = None
# Optional erase region as a normalized bounding box [x0, y0, x1, y1] in
# 0..1 image coords (origin top-left). When set, --build confines the edit to
# this box: the static background outside it is preserved pixel-for-pixel
# (OpenAI gets a real edit mask; Gemini's full regeneration is composited back
# over the prior frame outside the box). Without it, the whole frame is
# regenerated and a static background can drift (#90). Belongs only on
# non-final steps — the final step is a verbatim copy, never edited.
erase_region: tuple[float, float, float, float] | None = None
@model_validator(mode="after")
def _check_erase_region(self) -> "Build":
if self.erase_region is None:
return self
x0, y0, x1, y1 = self.erase_region
if not all(0.0 <= v <= 1.0 for v in self.erase_region):
raise ValueError(
f"erase_region values must be in 0..1 (normalized), got {self.erase_region}"
)
if x0 >= x1 or y0 >= y1:
raise ValueError(
"erase_region must be [x0, y0, x1, y1] with x0 < x1 and y0 < y1, "
f"got {self.erase_region}"
)
return self
# ── Screenplay-form script ───────────────────────────────────────────
class ScriptItem(_StrictModel):
"""One unit in a slide's screenplay block.
Exactly one of {cue, parenthetical, line} is set. `speaker` is required
iff the talk has more than one speaker (enforced at the Outline level).
"""
cue: str | None = None
parenthetical: str | None = None
line: str | None = None
speaker: str | None = None
@model_validator(mode="after")
def _exactly_one_content(self) -> "ScriptItem":
present = [
x for x in (self.cue, self.parenthetical, self.line) if x is not None
]
if len(present) != 1:
raise ValueError(
"script item must have exactly one of {cue, parenthetical, line}",
)
if self.speaker is not None and self.cue is not None:
raise ValueError(
"speaker attribution does not apply to cue items "
"(cues are scene-level, not speaker-attributed)",
)
return self
# ── Narrative-side: chapters + argument beats ────────────────────────
class ArgumentBeat(_StrictModel):
text: str
slide_refs: list[int] = Field(default_factory=list)
tags: list[str] = Field(default_factory=list)
class Chapter(_StrictModel):
id: str
title: str
target_min: float = Field(gt=0)
accent: str | None = None
cuttable: bool = False
argument_beats: list[ArgumentBeat] = Field(default_factory=list)
# ── Illustration style anchor (optional, per talk) ───────────────────
class StyleAnchor(_StrictModel):
model: str
full: str
imgtxt: str
conventions: str
# Deck-wide composition; null == standard overlay. poster-theatrical bakes
# the title (+ embedded_footer) into every image. See rules/title-overlay-rules.md.
composition: Composition | None = None
embedded_footer: str | None = None
# How baked title + footer text is rendered in poster-theatrical mode (e.g.
# "glowing hand-script neon on an in-scene surface"). Lives on the anchor so
# every slide's text is styled identically; the per-slide image_prompt carries
# only the scene and text_overlay carries only the literal title string. Only
# consumed in poster-theatrical mode; null falls back to a generic treatment.
text_treatment: str | None = None
# ── Talk metadata ────────────────────────────────────────────────────
class TalkMetadata(_StrictModel):
title: str
slug: str
speakers: list[str] = Field(min_length=1)
duration_min: float = Field(gt=0)
audience: str
# Whether the room is mixed in what it accepts as proof, or uniform.
# Drives the `walk-around` cover-or-match decision: a heterogeneous room
# gets all four registers, a homogeneous one gets its own. Declared at
# intake so the choice is made deliberately rather than defaulted into.
audience_spread: AudienceSpread
# Which register a homogeneous room speaks. Required iff the room is
# homogeneous; forbidden otherwise (there is no single register to name
# for a mixed room).
dominant_register: Register | None = None
mode: str
venue: str
slide_budget: int = Field(gt=0)
pacing_wpm: tuple[int, int]
architecture: str
applied_patterns: list[AppliedPattern] = Field(default_factory=list)
@model_validator(mode="after")
def _walk_around_is_per_claim(self) -> "TalkMetadata":
"""`walk-around` audits a claim, and a claim has a location.
Slides and interludes both carry claims and are individually
checkable — a live demo answering "how does it work in what order"
is a B answer no slide can match. Talk level carries no claim: it
asserts that the talk answers a register somewhere, with nothing to
check it against, which is the unfalsifiable shape the audit exists
to prevent.
"""
if any(p.id == "walk-around" for p in self.applied_patterns):
raise ValueError(
"`walk-around` is a per-claim audit and cannot be declared at "
"talk level — attach it to the slides or interludes carrying "
"the load-bearing claims, with `registers:` naming what each "
"one answers",
)
return self
@model_validator(mode="after")
def _register_matches_spread(self) -> "TalkMetadata":
if self.audience_spread == AudienceSpread.homogeneous:
if self.dominant_register is None:
raise ValueError(
"audience_spread 'homogeneous' requires 'dominant_register' "
"(A=precision/evidence, B=process/sequence, C=human impact, "
"D=implication) — name the register the room speaks, or set "
"audience_spread to 'heterogeneous' and cover all four",
)
elif self.dominant_register is not None:
raise ValueError(
"audience_spread 'heterogeneous' does not accept "
"'dominant_register' — a mixed room has no single register; "
"cover all four via walk-around instead",
)
return self
# Spec metadata (collapsed from the legacy presentation-spec.md).
# All optional — older outlines without these fields still validate.
thesis: str | None = None
# A reader-facing summary of `thesis` — a couple of paragraphs or a short
# bulleted list. Authored as a distillation of the elaborated thesis;
# narrative.md renders it verbatim as the TL;DR (it never reprints `thesis`).
tldr: str | None = None
shownotes_url_base: str | None = None
commercial_intent: str | None = None
profanity_register: str | None = None
must_include: list[str] = Field(default_factory=list)
must_avoid: list[str] = Field(default_factory=list)
catalog_reference: str | None = None
delivery_count: int | None = Field(default=None, ge=1)
delivery_date: str | None = None # ISO YYYY-MM-DD
# Deck tooling + theme, sourced in Phase 2 Decision #2. All optional —
# older outlines without these fields still validate.
engine: Renderer | None = None # pptx | presenterm; null = legacy/inferred
deck_theme: str | None = None # free-string theme/template pointer (provenance)
engine_source: str | None = None # how the engine was sourced (provenance)
@field_validator("architecture")
@classmethod
def _architecture_in_closed_set(cls, v: str) -> str:
if v not in ARCHITECTURE_IDS:
raise ValueError(
f"architecture '{v}' not in closed set: {sorted(ARCHITECTURE_IDS)}",
)
return v
@field_validator("slug")
@classmethod
def _slug_is_kebab(cls, v: str) -> str:
if not _SLUG_RE.match(v):
raise ValueError(
f"slug '{v}' must be kebab-case (lowercase alphanumeric + "
f"single hyphens, no leading/trailing/double dashes)",
)
return v
@field_validator("delivery_date")
@classmethod
def _delivery_date_iso(cls, v: str | None) -> str | None:
if v is None:
return v
if not re.match(r"^\d{4}-\d{2}-\d{2}$", v):
raise ValueError(
f"delivery_date '{v}' must be ISO YYYY-MM-DD",
)
return v
# ── Slide ────────────────────────────────────────────────────────────
class Interlude(_StrictModel):
"""A production interlude between slides — typically a live demo.
The speaker switches off the deck for the duration. The interlude is
anchored by `after_slide`: it plays between slide N and slide N+1.
Audience never sees a numbered slide for it; the script extractor
inlines it in sequence.
"""
id: str
after_slide: int = Field(ge=0)
title: str
chapter: str
cuttable: bool = False
script: list[ScriptItem] = Field(default_factory=list)
callbacks: list[Callback] = Field(default_factory=list)
applied_patterns: list[AppliedPattern] = Field(default_factory=list)
class Slide(_StrictModel):
n: int = Field(ge=0)
cuttable: bool = False
chapter: str
title: str
format: SlideFormat
format_justification: str | None = None
visual: str | None = None
text_overlay: str | None = None
image_prompt: str | None = None
safe_zone: SafeZone | None = None
builds: list[Build] = Field(default_factory=list)
script: list[ScriptItem] = Field(default_factory=list)
applied_patterns: list[AppliedPattern] = Field(default_factory=list)
callbacks: list[Callback] = Field(default_factory=list)
progressive_lists: list[ProgressiveListEntry] = Field(default_factory=list)
running_gags: list[RunningGagEntry] = Field(default_factory=list)
placeholders: list[str] = Field(default_factory=list)
big_idea: bool = False
thesis: Literal["preview", "payoff"] | None = None
@model_validator(mode="after")
def _exception_needs_justification(self) -> "Slide":
if self.format == SlideFormat.exception and not self.format_justification:
raise ValueError(
f"slide {self.n}: format=EXCEPTION requires format_justification",
)
return self
@model_validator(mode="after")
def _build_steps_contiguous_from_zero(self) -> "Slide":
"""Builds start at 0 and are contiguous (0, 1, 2, …, N-1).
Per phase3-content.md: `step: 0` is the empty frame and each
subsequent step adds one element. Holes or wrong starting steps
produce mislabeled build-NN images and break slide-budget
arithmetic.
"""
if not self.builds:
return self
steps = [b.step for b in self.builds]
expected = list(range(len(steps)))
if sorted(steps) != expected:
raise ValueError(
f"slide {self.n}: build steps must be contiguous starting "
f"at 0; got {steps}, expected {expected}",
)
if steps != expected:
raise ValueError(
f"slide {self.n}: build steps not in ascending order: {steps}",
)
return self
# ── Shared cross-field checks ────────────────────────────────────────
def _check_beat_slide_refs(
chapters: list["Chapter"],
slides: list["Slide"],
) -> None:
"""Reject argument_beat slide_refs that point at a non-existent slide.
Shared by `Outline` (full) and `PartialOutline` (narrative phase). When no
slide is authored yet, the slide set is empty, so any ref is invalid — beats
must leave slide_refs empty until Phase 3 wires them to real slides.
"""
slide_ns = {s.n for s in slides}
for c in chapters:
for beat in c.argument_beats:
for ref in beat.slide_refs:
if ref not in slide_ns:
hint = (
" — no slides are authored yet; leave slide_refs "
"empty until Phase 3"
if not slide_ns
else ""
)
raise ValueError(
f"chapter '{c.id}' argument_beat slide_ref {ref} "
f"does not match any slide in slides[]{hint}",
)
# ── Top-level outline ────────────────────────────────────────────────
class Outline(_StrictModel):
talk: TalkMetadata
style_anchor: StyleAnchor | None = None
chapters: list[Chapter] = Field(min_length=1)
slides: list[Slide] = Field(min_length=1)
interludes: list[Interlude] = Field(default_factory=list)
@model_validator(mode="after")
def _chapter_ids_unique(self) -> "Outline":
ids = [c.id for c in self.chapters]
if len(set(ids)) != len(ids):
raise ValueError(f"duplicate chapter ids: {ids}")
return self
@model_validator(mode="after")
def _chapter_refs_valid(self) -> "Outline":
chapter_ids = {c.id for c in self.chapters}
for s in self.slides:
if s.chapter not in chapter_ids:
raise ValueError(
f"slide {s.n} references unknown chapter '{s.chapter}' "
f"(known: {sorted(chapter_ids)})",
)
for il in self.interludes:
if il.chapter not in chapter_ids:
raise ValueError(
f"interlude '{il.id}' references unknown chapter "
f"'{il.chapter}' (known: {sorted(chapter_ids)})",
)
return self
@model_validator(mode="after")
def _argument_beat_slide_refs_valid(self) -> "Outline":
_check_beat_slide_refs(self.chapters, self.slides)
return self
@model_validator(mode="after")
def _interlude_anchors_valid(self) -> "Outline":
slide_ns = {s.n for s in self.slides}
for il in self.interludes:
if il.after_slide not in slide_ns:
raise ValueError(
f"interlude '{il.id}' anchored after_slide={il.after_slide} "
f"but no slide has that number",
)
ids = [il.id for il in self.interludes]
if len(set(ids)) != len(ids):
raise ValueError(f"duplicate interlude ids: {ids}")
return self
@model_validator(mode="after")
def _slide_numbers_unique_and_ordered(self) -> "Outline":
nums = [s.n for s in self.slides]
if len(set(nums)) != len(nums):
raise ValueError(f"duplicate slide numbers: {nums}")
if nums != sorted(nums):
raise ValueError(f"slide numbers not in ascending order: {nums}")
return self
@model_validator(mode="after")
def _exactly_one_big_idea(self) -> "Outline":
marked = [s.n for s in self.slides if s.big_idea]
if len(marked) != 1:
raise ValueError(
f"expected exactly 1 slide with big_idea=true, found {len(marked)} "
f"(slides: {marked})",
)
return self
@model_validator(mode="after")
def _speaker_attribution_matches_talk_speakers(self) -> "Outline":
speakers = set(self.talk.speakers)
multi = len(speakers) > 1
def _check(label: str, script: list[ScriptItem]) -> None:
for i, item in enumerate(script):
# `line:` items have stricter rules than `parenthetical:` items.
# Cues never carry `speaker:` (already enforced at ScriptItem level).
if item.line is not None:
if multi and item.speaker is None:
raise ValueError(
f"{label} script[{i}]: multi-speaker talk requires "
f"`speaker:` on every line item",
)
if not multi and item.speaker is not None:
raise ValueError(
f"{label} script[{i}]: single-speaker talk must not "
f"attribute lines",
)
elif item.parenthetical is not None:
# speaker on parenthetical is OPTIONAL in multi-speaker
# mode (floating stage directions describe the scene) and
# FORBIDDEN in single-speaker mode (redundant).
if not multi and item.speaker is not None:
raise ValueError(
f"{label} script[{i}]: single-speaker talk must not "
f"attribute parentheticals",
)
if item.speaker is not None and item.speaker not in speakers:
raise ValueError(
f"{label} script[{i}]: speaker '{item.speaker}' "
f"not in talk.speakers={sorted(speakers)}",
)
for s in self.slides:
_check(f"slide {s.n}", s.script)
for il in self.interludes:
_check(f"interlude '{il.id}'", il.script)
return self
@model_validator(mode="after")
def _slide_budget_respected(self) -> "Outline":
total = sum(max(len(s.builds), 1) for s in self.slides)
if total > self.talk.slide_budget:
raise ValueError(
f"slide budget exceeded: {total} slides (builds expanded) > "
f"{self.talk.slide_budget} budget",
)
return self
@model_validator(mode="after")
def _callback_plants_paid(self) -> "Outline":
plants: dict[str, int] = {}
pays: dict[str, int] = {}
for s in self.slides:
for cb in s.callbacks:
bucket = plants if cb.kind == "plant" else pays
bucket[cb.id] = bucket.get(cb.id, 0) + 1
for il in self.interludes:
for cb in il.callbacks:
bucket = plants if cb.kind == "plant" else pays
bucket[cb.id] = bucket.get(cb.id, 0) + 1
unpaid = sorted(set(plants) - set(pays))
orphan_pays = sorted(set(pays) - set(plants))
problems: list[str] = []
if unpaid:
problems.append(f"unpaid plants: {unpaid}")
if orphan_pays:
problems.append(f"pays without plants: {orphan_pays}")
if problems:
raise ValueError("; ".join(problems))
return self
# ── Narrative-phase partial view ─────────────────────────────────────
class PartialOutline(_StrictModel):
"""Talk metadata plus an optional, slide-less narrative scaffold.
Before any slide exists, `narrative.md` renders `talk.tldr` as the TL;DR
plus the `chapters[].argument_beats` arc (fully authored by the end of
Phase 2); once `slides[]` are authored, the full view switches to a
per-slide walk. `extract-narrative.py` renders from this view so the human
can review and approve the narrative during Phases 1–2.
Every present section is still validated by its own field- and model-level
validators (a `slides[]` entry, if present, still runs its build-step and
EXCEPTION checks), and slide_refs are still checked for integrity. What this
view *skips* are the `Outline`-level cross-field validators that presuppose a
complete deck: `chapters`/`slides` `min_length`, the `big_idea` singleton,
slide-budget arithmetic, callback pairing, slide ordering/uniqueness,
chapter/interlude anchoring, and speaker attribution. The full `Outline`
stays the Phase 3+ source-of-truth contract.
"""
talk: TalkMetadata
style_anchor: StyleAnchor | None = None
chapters: list[Chapter] = Field(default_factory=list)
slides: list[Slide] = Field(default_factory=list)
interludes: list[Interlude] = Field(default_factory=list)
@model_validator(mode="after")
def _argument_beat_slide_refs_valid(self) -> "PartialOutline":
_check_beat_slide_refs(self.chapters, self.slides)
return self
# ── Loader + CLI guard ───────────────────────────────────────────────
def load_outline(path: Path | str) -> Outline:
text = Path(path).read_text(encoding="utf-8")
data = yaml.safe_load(text)
return Outline.model_validate(data)
def load_outline_partial(path: Path | str) -> PartialOutline:
text = Path(path).read_text(encoding="utf-8")
data = yaml.safe_load(text)
return PartialOutline.model_validate(data)
def main(argv: list[str]) -> int:
args = argv[1:]
emit_json = "--emit-json" in args
args = [a for a in args if a != "--emit-json"]
if len(args) != 1:
print(
"usage: outline_schema.py [--emit-json] <outline.yaml>",
file=sys.stderr,
)
return 2
try:
outline = load_outline(args[0])
except (OSError, yaml.YAMLError, ValidationError) as exc:
print(f"FAIL: {exc}", file=sys.stderr)
return 1
if emit_json:
json.dump(outline.model_dump(mode="json"), sys.stdout, indent=2)
sys.stdout.write("\n")
else:
print(
f"OK: {len(outline.slides)} slides across {len(outline.chapters)} chapters",
)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv)).tessl-plugin
rules
skills
illustrations
presentation-creator
references
patterns
build
deliver
prepare
scripts
shownotes-publisher
vault-clarification
vault-ingress
references
scripts
vault-profile