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:
@@ -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.
|
|
||||||
|
|
||||||
Requirements:
|
Must include:
|
||||||
- Include protagonist
|
- Protagonist
|
||||||
- Include their goal
|
- Goal
|
||||||
- Include the conflict/obstacle
|
- Conflict/obstacle
|
||||||
- Include the stakes
|
- 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.
|
||||||
{},
|
|
||||||
))
|
Characters: {', '.join(c.name for c in state.prewriting.characters)}
|
||||||
|
|
||||||
|
Include: Backstory, Psychology, Speech patterns, Key scenes
|
||||||
|
"""
|
||||||
|
|
||||||
text = result.output if isinstance(result.output, str) else str(result.output)
|
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)
|
|
||||||
state.messages.append("Style guide created")
|
|
||||||
state.stage = Stage.STYLE_GUIDE
|
|
||||||
state.progress = 0.60
|
|
||||||
|
|
||||||
return state
|
|
||||||
|
|
||||||
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:
|
Genre: {self.genre}
|
||||||
{chr(10).join(f"- {c.name} ({c.role}): {c.description[:100]}" for c in state.prewriting.characters[:5])}
|
|
||||||
|
|
||||||
## Style: {state.style_guide[:500]}...
|
Include: Tone, Voice, Sentence rhythm, Vocabulary level
|
||||||
|
|
||||||
## 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
|
||||||
|
|
||||||
|
def node_write_chapters(self, state: OpusGraphState) -> OpusGraphState:
|
||||||
|
"""Write all chapters."""
|
||||||
|
print("\n✍️ WRITING CHAPTERS...")
|
||||||
|
|
||||||
state.chapters[chapter_num] = ChapterState(
|
system_prompt = f"""You are a professional novelist.
|
||||||
content=output.get("content", ""),
|
Style: {state.style_guide[:500] if state.style_guide else 'Professional fiction'}
|
||||||
word_count=output.get("word_count", len(output.get("content", "").split())),
|
"""
|
||||||
iterations=state.chapters.get(chapter_num, ChapterState()).iterations + 1,
|
|
||||||
)
|
|
||||||
|
|
||||||
state.messages.append(f"Chapter {chapter_num} written: {state.chapters[chapter_num].word_count} words")
|
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"):
|
if "name:" in lower and len(line) < 50:
|
||||||
characters.append(Character(**current))
|
name = line.split(":", 1)[-1].strip()
|
||||||
current = {"name": line.split(":", 1)[-1].strip()}
|
characters.append(Character(
|
||||||
elif "role:" in lower:
|
name=name,
|
||||||
current["role"] = line.split(":", 1)[-1].strip()
|
role="character",
|
||||||
elif "want:" in lower:
|
description=line,
|
||||||
current["want"] = line.split(":", 1)[-1].strip()
|
want="To be defined",
|
||||||
elif "need:" in lower:
|
need="To be defined",
|
||||||
current["need"] = line.split(":", 1)[-1].strip()
|
fear="Unknown",
|
||||||
elif "fear:" in lower:
|
))
|
||||||
current["fear"] = line.split(":", 1)[-1].strip()
|
|
||||||
|
|
||||||
if current and current.get("name"):
|
|
||||||
characters.append(Character(**current))
|
|
||||||
|
|
||||||
# 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(
|
||||||
|
|||||||
Reference in New Issue
Block a user