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:
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user