Phase 1 implementation: script ingestion + AI extraction pipeline

Complete working pipeline from Fountain script to validated scene JSON:
- Schemas (Pydantic): all 7 layers defined upfront
- Fountain parser + normalizer (Layer 1)
- AI scene extractor with prompt contracts (Layer 2)
- Schema validator + scene-specific semantic validator
- Structured JSON logging per layer/scene execution
- Versioned output writer (never overwrites)
- Retry engine with 4-level failure escalation
- Stop condition evaluator (per-unit + global halts)
- Diff/drift detector for re-run comparison
- CLI entry point with --dry-run, --scene, --test, --force
- 3 test scripts (dialogue-heavy, action-heavy, nonstandard)
- Expected output files for regression testing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 15:49:43 -07:00
co-authored by Claude Opus 4.6
parent 2218e47c1f
commit 87d0af0748
42 changed files with 2418 additions and 0 deletions
View File
+210
View File
@@ -0,0 +1,210 @@
"""Fountain format parser. Deterministic code — no AI.
Follows the Fountain spec: https://fountain.io/syntax
Converts raw .fountain text into a list of ScriptElement objects.
"""
import re
from src.schemas.script_element import ScriptElement
# Fountain scene heading prefixes
_SCENE_HEADING_PREFIXES = (
"INT ", "INT.", "EXT ", "EXT.", "INT/EXT", "INT./EXT", "I/E",
"int ", "int.", "ext ", "ext.", "int/ext", "int./ext", "i/e",
)
_TRANSITION_PATTERN = re.compile(r"^[A-Z\s]+TO:$")
_FORCED_SCENE_HEADING = re.compile(r"^\.[A-Z]")
_FORCED_TRANSITION = re.compile(r"^>(?!<)")
_CENTERED_TEXT = re.compile(r"^>.*<$")
_PARENTHETICAL = re.compile(r"^\(.*\)$")
def parse_fountain(text: str) -> list[ScriptElement]:
"""Parse Fountain-formatted text into ScriptElement list.
Args:
text: Raw contents of a .fountain file.
Returns:
Ordered list of ScriptElement objects with line numbers.
"""
lines = text.replace("\r\n", "\n").split("\n")
elements: list[ScriptElement] = []
i = 0
while i < len(lines):
line = lines[i]
stripped = line.strip()
line_num = i + 1 # 1-indexed
# Skip empty lines
if not stripped:
i += 1
continue
# Title page (key: value at start of document) — skip
if i < _find_first_content_line(lines) and ":" in stripped:
i += 1
continue
# Boneyard (/* ... */) — skip
if stripped.startswith("/*"):
while i < len(lines) and "*/" not in lines[i]:
i += 1
i += 1
continue
# Notes ([[ ... ]]) — skip
if stripped.startswith("[[") and stripped.endswith("]]"):
i += 1
continue
# Section headers (# ) — skip (metadata, not story)
if stripped.startswith("#"):
i += 1
continue
# Synopsis (= ) — skip (metadata)
if stripped.startswith("=") and not stripped.startswith("=="):
i += 1
continue
# Page break (===) — skip
if stripped.startswith("==="):
i += 1
continue
# Forced scene heading (.LOCATION)
if _FORCED_SCENE_HEADING.match(stripped):
elements.append(ScriptElement(
type="scene_heading",
text=stripped[1:].strip(), # remove leading dot
line_number=line_num,
))
i += 1
continue
# Standard scene heading
if stripped.upper().startswith(_SCENE_HEADING_PREFIXES):
elements.append(ScriptElement(
type="scene_heading",
text=stripped,
line_number=line_num,
))
i += 1
continue
# Forced transition (> TEXT)
if _FORCED_TRANSITION.match(stripped):
elements.append(ScriptElement(
type="transition",
text=stripped[1:].strip(),
line_number=line_num,
))
i += 1
continue
# Standard transition (SOMETHING TO:)
if _TRANSITION_PATTERN.match(stripped):
elements.append(ScriptElement(
type="transition",
text=stripped,
line_number=line_num,
))
i += 1
continue
# Centered text — treat as action
if _CENTERED_TEXT.match(stripped):
elements.append(ScriptElement(
type="action",
text=stripped[1:-1].strip(),
line_number=line_num,
))
i += 1
continue
# Character + Dialogue block
if _is_character_line(stripped, lines, i):
# Character name
char_name = stripped.rstrip("^").strip() # remove dual dialogue caret
if char_name.startswith("@"):
char_name = char_name[1:] # forced character
elements.append(ScriptElement(
type="character",
text=char_name,
line_number=line_num,
))
i += 1
# Consume parentheticals and dialogue that follow
while i < len(lines):
next_line = lines[i].strip()
if not next_line:
break
if _PARENTHETICAL.match(next_line):
elements.append(ScriptElement(
type="parenthetical",
text=next_line,
line_number=i + 1,
))
else:
elements.append(ScriptElement(
type="dialogue",
text=next_line,
line_number=i + 1,
))
i += 1
continue
# Default: action
elements.append(ScriptElement(
type="action",
text=stripped,
line_number=line_num,
))
i += 1
return elements
def _is_character_line(stripped: str, lines: list[str], index: int) -> bool:
"""Determine if a line is a character cue.
Fountain rules:
- All uppercase
- Followed by dialogue (non-empty next line after possible blank)
- May end with ^ (dual dialogue)
- May start with @ (forced character)
"""
if stripped.startswith("@"):
return True
# Must be uppercase (ignoring parenthetical extensions like (V.O.), (O.S.))
name_part = re.sub(r"\(.*?\)", "", stripped).strip().rstrip("^").strip()
if not name_part:
return False
if not name_part.replace(" ", "").replace(".", "").replace("'", "").replace("-", "").isalpha():
return False
if name_part != name_part.upper():
return False
# Must have a non-empty line following (the dialogue)
next_i = index + 1
if next_i < len(lines) and lines[next_i].strip():
return True
return False
def _find_first_content_line(lines: list[str]) -> int:
"""Find the first line that isn't part of the title page.
Title page ends at the first blank line.
"""
for i, line in enumerate(lines):
if not line.strip():
return i + 1
return 0
+29
View File
@@ -0,0 +1,29 @@
"""Normalizer — wraps parsed ScriptElements into a validated NormalizedScript."""
from src.schemas.script_element import ScriptElement
from src.schemas.normalized_script import NormalizedScript
class NormalizationError(Exception):
pass
def normalize(elements: list[ScriptElement]) -> NormalizedScript:
"""Validate and wrap ScriptElements into NormalizedScript.
Args:
elements: Output from fountain_parser.parse_fountain()
Returns:
Validated NormalizedScript.
Raises:
NormalizationError: If elements are empty or contain no scene headings.
"""
if not elements:
raise NormalizationError("No elements to normalize — input is empty")
try:
return NormalizedScript(elements=elements)
except ValueError as e:
raise NormalizationError(str(e)) from e