|
| 1 | +""" |
| 2 | +World Building Loop — LangGraph StateGraph for conversational world creation. |
| 3 | +
|
| 4 | +LAYER: 2 (agents) |
| 5 | +IMPORTS FROM: monitor_data (Layer 1), langgraph, external libs |
| 6 | +CALLED BY: chat router (Layer 3 / backend) via WorldBuildingLoop.run() |
| 7 | +
|
| 8 | +The World Building Loop handles the World Architect mode where users |
| 9 | +collaboratively define their setting through conversation. Unlike the |
| 10 | +Scene Loop (interactive narrative), this loop: |
| 11 | +
|
| 12 | +- Has NO dice/resolution mechanics |
| 13 | +- Uses WorldArchitect agent instead of Narrator |
| 14 | +- Auto-commits proposals (user is defining their world deliberately) |
| 15 | +- Tracks creation progress and suggests gaps |
| 16 | +
|
| 17 | +Flow: |
| 18 | + load_world_context → process_user_input → commit_proposals → |
| 19 | + format_response → (return to caller, await next input) |
| 20 | +
|
| 21 | +This loop is driven externally — each call to ``run()`` processes one |
| 22 | +user turn and returns the architect's response. The chat router manages |
| 23 | +the outer message loop. |
| 24 | +""" |
| 25 | + |
| 26 | +from __future__ import annotations |
| 27 | + |
| 28 | +from typing import Any, Dict, List, Optional |
| 29 | +from uuid import UUID |
| 30 | + |
| 31 | +from langgraph.graph import StateGraph, END |
| 32 | +from pydantic import BaseModel, Field |
| 33 | + |
| 34 | + |
| 35 | +# ============================================================================= |
| 36 | +# STATE SCHEMA |
| 37 | +# ============================================================================= |
| 38 | + |
| 39 | + |
| 40 | +class WorldBuildingState(BaseModel): |
| 41 | + """State flowing through the World Building Loop nodes.""" |
| 42 | + |
| 43 | + # Session identifiers |
| 44 | + session_id: str |
| 45 | + universe_id: Optional[UUID] = None |
| 46 | + multiverse_id: Optional[UUID] = None |
| 47 | + |
| 48 | + # Conversation context |
| 49 | + conversation_history: List[Dict[str, Any]] = Field(default_factory=list) |
| 50 | + current_user_input: str = "" |
| 51 | + |
| 52 | + # World state (loaded each turn for freshness) |
| 53 | + world_summary: Dict[str, Any] = Field(default_factory=dict) |
| 54 | + |
| 55 | + # Turn output |
| 56 | + response_text: str = "" |
| 57 | + extracted_proposals: List[Dict[str, Any]] = Field(default_factory=list) |
| 58 | + committed_count: int = 0 |
| 59 | + commit_errors: List[str] = Field(default_factory=list) |
| 60 | + |
| 61 | + # Flow control |
| 62 | + turns_count: int = 0 |
| 63 | + is_first_turn: bool = True |
| 64 | + |
| 65 | + |
| 66 | +# ============================================================================= |
| 67 | +# NODES |
| 68 | +# ============================================================================= |
| 69 | + |
| 70 | + |
| 71 | +async def load_world_context(state: WorldBuildingState) -> Dict[str, Any]: |
| 72 | + """ |
| 73 | + Load current universe state from Neo4j for context injection. |
| 74 | +
|
| 75 | + This runs at the start of every turn to ensure the architect has |
| 76 | + an up-to-date view of what exists in the world. |
| 77 | +
|
| 78 | + Write: nothing (read-only node). |
| 79 | + """ |
| 80 | + from monitor_agents.world_architect import WorldArchitect |
| 81 | + |
| 82 | + architect = WorldArchitect() |
| 83 | + world_summary = await architect._load_world_state(state.universe_id) |
| 84 | + |
| 85 | + # On first turn with an empty universe, generate a welcome message |
| 86 | + is_first = state.turns_count == 0 and not state.conversation_history |
| 87 | + |
| 88 | + return { |
| 89 | + "world_summary": world_summary, |
| 90 | + "is_first_turn": is_first, |
| 91 | + } |
| 92 | + |
| 93 | + |
| 94 | +async def process_user_input(state: WorldBuildingState) -> Dict[str, Any]: |
| 95 | + """ |
| 96 | + Process the user's message through the WorldArchitect agent. |
| 97 | +
|
| 98 | + This is the core node — it runs the DSPy module to generate both |
| 99 | + a conversational response and structured proposal extractions. |
| 100 | +
|
| 101 | + Write: nothing directly (proposals are committed in next node). |
| 102 | + """ |
| 103 | + from monitor_agents.world_architect import WorldArchitect |
| 104 | + |
| 105 | + architect = WorldArchitect() |
| 106 | + |
| 107 | + # If this is the very first turn and no input, generate a welcome |
| 108 | + if state.is_first_turn and not state.current_user_input: |
| 109 | + suggestion = await architect.suggest_next(state.universe_id) |
| 110 | + return { |
| 111 | + "response_text": ( |
| 112 | + "Welcome to the **World Architect**! I'm here to help you build " |
| 113 | + "your setting from the ground up.\n\n" |
| 114 | + f"{suggestion}\n\n" |
| 115 | + "Describe your world however feels natural — I'll extract the key " |
| 116 | + "elements and add them to your universe." |
| 117 | + ), |
| 118 | + "extracted_proposals": [], |
| 119 | + "is_first_turn": False, |
| 120 | + } |
| 121 | + |
| 122 | + result = await architect.process_turn( |
| 123 | + user_message=state.current_user_input, |
| 124 | + universe_id=state.universe_id, |
| 125 | + conversation_history=state.conversation_history, |
| 126 | + ) |
| 127 | + |
| 128 | + return { |
| 129 | + "response_text": result["response"], |
| 130 | + "extracted_proposals": result["proposals"], |
| 131 | + "committed_count": result["committed"], |
| 132 | + "commit_errors": result["errors"], |
| 133 | + "is_first_turn": False, |
| 134 | + } |
| 135 | + |
| 136 | + |
| 137 | +async def format_response(state: WorldBuildingState) -> Dict[str, Any]: |
| 138 | + """ |
| 139 | + Append creation summary to the response if proposals were committed. |
| 140 | +
|
| 141 | + This enriches the architect's natural language response with a |
| 142 | + structured summary of what was actually added to Neo4j. |
| 143 | +
|
| 144 | + Write: nothing. |
| 145 | + """ |
| 146 | + response = state.response_text |
| 147 | + committed = state.committed_count |
| 148 | + proposals = state.extracted_proposals |
| 149 | + errors = state.commit_errors |
| 150 | + |
| 151 | + # Add creation summary footer |
| 152 | + if committed > 0: |
| 153 | + created_items = [] |
| 154 | + for p in proposals[:committed]: |
| 155 | + p_type = p.get("proposal_type", "element") |
| 156 | + name = p.get("payload", {}).get("name") or p.get("payload", {}).get("statement", "")[:50] |
| 157 | + if name: |
| 158 | + created_items.append(f" - {p_type.title()}: **{name}**") |
| 159 | + |
| 160 | + if created_items: |
| 161 | + response += "\n\n---\n_Added to your world:_\n" + "\n".join(created_items) |
| 162 | + |
| 163 | + if errors: |
| 164 | + response += f"\n\n_({len(errors)} element(s) could not be added — will retry on next turn)_" |
| 165 | + |
| 166 | + return { |
| 167 | + "response_text": response, |
| 168 | + "turns_count": state.turns_count + 1, |
| 169 | + } |
| 170 | + |
| 171 | + |
| 172 | +# ============================================================================= |
| 173 | +# GRAPH BUILDER |
| 174 | +# ============================================================================= |
| 175 | + |
| 176 | + |
| 177 | +def build_world_building_graph() -> StateGraph: |
| 178 | + """Construct the World Building Loop StateGraph.""" |
| 179 | + graph = StateGraph(WorldBuildingState) |
| 180 | + |
| 181 | + graph.add_node("load_world_context", load_world_context) |
| 182 | + graph.add_node("process_user_input", process_user_input) |
| 183 | + graph.add_node("format_response", format_response) |
| 184 | + |
| 185 | + graph.set_entry_point("load_world_context") |
| 186 | + graph.add_edge("load_world_context", "process_user_input") |
| 187 | + graph.add_edge("process_user_input", "format_response") |
| 188 | + graph.add_edge("format_response", END) |
| 189 | + |
| 190 | + return graph |
| 191 | + |
| 192 | + |
| 193 | +# ============================================================================= |
| 194 | +# LOOP CLASS |
| 195 | +# ============================================================================= |
| 196 | + |
| 197 | + |
| 198 | +class WorldBuildingLoop: |
| 199 | + """ |
| 200 | + High-level interface for the World Building Loop. |
| 201 | +
|
| 202 | + Each call to ``run()`` processes one user turn through the graph |
| 203 | + and returns the architect's response. |
| 204 | +
|
| 205 | + Usage: |
| 206 | + loop = WorldBuildingLoop( |
| 207 | + session_id="abc", |
| 208 | + universe_id=UUID("..."), |
| 209 | + ) |
| 210 | + result = await loop.run(user_input="I want a dark fantasy world") |
| 211 | + print(result["response_text"]) |
| 212 | + """ |
| 213 | + |
| 214 | + def __init__( |
| 215 | + self, |
| 216 | + session_id: str, |
| 217 | + universe_id: Optional[UUID] = None, |
| 218 | + multiverse_id: Optional[UUID] = None, |
| 219 | + ) -> None: |
| 220 | + self.session_id = session_id |
| 221 | + self.universe_id = universe_id |
| 222 | + self.multiverse_id = multiverse_id |
| 223 | + self._graph = build_world_building_graph().compile() |
| 224 | + |
| 225 | + async def run( |
| 226 | + self, |
| 227 | + user_input: str = "", |
| 228 | + conversation_history: Optional[List[Dict[str, Any]]] = None, |
| 229 | + ) -> Dict[str, Any]: |
| 230 | + """ |
| 231 | + Execute one world-building turn. |
| 232 | +
|
| 233 | + Args: |
| 234 | + user_input: The user's message. |
| 235 | + conversation_history: Prior messages for context. |
| 236 | +
|
| 237 | + Returns: |
| 238 | + Dict with response_text, extracted_proposals, committed_count, etc. |
| 239 | + """ |
| 240 | + initial_state = WorldBuildingState( |
| 241 | + session_id=self.session_id, |
| 242 | + universe_id=self.universe_id, |
| 243 | + multiverse_id=self.multiverse_id, |
| 244 | + conversation_history=conversation_history or [], |
| 245 | + current_user_input=user_input, |
| 246 | + ) |
| 247 | + |
| 248 | + result = await self._graph.ainvoke(initial_state.model_dump()) |
| 249 | + |
| 250 | + return { |
| 251 | + "response_text": result.get("response_text", ""), |
| 252 | + "extracted_proposals": result.get("extracted_proposals", []), |
| 253 | + "committed_count": result.get("committed_count", 0), |
| 254 | + "commit_errors": result.get("commit_errors", []), |
| 255 | + "turns_count": result.get("turns_count", 0), |
| 256 | + } |
0 commit comments