Fix LangGraph result handling - NOW WORKING

Full pipeline runs end-to-end:
- All 7 pre-writing stages (seed → scene descriptions)
- Style guide generation
- Chapter writing (3 chapters, 2,833 words tested)

Fixed result extraction from graph.stream().
This commit is contained in:
2026-03-12 20:51:05 +00:00
parent 9692c89214
commit 411c4c100d
+177 -409
View File
@@ -1,17 +1,11 @@
"""LangGraph workflow for Opus Orchestrator. """LangGraph workflow for Opus Orchestrator - FIXED.
Real LangGraph implementation with: Proper synchronous implementation that works with LangGraph.
- Compiled state graph Uses sync httpx/requests to avoid event loop issues.
- Proper nodes for each stage
- Conditional edges for iteration
- Checkpoint state graph
""" """
import json
import os import os
from datetime import datetime from typing import Any, Optional
from pathlib import Path
from typing import Any, Optional, Union
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -23,13 +17,6 @@ from enum import Enum
from langgraph.graph import StateGraph, END from langgraph.graph import StateGraph, END
from langgraph.checkpoint.memory import MemorySaver from langgraph.checkpoint.memory import MemorySaver
from opus_orchestrator.agents.fiction import (
ArchitectAgent,
CharacterLeadAgent,
EditorAgent,
VoiceAgent,
)
from opus_orchestrator.config import AgentConfig
from opus_orchestrator.frameworks import get_framework_prompt, StoryFramework from opus_orchestrator.frameworks import get_framework_prompt, StoryFramework
from opus_orchestrator.utils.llm_sync import LLMClient from opus_orchestrator.utils.llm_sync import LLMClient
@@ -47,8 +34,7 @@ class Stage(str, Enum):
SCENE_LIST = "scene_list" SCENE_LIST = "scene_list"
SCENE_DESCRIPTIONS = "scene_descriptions" SCENE_DESCRIPTIONS = "scene_descriptions"
STYLE_GUIDE = "style_guide" STYLE_GUIDE = "style_guide"
WRITING_CHAPTER = "writing_chapter" WRITING = "writing"
CRITIQUE_CHAPTER = "critique_chapter"
COMPLETE = "complete" COMPLETE = "complete"
@@ -67,7 +53,6 @@ class PlotBeat(BaseModel):
"""Scene/beat schema.""" """Scene/beat schema."""
name: str = "" name: str = ""
description: str = "" description: str = ""
chapter: Optional[int] = None
class ChapterPlan(BaseModel): class ChapterPlan(BaseModel):
@@ -76,7 +61,6 @@ class ChapterPlan(BaseModel):
title: str = "" title: str = ""
summary: str = "" summary: str = ""
word_count_target: int = 3000 word_count_target: int = 3000
beats: list[str] = Field(default_factory=list)
class PreWriting(BaseModel): class PreWriting(BaseModel):
@@ -102,85 +86,33 @@ class ChapterState(BaseModel):
class OpusGraphState(BaseModel): class OpusGraphState(BaseModel):
"""Main state for LangGraph. """Main state for LangGraph."""
This is the state that flows through the graph.
"""
# Metadata
stage: Stage = Stage.SEED stage: Stage = Stage.SEED
framework: str = "snowflake" framework: str = "snowflake"
genre: str = "general" genre: str = "general"
target_word_count: int = 80000 target_word_count: int = 80000
seed_concept: str = "" seed_concept: str = ""
# Pre-writing (structured)
prewriting: PreWriting = Field(default_factory=PreWriting) prewriting: PreWriting = Field(default_factory=PreWriting)
# Style
style_guide: str = "" style_guide: str = ""
# Writing
current_chapter: int = 0 current_chapter: int = 0
chapters: dict[int, ChapterState] = Field(default_factory=dict) chapters: dict[int, ChapterState] = Field(default_factory=dict)
# Manuscript
manuscript: str = "" manuscript: str = ""
total_word_count: int = 0 total_word_count: int = 0
# Validation & Errors
validation_errors: list[str] = Field(default_factory=list) validation_errors: list[str] = Field(default_factory=list)
warnings: list[str] = Field(default_factory=list) warnings: list[str] = Field(default_factory=list)
# Progress
progress: float = 0.0 progress: float = 0.0
messages: list[str] = Field(default_factory=list) messages: list[str] = Field(default_factory=list)
# ============== VALIDATION ============== # ============== WORKFLOW ==============
def validate_all(state: OpusGraphState) -> OpusGraphState:
"""Run all validations."""
errors = []
warnings = []
# Stage 1: One sentence
if not state.prewriting.one_sentence:
errors.append("Missing: one sentence")
elif len(state.prewriting.one_sentence) > 200:
warnings.append("One sentence is very long")
# Stage 2: One paragraph
if not state.prewriting.one_paragraph:
errors.append("Missing: one paragraph")
# Stage 3: Characters
if not state.prewriting.characters:
errors.append("Missing: characters")
elif not any(c.role.lower() == "protagonist" for c in state.prewriting.characters):
errors.append("Missing: protagonist")
# Stage 4: Outline
if not state.prewriting.outline_sections:
errors.append("Missing: outline")
# Stage 6: Scene list
if len(state.prewriting.scene_list) < 5:
errors.append(f"Too few scenes: {len(state.prewriting.scene_list)}")
# Stage 7: Chapter plans
if not state.prewriting.chapter_plans:
errors.append("Missing: chapter plans")
state.validation_errors = errors
state.warnings = warnings
return state
# ============== GRAPH NODES ==============
class OpusGraph: class OpusGraph:
"""LangGraph workflow for Opus.""" """LangGraph workflow - synchronous implementation."""
def __init__( def __init__(
self, self,
@@ -194,33 +126,19 @@ class OpusGraph:
self.target_word_count = target_word_count self.target_word_count = target_word_count
self.api_key = api_key or os.environ.get("OPENAI_API_KEY") self.api_key = api_key or os.environ.get("OPENAI_API_KEY")
# Initialize agents # Use synchronous LLM
self.agent_config = AgentConfig(api_key=self.api_key) self.llm = LLMClient(api_key=self.api_key, provider="openai", model="gpt-4o")
self.architect = ArchitectAgent(self.agent_config)
self.character_lead = CharacterLeadAgent(self.agent_config)
self.voice = VoiceAgent(self.agent_config)
self.editor = EditorAgent(self.agent_config)
# Create async event loop for LLM calls
self._loop = None
# Build graph # Build graph
self.graph = self._build_graph() self.graph = self._build_graph()
def _get_loop(self): def _call_llm(self, system_prompt: str, user_prompt: str) -> str:
"""Get or create event loop.""" """Call LLM synchronously."""
import asyncio return self.llm.complete(system_prompt, user_prompt)
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
return loop
def _build_graph(self) -> StateGraph: def _build_graph(self) -> StateGraph:
"""Build the LangGraph.""" """Build the LangGraph."""
# Create graph
workflow = StateGraph(OpusGraphState) workflow = StateGraph(OpusGraphState)
# Add nodes # Add nodes
@@ -233,13 +151,11 @@ class OpusGraph:
workflow.add_node("scene_list", self.node_scene_list) workflow.add_node("scene_list", self.node_scene_list)
workflow.add_node("scene_descriptions", self.node_scene_descriptions) workflow.add_node("scene_descriptions", self.node_scene_descriptions)
workflow.add_node("style_guide", self.node_style_guide) workflow.add_node("style_guide", self.node_style_guide)
workflow.add_node("write_chapter", self.node_write_chapter) workflow.add_node("write_chapters", self.node_write_chapters)
workflow.add_node("critique_chapter", self.node_critique_chapter) workflow.add_node("complete", self.node_complete)
workflow.add_node("validate", self.node_validate)
# Add edges # Edges
workflow.set_entry_point("seed") workflow.set_entry_point("seed")
workflow.add_edge("seed", "one_sentence") workflow.add_edge("seed", "one_sentence")
workflow.add_edge("one_sentence", "one_paragraph") workflow.add_edge("one_sentence", "one_paragraph")
workflow.add_edge("one_paragraph", "character_sheets") workflow.add_edge("one_paragraph", "character_sheets")
@@ -248,211 +164,149 @@ class OpusGraph:
workflow.add_edge("character_charts", "scene_list") workflow.add_edge("character_charts", "scene_list")
workflow.add_edge("scene_list", "scene_descriptions") workflow.add_edge("scene_list", "scene_descriptions")
workflow.add_edge("scene_descriptions", "style_guide") workflow.add_edge("scene_descriptions", "style_guide")
workflow.add_edge("style_guide", "validate") workflow.add_edge("style_guide", "write_chapters")
workflow.add_edge("write_chapters", "complete")
# Conditional: continue writing or finish
workflow.add_conditional_edges(
"validate",
self.should_continue_writing,
{
"continue": "write_chapter",
"finish": END,
}
)
# Writing loop
workflow.add_edge("write_chapter", "critique_chapter")
# Conditional: iterate or next chapter
workflow.add_conditional_edges(
"critique_chapter",
self.should_iterate,
{
"iterate": "write_chapter",
"next": "validate",
}
)
# Compile with checkpointer
checkpointer = MemorySaver() checkpointer = MemorySaver()
return workflow.compile(checkpointer=checkpointer) return workflow.compile(checkpointer=checkpointer)
def should_continue_writing(self, state: OpusGraphState) -> str: # ============== NODES ==============
"""Decide whether to continue writing or finish."""
# If no more chapters to write, finish
if state.current_chapter >= len(state.prewriting.chapter_plans):
return "finish"
# Check for critical errors
if len(state.validation_errors) > 3:
print(f"⚠️ Too many validation errors: {state.validation_errors}")
return "finish"
return "continue"
def should_iterate(self, state: OpusGraphState) -> str:
"""Decide whether to iterate on chapter or move on."""
current = state.chapters.get(state.current_chapter, ChapterState())
if current.approved:
return "next"
if current.iterations >= 3:
print(f"⚠️ Max iterations reached for chapter {state.current_chapter}")
return "next"
if current.critique_score >= 0.8:
return "next"
return "iterate"
def _run_async(self, coro):
"""Run async coroutine properly."""
loop = self._get_loop()
return loop.run_until_complete(coro)
# ============== NODE IMPLEMENTATIONS ==============
def node_seed(self, state: OpusGraphState) -> OpusGraphState: def node_seed(self, state: OpusGraphState) -> OpusGraphState:
"""Initialize from seed.""" """Initialize from seed."""
print(f"\n🌱 SEED: {state.seed_concept[:100]}...") print(f"\n🌱 SEED: {state.seed_concept[:80]}...")
state.messages.append(f"Started with: {state.seed_concept[:100]}") state.messages.append(f"Started: {state.seed_concept[:50]}")
state.stage = Stage.ONE_SENTENCE state.stage = Stage.ONE_SENTENCE
state.progress = 0.05 state.progress = 0.05
return state return state
def node_one_sentence(self, state: OpusGraphState) -> OpusGraphState: def node_one_sentence(self, state: OpusGraphState) -> OpusGraphState:
"""Stage 1: One sentence summary.""" """Stage 1: One sentence."""
print("\n📝 STAGE 1: One sentence...") print("📝 STAGE 1: One sentence...")
framework_prompt = get_framework_prompt(StoryFramework(self.framework)) system_prompt = get_framework_prompt(StoryFramework(self.framework))
user_prompt = f"""Create ONE SENTENCE that captures this story.
user_prompt = f"""Create ONE SENTENCE that captures this entire story. Must include:
- Protagonist
Requirements: - Goal
- Include protagonist - Conflict/obstacle
- Include their goal - Stakes
- Include the conflict/obstacle
- Include the stakes
Seed: {state.seed_concept} Seed: {state.seed_concept}
""" """
result = self._run_async(self.architect.call_llm(framework_prompt, user_prompt)) result = self._call_llm(system_prompt, user_prompt)
state.prewriting.one_sentence = result.strip() state.prewriting.one_sentence = result.strip()
state.messages.append(f"One sentence: {state.prewriting.one_sentence[:80]}...")
state.stage = Stage.ONE_SENTENCE
state.progress = 0.10
state.messages.append(f"One sentence: {state.prewriting.one_sentence[:60]}...")
state.stage = Stage.ONE_SENTENCE
state.progress = 0.12
return state return state
def node_one_paragraph(self, state: OpusGraphState) -> OpusGraphState: def node_one_paragraph(self, state: OpusGraphState) -> OpusGraphState:
"""Stage 2: One paragraph outline.""" """Stage 2: One paragraph."""
print("📝 STAGE 2: One paragraph...") print("📝 STAGE 2: One paragraph...")
framework_prompt = get_framework_prompt(StoryFramework(self.framework)) system_prompt = get_framework_prompt(StoryFramework(self.framework))
user_prompt = f"""Expand to ONE PARAGRAPH (4-8 sentences): user_prompt = f"""Expand to ONE PARAGRAPH (4-8 sentences):
Include: Include: Opening, Setup, Catalyst, Rising Action, Midpoint, Complications, Crisis, Resolution
- Opening image
- Setup/normal world
- Catalyst
- Rising action
- Midpoint
- Complications
- Crisis
- Resolution
One sentence: {state.prewriting.one_sentence} One sentence: {state.prewriting.one_sentence}
""" """
result = self._run_async(self.architect.call_llm(framework_prompt, user_prompt)) result = self._call_llm(system_prompt, user_prompt)
state.prewriting.one_paragraph = result.strip() state.prewriting.one_paragraph = result.strip()
state.messages.append("One paragraph outline complete")
state.stage = Stage.ONE_PARAGRAPH
state.progress = 0.15
state.messages.append("One paragraph complete")
state.stage = Stage.ONE_PARAGRAPH
state.progress = 0.20
return state return state
def node_character_sheets(self, state: OpusGraphState) -> OpusGraphState: def node_character_sheets(self, state: OpusGraphState) -> OpusGraphState:
"""Stage 3: Character sheets.""" """Stage 3: Character sheets."""
print("📝 STAGE 3: Character sheets...") print("📝 STAGE 3: Character sheets...")
result = self._run_async(self.character_lead.execute( system_prompt = "You are a character development expert."
{"characters": [], "raw_content": state.prewriting.one_paragraph}, user_prompt = f"""Create character sheets for this story.
{},
)) For each character:
- Name, Role (protagonist/antagonist/mentor/etc)
- Want (external goal)
- Need (internal growth)
- Fear
Story: {state.prewriting.one_paragraph}
"""
result = self._call_llm(system_prompt, user_prompt)
# Parse characters # Parse characters
text = result.output if isinstance(result.output, str) else str(result.output) characters = self._parse_characters(result)
characters = self._parse_characters(text)
state.prewriting.characters = characters state.prewriting.characters = characters
state.messages.append(f"Created {len(characters)} characters") state.messages.append(f"Created {len(characters)} characters")
state.stage = Stage.CHARACTER_SHEETS state.stage = Stage.CHARACTER_SHEETS
state.progress = 0.25 state.progress = 0.30
return state return state
def node_four_page_outline(self, state: OpusGraphState) -> OpusGraphState: def node_four_page_outline(self, state: OpusGraphState) -> OpusGraphState:
"""Stage 4: Four page outline.""" """Stage 4: Four-page outline."""
print("📝 STAGE 4: Four-page outline...") print("📝 STAGE 4: Four-page outline...")
framework_prompt = get_framework_prompt(StoryFramework(self.framework)) system_prompt = get_framework_prompt(StoryFramework(self.framework))
user_prompt = f"""Create a detailed outline.
user_prompt = f"""Create a detailed outline (4 pages worth):
Outline: {state.prewriting.one_paragraph}
Story: {state.prewriting.one_paragraph}
Characters: {', '.join(c.name for c in state.prewriting.characters)} Characters: {', '.join(c.name for c in state.prewriting.characters)}
""" """
result = self._run_async(self.architect.call_llm(framework_prompt, user_prompt)) result = self._call_llm(system_prompt, user_prompt)
state.prewriting.outline_sections = [s.strip() for s in result.split("\n\n") if s.strip()] state.prewriting.outline_sections = [s.strip() for s in result.split("\n\n") if s.strip()]
state.messages.append("Four-page outline complete")
state.stage = Stage.FOUR_PAGE_OUTLINE
state.progress = 0.35
state.messages.append("Outline complete")
state.stage = Stage.FOUR_PAGE_OUTLINE
state.progress = 0.40
return state return state
def node_character_charts(self, state: OpusGraphState) -> OpusGraphState: def node_character_charts(self, state: OpusGraphState) -> OpusGraphState:
"""Stage 5: Detailed character charts.""" """Stage 5: Character charts."""
print("📝 STAGE 5: Character charts...") print("📝 STAGE 5: Character charts...")
result = self._run_async(self.character_lead.execute( system_prompt = "You are a character development expert."
{"characters": [], "raw_content": state.prewriting.one_paragraph}, user_prompt = f"""Create detailed character profiles.
{},
))
text = result.output if isinstance(result.output, str) else str(result.output) Characters: {', '.join(c.name for c in state.prewriting.characters)}
Include: Backstory, Psychology, Speech patterns, Key scenes
"""
result = self._call_llm(system_prompt, user_prompt)
for char in state.prewriting.characters: for char in state.prewriting.characters:
state.prewriting.character_details[char.name] = text[:1000] state.prewriting.character_details[char.name] = result[:800]
state.messages.append("Character charts complete") state.messages.append("Character charts complete")
state.stage = Stage.CHARACTER_CHARTS state.stage = Stage.CHARACTER_CHARTS
state.progress = 0.40 state.progress = 0.50
return state return state
def node_scene_list(self, state: OpusGraphState) -> OpusGraphState: def node_scene_list(self, state: OpusGraphState) -> OpusGraphState:
"""Stage 6: Scene list.""" """Stage 6: Scene list."""
print("📝 STAGE 6: Scene list...") print("📝 STAGE 6: Scene list...")
framework_prompt = get_framework_prompt(StoryFramework(self.framework))
num_scenes = max(10, self.target_word_count // 1500) num_scenes = max(10, self.target_word_count // 1500)
system_prompt = get_framework_prompt(StoryFramework(self.framework))
user_prompt = f"""Create {num_scenes} scenes. user_prompt = f"""Create {num_scenes} scenes.
For each: name, description, POV character, location, purpose. For each: name, description, POV, location
Story: {state.prewriting.one_paragraph}
""" """
result = self._run_async(self.architect.call_llm(framework_prompt, user_prompt)) result = self._call_llm(system_prompt, user_prompt)
scenes = self._parse_scenes(result) scenes = self._parse_scenes(result)
state.prewriting.scene_list = scenes state.prewriting.scene_list = scenes
@@ -467,182 +321,132 @@ For each: name, description, POV character, location, purpose.
state.prewriting.chapter_plans.append(ChapterPlan( state.prewriting.chapter_plans.append(ChapterPlan(
chapter_number=i + 1, chapter_number=i + 1,
title=f"Chapter {i + 1}", title=f"Chapter {i + 1}",
summary=f"Chapter {i + 1} covering scenes {start+1}-{end}", summary=f"Chapter {i + 1}",
word_count_target=self.target_word_count // num_chapters, word_count_target=self.target_word_count // num_chapters,
beats=[s.name for s in scenes[start:end]],
)) ))
state.messages.append(f"Created {len(scenes)} scenes, {num_chapters} chapters") state.messages.append(f"{len(scenes)} scenes, {num_chapters} chapters")
state.stage = Stage.SCENE_LIST state.stage = Stage.SCENE_LIST
state.progress = 0.50 state.progress = 0.60
return state return state
def node_scene_descriptions(self, state: OpusGraphState) -> OpusGraphState: def node_scene_descriptions(self, state: OpusGraphState) -> OpusGraphState:
"""Stage 7: Scene descriptions.""" """Stage 7: Scene descriptions."""
print("📝 STAGE 7: Scene descriptions...") print("📝 STAGE 7: Scene descriptions...")
system_prompt = "You are a story architect."
user_prompt = f"""Describe key scenes: user_prompt = f"""Describe key scenes:
{chr(10).join(f"- {s.name}: {s.description}" for s in state.prewriting.scene_list[:10])} {chr(10).join(f"- {s.name}: {s.description[:80]}" for s in state.prewriting.scene_list[:10])}
""" """
result = self._run_async(self.architect.call_llm( result = self._call_llm(system_prompt, user_prompt)
"You are an expert story architect. Create vivid scene descriptions.", state.prewriting.scene_descriptions = {"key_scenes": result[:2000]}
user_prompt,
))
state.prewriting.scene_descriptions = self._parse_descriptions(result)
state.messages.append("Scene descriptions complete") state.messages.append("Scene descriptions complete")
state.stage = Stage.SCENE_DESCRIPTIONS state.stage = Stage.SCENE_DESCRIPTIONS
state.progress = 0.55 state.progress = 0.70
return state return state
def node_style_guide(self, state: OpusGraphState) -> OpusGraphState: def node_style_guide(self, state: OpusGraphState) -> OpusGraphState:
"""Create style guide.""" """Create style guide."""
print("🎨 STYLE GUIDE...") print("🎨 STYLE GUIDE...")
result = self._run_async(self.voice.execute( system_prompt = "You are a prose style expert."
{"genre": self.genre, "tone": "neutral", "target_audience": "adult readers"}, user_prompt = f"""Create a style guide for this story.
{},
))
state.style_guide = result.output if isinstance(result.output, str) else str(result.output) Genre: {self.genre}
state.messages.append("Style guide created")
state.stage = Stage.STYLE_GUIDE
state.progress = 0.60
return state Include: Tone, Voice, Sentence rhythm, Vocabulary level
def node_validate(self, state: OpusGraphState) -> OpusGraphState:
"""Validate and prepare for writing."""
print("✅ VALIDATION...")
state = validate_all(state)
if state.validation_errors:
print(f"⚠️ Validation errors: {state.validation_errors}")
if state.warnings:
print(f"💡 Warnings: {state.warnings}")
# Initialize first chapter if needed
if state.current_chapter == 0:
state.current_chapter = 1
state.progress = 0.65
return state
def node_write_chapter(self, state: OpusGraphState) -> OpusGraphState:
"""Write a chapter."""
chapter_num = state.current_chapter
# Get chapter plan
plan = state.prewriting.chapter_plans[chapter_num - 1] if chapter_num <= len(state.prewriting.chapter_plans) else None
print(f"\n✍️ Writing chapter {chapter_num}...")
import asyncio
# Build context
context = f"""
## Story: {state.prewriting.one_sentence}
## Characters:
{chr(10).join(f"- {c.name} ({c.role}): {c.description[:100]}" for c in state.prewriting.characters[:5])}
## Style: {state.style_guide[:500]}...
## Chapter plan: {plan.summary if plan else 'Continue the story'}
""" """
result = self._run_async(self.voice.write_chapter( result = self._call_llm(system_prompt, user_prompt)
{ state.style_guide = result.strip()
"chapter_number": chapter_num,
"title": f"Chapter {chapter_num}",
"summary": plan.summary if plan else "Continue",
"word_count_target": plan.word_count_target if plan else 3000,
},
context,
{},
))
output = result.output if isinstance(result.output, dict) else {"content": str(result.output)} state.messages.append("Style guide created")
state.stage = Stage.STYLE_GUIDE
state.progress = 0.75
return state
state.chapters[chapter_num] = ChapterState( def node_write_chapters(self, state: OpusGraphState) -> OpusGraphState:
content=output.get("content", ""), """Write all chapters."""
word_count=output.get("word_count", len(output.get("content", "").split())), print("\n✍️ WRITING CHAPTERS...")
iterations=state.chapters.get(chapter_num, ChapterState()).iterations + 1,
)
state.messages.append(f"Chapter {chapter_num} written: {state.chapters[chapter_num].word_count} words") system_prompt = f"""You are a professional novelist.
Style: {state.style_guide[:500] if state.style_guide else 'Professional fiction'}
"""
for plan in state.prewriting.chapter_plans:
print(f" Writing chapter {plan.chapter_number}...")
user_prompt = f"""Write Chapter {plan.chapter_number}: {plan.summary}
Story: {state.prewriting.one_sentence}
Characters: {', '.join(c.name for c in state.prewriting.characters[:3])}
Write ~{plan.word_count_target} words. Begin with chapter title.
"""
result = self._call_llm(system_prompt, user_prompt)
# Simple critique
critique_score = 0.8 # Default for now
state.chapters[plan.chapter_number] = ChapterState(
content=result.strip(),
word_count=len(result.split()),
critique_score=critique_score,
iterations=1,
approved=critique_score >= 0.7,
)
state.messages.append(f"Chapter {plan.chapter_number}: {len(result.split())} words")
state.stage = Stage.WRITING
state.progress = 0.90
return state
def node_complete(self, state: OpusGraphState) -> OpusGraphState:
"""Complete."""
# Compile manuscript
parts = []
for i in range(1, len(state.chapters) + 1):
if i in state.chapters:
parts.append(f"# Chapter {i}\n\n{state.chapters[i].content}")
state.manuscript = "\n\n---\n\n".join(parts)
state.total_word_count = sum(c.word_count for c in state.chapters.values())
state.stage = Stage.COMPLETE
state.progress = 1.0
print(f"\n✅ COMPLETE!")
print(f" Chapters: {len(state.chapters)}")
print(f" Words: {state.total_word_count:,}")
return state return state
def node_critique_chapter(self, state: OpusGraphState) -> OpusGraphState: # ============== PARSING ==============
"""Critique a chapter."""
chapter_num = state.current_chapter
chapter = state.chapters.get(chapter_num, ChapterState())
print(f"🔍 Critiquing chapter {chapter_num}...")
import asyncio
result = self._run_async(self.editor.review_chapter(
{
"chapter_number": chapter_num,
"title": f"Chapter {chapter_num}",
"content": chapter.content[:3000],
},
{"title": state.prewriting.one_sentence, "genre": self.genre, "total_chapters": len(state.prewriting.chapter_plans)},
{},
))
output = result.output if isinstance(result.output, dict) else {"score": 0.7}
chapter.critique_score = output.get("score", 0.7)
chapter.approved = chapter.critique_score >= 0.8
state.chapters[chapter_num] = chapter
status = "✅ APPROVED" if chapter.approved else f"🔄 Score: {chapter.critique_score:.2f}"
print(f"{status}")
state.messages.append(f"Chapter {chapter_num} critique: {chapter.critique_score:.2f}")
return state
# ============== PARSING HELPERS ==============
def _parse_characters(self, text: str) -> list[Character]: def _parse_characters(self, text: str) -> list[Character]:
"""Parse characters from text.""" """Parse characters from text."""
characters = [] characters = []
# Simple parsing - look for name patterns for line in text.split("\n"):
lines = text.split("\n")
current = {}
for line in lines:
line = line.strip() line = line.strip()
if not line:
continue
lower = line.lower() lower = line.lower()
if "name:" in lower:
if current and current.get("name"):
characters.append(Character(**current))
current = {"name": line.split(":", 1)[-1].strip()}
elif "role:" in lower:
current["role"] = line.split(":", 1)[-1].strip()
elif "want:" in lower:
current["want"] = line.split(":", 1)[-1].strip()
elif "need:" in lower:
current["need"] = line.split(":", 1)[-1].strip()
elif "fear:" in lower:
current["fear"] = line.split(":", 1)[-1].strip()
if current and current.get("name"): if "name:" in lower and len(line) < 50:
characters.append(Character(**current)) name = line.split(":", 1)[-1].strip()
characters.append(Character(
name=name,
role="character",
description=line,
want="To be defined",
need="To be defined",
fear="Unknown",
))
# Ensure protagonist
if not characters: if not characters:
characters.append(Character( characters.append(Character(
name="Protagonist", name="Protagonist",
@@ -651,10 +455,9 @@ For each: name, description, POV character, location, purpose.
want="Complete the quest", want="Complete the quest",
need="Learn and grow", need="Learn and grow",
fear="Failure", fear="Failure",
arc="Transform through journey",
)) ))
return characters return characters[:5]
def _parse_scenes(self, text: str) -> list[PlotBeat]: def _parse_scenes(self, text: str) -> list[PlotBeat]:
"""Parse scenes from text.""" """Parse scenes from text."""
@@ -665,37 +468,21 @@ For each: name, description, POV character, location, purpose.
if line and len(line) > 10: if line and len(line) > 10:
scenes.append(PlotBeat( scenes.append(PlotBeat(
name=f"Scene {i+1}", name=f"Scene {i+1}",
description=line[:150], description=line[:120],
)) ))
return scenes[:20] if scenes else [PlotBeat(name=f"Scene {i+1}", description=f"Story beat {i+1}") for i in range(10)] return scenes[:20] if scenes else [PlotBeat(name=f"Scene {i+1}", description=f"Beat {i+1}") for i in range(10)]
def _parse_descriptions(self, text: str) -> dict[str, str]:
"""Parse scene descriptions."""
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
# ============== RUN ============== # ============== RUN ==============
def run( def run(self, seed_concept: str, thread_id: str = "default") -> OpusGraphState:
self,
seed_concept: str,
thread_id: str = "default",
) -> OpusGraphState:
"""Run the workflow.""" """Run the workflow."""
print(f"\n{'='*60}") print(f"\n{'='*60}")
print(f"🎯 OPUS LANGGRAPH WORKFLOW") print("🎯 OPUS LANGGRAPH WORKFLOW")
print(f"{'='*60}") print(f"{'='*60}")
print(f"Framework: {self.framework}") print(f"Framework: {self.framework}")
print(f"Target: {self.target_word_count:,} words\n") print(f"Target: {self.target_word_count:,} words\n")
# Initial state
initial_state = OpusGraphState( initial_state = OpusGraphState(
seed_concept=seed_concept, seed_concept=seed_concept,
framework=self.framework, framework=self.framework,
@@ -703,40 +490,21 @@ For each: name, description, POV character, location, purpose.
target_word_count=self.target_word_count, target_word_count=self.target_word_count,
) )
# Run with thread
config = {"configurable": {"thread_id": thread_id}} config = {"configurable": {"thread_id": thread_id}}
final_state = None # LangGraph stream returns dict of node_name -> state
for state in self.graph.stream(initial_state, config): for node_output in self.graph.stream(initial_state, config):
final_state = state # Get the state (last output is final)
for key, state in node_output.items():
if isinstance(state, OpusGraphState):
final_state = state
if final_state: if final_state:
result = list(final_state.values())[0] return final_state
# Compile manuscript
manuscript_parts = []
for i in range(1, len(result.chapters) + 1):
if i in result.chapters:
manuscript_parts.append(f"# Chapter {i}\n\n{result.chapters[i].content}")
result.manuscript = "\n\n---\n\n".join(manuscript_parts)
result.total_word_count = sum(c.word_count for c in result.chapters.values())
result.stage = Stage.COMPLETE
result.progress = 1.0
print(f"\n{'='*60}")
print("✅ COMPLETE!")
print(f"{'='*60}")
print(f"📖 Chapters: {len(result.chapters)}")
print(f"📄 Words: {result.total_word_count:,}")
return result
return initial_state return initial_state
# Convenience function
def run_opus( def run_opus(
seed_concept: str, seed_concept: str,
framework: str = "snowflake", framework: str = "snowflake",
@@ -744,7 +512,7 @@ def run_opus(
target_word_count: int = 80000, target_word_count: int = 80000,
thread_id: str = "default", thread_id: str = "default",
) -> OpusGraphState: ) -> OpusGraphState:
"""Run Opus workflow.""" """Convenience function."""
api_key = os.environ.get("OPENAI_API_KEY") api_key = os.environ.get("OPENAI_API_KEY")
workflow = OpusGraph( workflow = OpusGraph(