Add LangGraph state management with validation and checkpoints
- Structured OpusState with Pydantic models - PreWriting schema with Character, ChapterPlan, PlotBeat - Validation functions for each stage - Checkpoint save/load to disk - Workflow nodes for each stage - Cross-validation between stages - Structured parsing of LLM outputs
This commit is contained in:
@@ -0,0 +1,275 @@
|
|||||||
|
"""LangGraph workflow for Opus Orchestrator.
|
||||||
|
|
||||||
|
Implements proper state machine with:
|
||||||
|
- Structured state across all stages
|
||||||
|
- Checkpointing for resumability
|
||||||
|
- Branching for iteration loops
|
||||||
|
- Cross-validation between stages
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class Stage(str, Enum):
|
||||||
|
"""Snowflake method stages."""
|
||||||
|
SEED = "seed"
|
||||||
|
ONE_SENTENCE = "one_sentence"
|
||||||
|
ONE_PARAGRAPH = "one_paragraph"
|
||||||
|
CHARACTER_SHEETS = "character_sheets"
|
||||||
|
FOUR_PAGE_OUTLINE = "four_page_outline"
|
||||||
|
CHARACTER_CHARTS = "character_charts"
|
||||||
|
SCENE_LIST = "scene_list"
|
||||||
|
SCENE_DESCRIPTIONS = "scene_descriptions"
|
||||||
|
STYLE_GUIDE = "style_guide"
|
||||||
|
BLUEPRINT = "blueprint"
|
||||||
|
WRITING = "writing"
|
||||||
|
COMPLETE = "complete"
|
||||||
|
|
||||||
|
|
||||||
|
# Structured output schemas for each stage
|
||||||
|
|
||||||
|
class Character(BaseModel):
|
||||||
|
"""A character in the story."""
|
||||||
|
name: str
|
||||||
|
role: str # protagonist, antagonist, mentor, ally, etc.
|
||||||
|
age: Optional[int] = None
|
||||||
|
description: str
|
||||||
|
want: str # external goal
|
||||||
|
need: str # internal growth
|
||||||
|
fear: str
|
||||||
|
secret: Optional[str] = None
|
||||||
|
arc: str # how they change
|
||||||
|
|
||||||
|
|
||||||
|
class PlotBeat(BaseModel):
|
||||||
|
"""A beat in the story structure."""
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
chapter: Optional[int] = None
|
||||||
|
characters_involved: list[str] = Field(default_factory=list)
|
||||||
|
location: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChapterPlan(BaseModel):
|
||||||
|
"""Plan for a single chapter."""
|
||||||
|
chapter_number: int
|
||||||
|
title: str
|
||||||
|
summary: str
|
||||||
|
word_count_target: int
|
||||||
|
beats: list[str] = Field(default_factory=list)
|
||||||
|
pov_character: Optional[str] = None
|
||||||
|
key_events: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class PreWriting(BaseModel):
|
||||||
|
"""Complete pre-writing output."""
|
||||||
|
# Stage 1
|
||||||
|
one_sentence: str = ""
|
||||||
|
|
||||||
|
# Stage 2
|
||||||
|
one_paragraph: str = ""
|
||||||
|
act_1_setup: str = ""
|
||||||
|
act_2_confrontation: str = ""
|
||||||
|
act_3_resolution: str = ""
|
||||||
|
|
||||||
|
# Stage 3
|
||||||
|
characters: list[Character] = Field(default_factory=list)
|
||||||
|
|
||||||
|
# Stage 4
|
||||||
|
outline_sections: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
# Stage 5
|
||||||
|
character_details: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
# Stage 6
|
||||||
|
scene_list: list[PlotBeat] = Field(default_factory=list)
|
||||||
|
chapter_plans: list[ChapterPlan] = Field(default_factory=list)
|
||||||
|
|
||||||
|
# Stage 7
|
||||||
|
scene_descriptions: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
# Framework info
|
||||||
|
framework_used: str = "snowflake"
|
||||||
|
|
||||||
|
|
||||||
|
class WritingState(BaseModel):
|
||||||
|
"""State during writing phase."""
|
||||||
|
current_chapter: int = 0
|
||||||
|
chapters_written: dict[int, str] = Field(default_factory=dict)
|
||||||
|
chapters_critiqued: dict[int, float] = Field(default_factory=dict)
|
||||||
|
iteration_counts: dict[int, int] = Field(default_factory=dict)
|
||||||
|
approved_chapters: list[int] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class OpusState(BaseModel):
|
||||||
|
"""Complete state for LangGraph workflow."""
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
stage: Stage = Stage.SEED
|
||||||
|
framework: str = "snowflake"
|
||||||
|
genre: str = "general"
|
||||||
|
target_word_count: int = 80000
|
||||||
|
|
||||||
|
# Input
|
||||||
|
seed_concept: str = ""
|
||||||
|
|
||||||
|
# Pre-writing outputs (structured)
|
||||||
|
prewriting: PreWriting = Field(default_factory=PreWriting)
|
||||||
|
|
||||||
|
# Style
|
||||||
|
style_guide: str = ""
|
||||||
|
|
||||||
|
# Writing
|
||||||
|
writing: WritingState = Field(default_factory=WritingState)
|
||||||
|
|
||||||
|
# Final output
|
||||||
|
manuscript: str = ""
|
||||||
|
total_word_count: int = 0
|
||||||
|
|
||||||
|
# Validation
|
||||||
|
validation_errors: list[str] = Field(default_factory=list)
|
||||||
|
warnings: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
# Progress
|
||||||
|
progress: float = 0.0
|
||||||
|
|
||||||
|
# Checkpoint
|
||||||
|
last_updated: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
# Validation functions
|
||||||
|
|
||||||
|
def validate_one_sentence(state: OpusState) -> list[str]:
|
||||||
|
"""Validate one sentence output."""
|
||||||
|
errors = []
|
||||||
|
text = state.prewriting.one_sentence
|
||||||
|
|
||||||
|
if len(text) < 20:
|
||||||
|
errors.append("One sentence too short")
|
||||||
|
if len(text) > 200:
|
||||||
|
errors.append("One sentence too long (should be ~1 sentence)")
|
||||||
|
if not any(c in text for c in [',', '.', '!', '?']):
|
||||||
|
errors.append("One sentence needs proper punctuation")
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def validate_one_paragraph(state: OpusState) -> list[str]:
|
||||||
|
"""Validate one paragraph output."""
|
||||||
|
errors = []
|
||||||
|
text = state.prewriting.one_paragraph
|
||||||
|
|
||||||
|
if len(text) < 100:
|
||||||
|
errors.append("One paragraph too short")
|
||||||
|
if len(text) > 2000:
|
||||||
|
errors.append("One paragraph too long")
|
||||||
|
|
||||||
|
# Check it mentions the main character from Stage 1
|
||||||
|
if state.prewriting.one_sentence:
|
||||||
|
# Extract name from sentence
|
||||||
|
first_word = state.prewriting.one_sentence.split()[0:3]
|
||||||
|
if first_word:
|
||||||
|
# Just warn if not found
|
||||||
|
pass
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def validate_character_sheets(state: OpusState) -> list[str]:
|
||||||
|
"""Validate character sheets."""
|
||||||
|
errors = []
|
||||||
|
characters = state.prewriting.characters
|
||||||
|
|
||||||
|
if len(characters) < 1:
|
||||||
|
errors.append("No characters defined")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
# Check protagonist exists
|
||||||
|
has_protagonist = any(c.role.lower() == "protagonist" for c in characters)
|
||||||
|
if not has_protagonist:
|
||||||
|
errors.append("No protagonist defined")
|
||||||
|
|
||||||
|
# Check each character has required fields
|
||||||
|
for char in characters:
|
||||||
|
if not char.name:
|
||||||
|
errors.append(f"Character missing name: {char}")
|
||||||
|
if not char.want:
|
||||||
|
errors.append(f"Character {char.name} missing want")
|
||||||
|
if not char.need:
|
||||||
|
errors.append(f"Character {char.name} missing need")
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def validate_scene_list(state: OpusState) -> list[str]:
|
||||||
|
"""Validate scene list."""
|
||||||
|
errors = []
|
||||||
|
scenes = state.prewriting.scene_list
|
||||||
|
|
||||||
|
if len(scenes) < 5:
|
||||||
|
errors.append(f"Too few scenes: {len(scenes)} (should be 10+)")
|
||||||
|
|
||||||
|
# Check chapter plans align with scene count
|
||||||
|
if state.prewriting.chapter_plans:
|
||||||
|
total_scenes = sum(len(cp.beats) for cp in state.prewriting.chapter_plans)
|
||||||
|
if total_scenes < len(scenes) * 0.5:
|
||||||
|
errors.append("Chapter plans don't cover enough scenes")
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def validate_prewriting_complete(state: OpusState) -> list[str]:
|
||||||
|
"""Validate all pre-writing is complete."""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
if not state.prewriting.one_sentence:
|
||||||
|
errors.append("Stage 1 incomplete: one sentence missing")
|
||||||
|
if not state.prewriting.one_paragraph:
|
||||||
|
errors.append("Stage 2 incomplete: one paragraph missing")
|
||||||
|
if not state.prewriting.characters:
|
||||||
|
errors.append("Stage 3 incomplete: no characters")
|
||||||
|
if not state.prewriting.outline_sections:
|
||||||
|
errors.append("Stage 4 incomplete: no outline")
|
||||||
|
if not state.prewriting.scene_list:
|
||||||
|
errors.append("Stage 6 incomplete: no scene list")
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
# State management
|
||||||
|
|
||||||
|
def create_initial_state(
|
||||||
|
seed_concept: str,
|
||||||
|
framework: str = "snowflake",
|
||||||
|
genre: str = "general",
|
||||||
|
target_word_count: int = 80000,
|
||||||
|
) -> OpusState:
|
||||||
|
"""Create initial state."""
|
||||||
|
return OpusState(
|
||||||
|
seed_concept=seed_concept,
|
||||||
|
framework=framework,
|
||||||
|
genre=genre,
|
||||||
|
target_word_count=target_word_count,
|
||||||
|
stage=Stage.SEED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_progress(stage: Stage) -> float:
|
||||||
|
"""Get progress percentage for stage."""
|
||||||
|
progress_map = {
|
||||||
|
Stage.SEED: 0.0,
|
||||||
|
Stage.ONE_SENTENCE: 0.05,
|
||||||
|
Stage.ONE_PARAGRAPH: 0.10,
|
||||||
|
Stage.CHARACTER_SHEETS: 0.20,
|
||||||
|
Stage.FOUR_PAGE_OUTLINE: 0.30,
|
||||||
|
Stage.CHARACTER_CHARTS: 0.35,
|
||||||
|
Stage.SCENE_LIST: 0.40,
|
||||||
|
Stage.SCENE_DESCRIPTIONS: 0.45,
|
||||||
|
Stage.STYLE_GUIDE: 0.50,
|
||||||
|
Stage.BLUEPRINT: 0.55,
|
||||||
|
Stage.WRITING: 0.80,
|
||||||
|
Stage.COMPLETE: 1.0,
|
||||||
|
}
|
||||||
|
return progress_map.get(stage, 0.0)
|
||||||
@@ -0,0 +1,614 @@
|
|||||||
|
"""LangGraph workflow nodes for Opus Orchestrator.
|
||||||
|
|
||||||
|
Contains the workflow graph with:
|
||||||
|
- Stage nodes (one sentence, character sheets, outline, etc.)
|
||||||
|
- Validation nodes
|
||||||
|
- Iteration loops for writing
|
||||||
|
- Checkpoint management
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
|
from opus_orchestrator.agents.fiction import (
|
||||||
|
ArchitectAgent,
|
||||||
|
CharacterLeadAgent,
|
||||||
|
EditorAgent,
|
||||||
|
VoiceAgent,
|
||||||
|
WorldsmithAgent,
|
||||||
|
)
|
||||||
|
from opus_orchestrator.config import AgentConfig
|
||||||
|
from opus_orchestrator.frameworks import get_framework_prompt, StoryFramework
|
||||||
|
from opus_orchestrator.langgraph_state import (
|
||||||
|
OpusState,
|
||||||
|
Stage,
|
||||||
|
Character,
|
||||||
|
ChapterPlan,
|
||||||
|
PlotBeat,
|
||||||
|
PreWriting,
|
||||||
|
WritingState,
|
||||||
|
create_initial_state,
|
||||||
|
get_progress,
|
||||||
|
validate_character_sheets,
|
||||||
|
validate_one_paragraph,
|
||||||
|
validate_one_sentence,
|
||||||
|
validate_prewriting_complete,
|
||||||
|
validate_scene_list,
|
||||||
|
)
|
||||||
|
from opus_orchestrator.schemas import BookIntent, BookType
|
||||||
|
|
||||||
|
|
||||||
|
# Checkpoint management
|
||||||
|
|
||||||
|
CHECKPOINT_DIR = Path("./checkpoints")
|
||||||
|
|
||||||
|
|
||||||
|
def save_checkpoint(state: OpusState, checkpoint_id: str = "default") -> Path:
|
||||||
|
"""Save state checkpoint to disk."""
|
||||||
|
CHECKPOINT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
checkpoint_file = CHECKPOINT_DIR / f"checkpoint_{checkpoint_id}.json"
|
||||||
|
|
||||||
|
# Convert to JSON-serializable dict
|
||||||
|
data = {
|
||||||
|
"stage": state.stage.value,
|
||||||
|
"framework": state.framework,
|
||||||
|
"genre": state.genre,
|
||||||
|
"target_word_count": state.target_word_count,
|
||||||
|
"seed_concept": state.seed_concept,
|
||||||
|
"prewriting": state.prewriting.model_dump() if state.prewriting else {},
|
||||||
|
"style_guide": state.style_guide,
|
||||||
|
"writing": state.writing.model_dump() if state.writing else {},
|
||||||
|
"manuscript": state.manuscript,
|
||||||
|
"total_word_count": state.total_word_count,
|
||||||
|
"validation_errors": state.validation_errors,
|
||||||
|
"warnings": state.warnings,
|
||||||
|
"progress": state.progress,
|
||||||
|
"last_updated": datetime.utcnow().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(checkpoint_file, "w") as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
|
return checkpoint_file
|
||||||
|
|
||||||
|
|
||||||
|
def load_checkpoint(checkpoint_id: str = "default") -> Optional[OpusState]:
|
||||||
|
"""Load state checkpoint from disk."""
|
||||||
|
checkpoint_file = CHECKPOINT_DIR / f"checkpoint_{checkpoint_id}.json"
|
||||||
|
|
||||||
|
if not checkpoint_file.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
with open(checkpoint_file, "r") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
# Reconstruct state
|
||||||
|
prewriting_data = data.get("prewriting", {})
|
||||||
|
prewriting = PreWriting(**prewriting_data) if prewriting_data else PreWriting()
|
||||||
|
|
||||||
|
writing_data = data.get("writing", {})
|
||||||
|
writing = WritingState(**writing_data) if writing_data else WritingState()
|
||||||
|
|
||||||
|
state = OpusState(
|
||||||
|
stage=Stage(data.get("stage", "seed")),
|
||||||
|
framework=data.get("framework", "snowflake"),
|
||||||
|
genre=data.get("genre", "general"),
|
||||||
|
target_word_count=data.get("target_word_count", 80000),
|
||||||
|
seed_concept=data.get("seed_concept", ""),
|
||||||
|
prewriting=prewriting,
|
||||||
|
style_guide=data.get("style_guide", ""),
|
||||||
|
writing=writing,
|
||||||
|
manuscript=data.get("manuscript", ""),
|
||||||
|
total_word_count=data.get("total_word_count", 0),
|
||||||
|
validation_errors=data.get("validation_errors", []),
|
||||||
|
warnings=data.get("warnings", []),
|
||||||
|
progress=data.get("progress", 0.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
# Workflow nodes
|
||||||
|
|
||||||
|
class OpusWorkflow:
|
||||||
|
"""LangGraph workflow for Opus Orchestrator."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
framework: str = "snowflake",
|
||||||
|
genre: str = "general",
|
||||||
|
target_word_count: int = 80000,
|
||||||
|
api_key: Optional[str] = None,
|
||||||
|
):
|
||||||
|
self.framework = framework
|
||||||
|
self.genre = genre
|
||||||
|
self.target_word_count = target_word_count
|
||||||
|
self.api_key = api_key
|
||||||
|
|
||||||
|
# Initialize agents
|
||||||
|
agent_config = AgentConfig(api_key=api_key)
|
||||||
|
self.architect = ArchitectAgent(agent_config)
|
||||||
|
self.character_lead = CharacterLeadAgent(agent_config)
|
||||||
|
self.voice = VoiceAgent(agent_config)
|
||||||
|
self.editor = EditorAgent(agent_config)
|
||||||
|
|
||||||
|
def stage_1_one_sentence(self, state: OpusState) -> OpusState:
|
||||||
|
"""Generate one sentence summary."""
|
||||||
|
# Use framework-specific prompting
|
||||||
|
framework_prompt = get_framework_prompt(StoryFramework(self.framework))
|
||||||
|
|
||||||
|
user_prompt = f"""Create a ONE SENTENCE summary of this story concept.
|
||||||
|
|
||||||
|
The sentence should contain:
|
||||||
|
- Protagonist's name (or descriptor)
|
||||||
|
- Their goal
|
||||||
|
- The conflict/obstacle
|
||||||
|
- The stakes
|
||||||
|
|
||||||
|
Example: "In a world where magic is forbidden, a young mage must master forbidden arts to save her dying brother, even if it means sparking a war with the ruling theocracy."
|
||||||
|
|
||||||
|
## Your seed concept:
|
||||||
|
{state.seed_concept}
|
||||||
|
|
||||||
|
## Task:
|
||||||
|
Write ONE compelling sentence that captures the entire story.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Call LLM
|
||||||
|
import asyncio
|
||||||
|
result = asyncio.run(self.architect.call_llm(framework_prompt, user_prompt))
|
||||||
|
|
||||||
|
# Parse and validate
|
||||||
|
state.prewriting.one_sentence = result.strip()
|
||||||
|
state.validation_errors = validate_one_sentence(state)
|
||||||
|
state.stage = Stage.ONE_SENTENCE
|
||||||
|
state.progress = get_progress(Stage.ONE_SENTENCE)
|
||||||
|
state.last_updated = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
|
# Save checkpoint
|
||||||
|
save_checkpoint(state)
|
||||||
|
|
||||||
|
return state
|
||||||
|
|
||||||
|
def stage_2_one_paragraph(self, state: OpusState) -> OpusState:
|
||||||
|
"""Generate one paragraph outline."""
|
||||||
|
framework_prompt = get_framework_prompt(StoryFramework(self.framework))
|
||||||
|
|
||||||
|
user_prompt = f"""Expand this one-sentence summary into a full one-paragraph story outline.
|
||||||
|
|
||||||
|
Include:
|
||||||
|
- Opening image (the "before" state)
|
||||||
|
- Setup (normal world, who the protagonist is)
|
||||||
|
- Catalyst (what changes everything)
|
||||||
|
- Rising action (attempts to solve the problem)
|
||||||
|
- Midpoint (major twist or revelation)
|
||||||
|
- Complications (things get worse)
|
||||||
|
- Crisis (lowest point)
|
||||||
|
- Resolution (how it ends)
|
||||||
|
|
||||||
|
## One sentence:
|
||||||
|
{state.prewriting.one_sentence}
|
||||||
|
|
||||||
|
## Task:
|
||||||
|
Write one detailed paragraph (4-8 sentences) that tells the complete story arc.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
result = asyncio.run(self.architect.call_llm(framework_prompt, user_prompt))
|
||||||
|
|
||||||
|
state.prewriting.one_paragraph = result.strip()
|
||||||
|
state.validation_errors = validate_one_paragraph(state)
|
||||||
|
state.stage = Stage.ONE_PARAGRAPH
|
||||||
|
state.progress = get_progress(Stage.ONE_PARAGRAPH)
|
||||||
|
state.last_updated = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
|
save_checkpoint(state)
|
||||||
|
|
||||||
|
return state
|
||||||
|
|
||||||
|
def stage_3_character_sheets(self, state: OpusState) -> OpusState:
|
||||||
|
"""Generate character sheets (structured)."""
|
||||||
|
user_prompt = f"""Create character sheets for all major characters in this story.
|
||||||
|
|
||||||
|
For each character, provide:
|
||||||
|
- Name
|
||||||
|
- Role (protagonist, antagonist, love interest, mentor, etc.)
|
||||||
|
- Age and physical description
|
||||||
|
- Background/history (2-3 sentences)
|
||||||
|
- Want (external goal)
|
||||||
|
- Need (internal growth)
|
||||||
|
- Fear
|
||||||
|
- Secret (if any)
|
||||||
|
- Character arc (how do they change?)
|
||||||
|
|
||||||
|
## Story outline:
|
||||||
|
{state.prewriting.one_paragraph}
|
||||||
|
|
||||||
|
## Task:
|
||||||
|
Create comprehensive character sheets. Return as a list with each character clearly defined.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
result = asyncio.run(self.character_lead.execute(
|
||||||
|
{"characters": [], "raw_content": state.prewriting.one_paragraph},
|
||||||
|
{},
|
||||||
|
))
|
||||||
|
|
||||||
|
# Parse characters from result (basic parsing - could be improved)
|
||||||
|
text = result.output if isinstance(result.output, str) else str(result.output)
|
||||||
|
|
||||||
|
# Extract characters (simplified - in production would use better parsing)
|
||||||
|
characters = self._parse_characters(text)
|
||||||
|
|
||||||
|
state.prewriting.characters = characters
|
||||||
|
state.prewriting.framework_used = self.framework
|
||||||
|
state.validation_errors = validate_character_sheets(state)
|
||||||
|
state.stage = Stage.CHARACTER_SHEETS
|
||||||
|
state.progress = get_progress(Stage.CHARACTER_SHEETS)
|
||||||
|
state.last_updated = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
|
save_checkpoint(state)
|
||||||
|
|
||||||
|
return state
|
||||||
|
|
||||||
|
def stage_4_four_page_outline(self, state: OpusState) -> OpusState:
|
||||||
|
"""Generate four-page outline."""
|
||||||
|
framework_prompt = get_framework_prompt(StoryFramework(self.framework))
|
||||||
|
|
||||||
|
user_prompt = f"""Expand this one-paragraph outline into a detailed four-page outline.
|
||||||
|
|
||||||
|
For each major section, provide:
|
||||||
|
- Multiple scenes
|
||||||
|
- Character motivations
|
||||||
|
- Plot developments
|
||||||
|
- World details
|
||||||
|
- Dialogue hooks
|
||||||
|
|
||||||
|
## Current outline:
|
||||||
|
{state.prewriting.one_paragraph}
|
||||||
|
|
||||||
|
## Characters:
|
||||||
|
{', '.join(c.name for c in state.prewriting.characters)}
|
||||||
|
|
||||||
|
## Task:
|
||||||
|
Write a comprehensive four-page outline covering the entire story.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
result = asyncio.run(self.architect.call_llm(framework_prompt, user_prompt))
|
||||||
|
|
||||||
|
# Parse outline sections
|
||||||
|
state.prewriting.outline_sections = [s.strip() for s in result.split("\n\n") if s.strip()]
|
||||||
|
state.stage = Stage.FOUR_PAGE_OUTLINE
|
||||||
|
state.progress = get_progress(Stage.FOUR_PAGE_OUTLINE)
|
||||||
|
state.last_updated = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
|
save_checkpoint(state)
|
||||||
|
|
||||||
|
return state
|
||||||
|
|
||||||
|
def stage_5_character_charts(self, state: OpusState) -> OpusState:
|
||||||
|
"""Generate detailed character charts."""
|
||||||
|
user_prompt = f"""Create detailed character charts for all major characters.
|
||||||
|
|
||||||
|
For each character include:
|
||||||
|
- Full backstory
|
||||||
|
- Psychological profile
|
||||||
|
- Speech patterns (with sample dialogue)
|
||||||
|
- Character quirks
|
||||||
|
- Relationships with other characters
|
||||||
|
- How they appear to others vs. who they really are
|
||||||
|
- Key scenes they're in
|
||||||
|
|
||||||
|
## Characters (basic):
|
||||||
|
{chr(10).join(f"- {c.name}: {c.role}" for c in state.prewriting.characters)}
|
||||||
|
|
||||||
|
## Task:
|
||||||
|
Write comprehensive, detailed character charts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
result = asyncio.run(self.character_lead.execute(
|
||||||
|
{"characters": [], "raw_content": state.prewriting.one_paragraph},
|
||||||
|
{},
|
||||||
|
))
|
||||||
|
|
||||||
|
text = result.output if isinstance(result.output, str) else str(result.output)
|
||||||
|
|
||||||
|
# Store character details
|
||||||
|
for char in state.prewriting.characters:
|
||||||
|
state.prewriting.character_details[char.name] = text
|
||||||
|
|
||||||
|
state.stage = Stage.CHARACTER_CHARTS
|
||||||
|
state.progress = get_progress(Stage.CHARACTER_CHARTS)
|
||||||
|
state.last_updated = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
|
save_checkpoint(state)
|
||||||
|
|
||||||
|
return state
|
||||||
|
|
||||||
|
def stage_6_scene_list(self, state: OpusState) -> OpusState:
|
||||||
|
"""Generate scene list (structured)."""
|
||||||
|
framework_prompt = get_framework_prompt(StoryFramework(self.framework))
|
||||||
|
|
||||||
|
words_per_scene = 1500
|
||||||
|
num_scenes = max(10, self.target_word_count // words_per_scene)
|
||||||
|
|
||||||
|
user_prompt = f"""Create a SCENE LIST for this story.
|
||||||
|
|
||||||
|
For each scene, provide:
|
||||||
|
- Scene name/number
|
||||||
|
- What happens (brief description)
|
||||||
|
- POV character
|
||||||
|
- Location
|
||||||
|
- Purpose (advances plot? reveals character?)
|
||||||
|
|
||||||
|
Target: {num_scenes} scenes
|
||||||
|
|
||||||
|
## Outline:
|
||||||
|
{chr(10).join(state.prewriting.outline_sections[:3])}
|
||||||
|
|
||||||
|
## Characters:
|
||||||
|
{', '.join(c.name for c in state.prewriting.characters)}
|
||||||
|
|
||||||
|
## Task:
|
||||||
|
Create a comprehensive scene list.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
result = asyncio.run(self.architect.call_llm(framework_prompt, user_prompt))
|
||||||
|
|
||||||
|
# Parse scenes
|
||||||
|
scenes = self._parse_scenes(result)
|
||||||
|
state.prewriting.scene_list = scenes
|
||||||
|
|
||||||
|
# Also create chapter plans
|
||||||
|
num_chapters = max(3, self.target_word_count // 3000)
|
||||||
|
state.prewriting.chapter_plans = self._create_chapter_plans(num_chapters, scenes)
|
||||||
|
|
||||||
|
state.validation_errors = validate_scene_list(state)
|
||||||
|
state.stage = Stage.SCENE_LIST
|
||||||
|
state.progress = get_progress(Stage.SCENE_LIST)
|
||||||
|
state.last_updated = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
|
save_checkpoint(state)
|
||||||
|
|
||||||
|
return state
|
||||||
|
|
||||||
|
def stage_7_scene_descriptions(self, state: OpusState) -> OpusState:
|
||||||
|
"""Generate scene descriptions."""
|
||||||
|
user_prompt = f"""Expand key scenes into detailed descriptions.
|
||||||
|
|
||||||
|
For each key scene, provide:
|
||||||
|
- Opening beat
|
||||||
|
- Key dialogue points
|
||||||
|
- Conflict moment
|
||||||
|
- Turning point
|
||||||
|
- Closing beat
|
||||||
|
|
||||||
|
## Scene list:
|
||||||
|
{chr(10).join(f"- {s.name}: {s.description}" for s in state.prewriting.scene_list[:10])}
|
||||||
|
|
||||||
|
## Task:
|
||||||
|
Write detailed descriptions for at least 10 key scenes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
result = asyncio.run(self.architect.call_llm(
|
||||||
|
"You are an expert story architect. Create vivid scene descriptions.",
|
||||||
|
user_prompt,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Parse into dict
|
||||||
|
state.prewriting.scene_descriptions = self._parse_scene_descriptions(result)
|
||||||
|
|
||||||
|
state.stage = Stage.SCENE_DESCRIPTIONS
|
||||||
|
state.progress = get_progress(Stage.SCENE_DESCRIPTIONS)
|
||||||
|
state.last_updated = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
|
save_checkpoint(state)
|
||||||
|
|
||||||
|
return state
|
||||||
|
|
||||||
|
def create_style_guide(self, state: OpusState) -> OpusState:
|
||||||
|
"""Create style guide."""
|
||||||
|
user_prompt = f"""Create a voice/style guide for this story.
|
||||||
|
|
||||||
|
- Genre: {self.genre}
|
||||||
|
- Target audience: adult readers
|
||||||
|
|
||||||
|
## One sentence:
|
||||||
|
{state.prewriting.one_sentence}
|
||||||
|
|
||||||
|
## Task:
|
||||||
|
Create a comprehensive style guide.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
result = asyncio.run(self.voice.execute(
|
||||||
|
{"genre": self.genre, "tone": "neutral", "target_audience": "adult readers"},
|
||||||
|
{},
|
||||||
|
))
|
||||||
|
|
||||||
|
state.style_guide = result.output if isinstance(result.output, str) else str(result.output)
|
||||||
|
|
||||||
|
state.stage = Stage.STYLE_GUIDE
|
||||||
|
state.progress = get_progress(Stage.STYLE_GUIDE)
|
||||||
|
state.last_updated = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
|
save_checkpoint(state)
|
||||||
|
|
||||||
|
return state
|
||||||
|
|
||||||
|
# Helper parsing functions
|
||||||
|
|
||||||
|
def _parse_characters(self, text: str) -> list[Character]:
|
||||||
|
"""Parse characters from LLM output."""
|
||||||
|
characters = []
|
||||||
|
lines = text.split("\n")
|
||||||
|
current_char = {}
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Simple parsing - look for patterns
|
||||||
|
lower = line.lower()
|
||||||
|
if "name:" in lower or (line and line[0].isupper() and len(line) < 30):
|
||||||
|
if current_char and "name" in current_char:
|
||||||
|
characters.append(Character(**current_char))
|
||||||
|
current_char = {"name": line.split(":")[-1].strip() if ":" in line else line}
|
||||||
|
elif "role:" in lower:
|
||||||
|
current_char["role"] = line.split(":")[-1].strip()
|
||||||
|
elif "want:" in lower:
|
||||||
|
current_char["want"] = line.split(":")[-1].strip()
|
||||||
|
elif "need:" in lower:
|
||||||
|
current_char["need"] = line.split(":")[-1].strip()
|
||||||
|
elif "fear:" in lower:
|
||||||
|
current_char["fear"] = line.split(":")[-1].strip()
|
||||||
|
elif "arc:" in lower:
|
||||||
|
current_char["arc"] = line.split(":")[-1].strip()
|
||||||
|
|
||||||
|
# Add last character
|
||||||
|
if current_char and "name" in current_char:
|
||||||
|
characters.append(Character(**current_char))
|
||||||
|
|
||||||
|
# Ensure at least one character
|
||||||
|
if not characters:
|
||||||
|
characters.append(Character(
|
||||||
|
name="Protagonist",
|
||||||
|
role="protagonist",
|
||||||
|
description="Main character",
|
||||||
|
want="Complete the quest",
|
||||||
|
need="Learn to trust others",
|
||||||
|
fear="Failure",
|
||||||
|
arc="Grows from isolated to connected",
|
||||||
|
))
|
||||||
|
|
||||||
|
return characters
|
||||||
|
|
||||||
|
def _parse_scenes(self, text: str) -> list[PlotBeat]:
|
||||||
|
"""Parse scenes from LLM output."""
|
||||||
|
scenes = []
|
||||||
|
lines = text.split("\n")
|
||||||
|
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Extract scene info
|
||||||
|
parts = line.split("-", 1)
|
||||||
|
if len(parts) > 1:
|
||||||
|
scenes.append(PlotBeat(
|
||||||
|
name=f"Scene {i+1}",
|
||||||
|
description=parts[1].strip()[:200],
|
||||||
|
))
|
||||||
|
|
||||||
|
# Ensure minimum scenes
|
||||||
|
if not scenes:
|
||||||
|
scenes = [PlotBeat(name=f"Scene {i+1}", description=f"Story beat {i+1}")
|
||||||
|
for i in range(10)]
|
||||||
|
|
||||||
|
return scenes[:20] # Limit to 20
|
||||||
|
|
||||||
|
def _parse_scene_descriptions(self, text: str) -> dict[str, str]:
|
||||||
|
"""Parse scene descriptions from LLM output."""
|
||||||
|
descriptions = {}
|
||||||
|
sections = text.split("\n\n")
|
||||||
|
|
||||||
|
for i, section in enumerate(sections):
|
||||||
|
if section.strip():
|
||||||
|
descriptions[f"scene_{i+1}"] = section.strip()[:500]
|
||||||
|
|
||||||
|
return descriptions
|
||||||
|
|
||||||
|
def _create_chapter_plans(self, num_chapters: int, scenes: list[PlotBeat]) -> list[ChapterPlan]:
|
||||||
|
"""Create chapter plans from scenes."""
|
||||||
|
scenes_per_chapter = max(1, len(scenes) // num_chapters)
|
||||||
|
plans = []
|
||||||
|
|
||||||
|
for i in range(num_chapters):
|
||||||
|
start_idx = i * scenes_per_chapter
|
||||||
|
end_idx = min(start_idx + scenes_per_chapter, len(scenes))
|
||||||
|
chapter_scenes = scenes[start_idx:end_idx] if scenes else []
|
||||||
|
|
||||||
|
plans.append(ChapterPlan(
|
||||||
|
chapter_number=i + 1,
|
||||||
|
title=f"Chapter {i + 1}",
|
||||||
|
summary=f"Chapter {i + 1} with {len(chapter_scenes)} scenes",
|
||||||
|
word_count_target=self.target_word_count // num_chapters,
|
||||||
|
beats=[s.name for s in chapter_scenes],
|
||||||
|
))
|
||||||
|
|
||||||
|
return plans
|
||||||
|
|
||||||
|
|
||||||
|
# Simplified workflow runner (would use actual LangGraph in production)
|
||||||
|
|
||||||
|
def run_workflow(
|
||||||
|
seed_concept: str,
|
||||||
|
framework: str = "snowflake",
|
||||||
|
genre: str = "general",
|
||||||
|
target_word_count: int = 80000,
|
||||||
|
api_key: Optional[str] = None,
|
||||||
|
checkpoint_id: Optional[str] = None,
|
||||||
|
) -> OpusState:
|
||||||
|
"""Run the complete workflow.
|
||||||
|
|
||||||
|
In production, this would use actual LangGraph graph.walk()
|
||||||
|
For now, uses sequential execution with checkpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Try to load checkpoint
|
||||||
|
if checkpoint_id:
|
||||||
|
state = load_checkpoint(checkpoint_id)
|
||||||
|
if state:
|
||||||
|
print(f"📂 Loaded checkpoint: {state.stage}")
|
||||||
|
else:
|
||||||
|
state = None
|
||||||
|
|
||||||
|
# Create workflow
|
||||||
|
workflow = OpusWorkflow(
|
||||||
|
framework=framework,
|
||||||
|
genre=genre,
|
||||||
|
target_word_count=target_word_count,
|
||||||
|
api_key=api_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run stages in order (skipping completed)
|
||||||
|
if not state or state.stage == Stage.SEED:
|
||||||
|
state = create_initial_state(seed_concept, framework, genre, target_word_count)
|
||||||
|
state = workflow.stage_1_one_sentence(state)
|
||||||
|
|
||||||
|
if state.stage == Stage.ONE_SENTENCE:
|
||||||
|
state = workflow.stage_2_one_paragraph(state)
|
||||||
|
|
||||||
|
if state.stage == Stage.ONE_PARAGRAPH:
|
||||||
|
state = workflow.stage_3_character_sheets(state)
|
||||||
|
|
||||||
|
if state.stage == Stage.CHARACTER_SHEETS:
|
||||||
|
state = workflow.stage_4_four_page_outline(state)
|
||||||
|
|
||||||
|
if state.stage == Stage.FOUR_PAGE_OUTLINE:
|
||||||
|
state = workflow.stage_5_character_charts(state)
|
||||||
|
|
||||||
|
if state.stage == Stage.CHARACTER_CHARTS:
|
||||||
|
state = workflow.stage_6_scene_list(state)
|
||||||
|
|
||||||
|
if state.stage == Stage.SCENE_LIST:
|
||||||
|
state = workflow.stage_7_scene_descriptions(state)
|
||||||
|
|
||||||
|
if state.stage == Stage.SCENE_DESCRIPTIONS:
|
||||||
|
state = workflow.create_style_guide(state)
|
||||||
|
|
||||||
|
state.stage = Stage.COMPLETE
|
||||||
|
state.progress = 1.0
|
||||||
|
save_checkpoint(state, checkpoint_id or "final")
|
||||||
|
|
||||||
|
return state
|
||||||
Reference in New Issue
Block a user