yamlgraph 0.3.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. examples/__init__.py +1 -0
  2. examples/codegen/__init__.py +5 -0
  3. examples/codegen/models/__init__.py +13 -0
  4. examples/codegen/models/schemas.py +76 -0
  5. examples/codegen/tests/__init__.py +1 -0
  6. examples/codegen/tests/test_ai_helpers.py +235 -0
  7. examples/codegen/tests/test_ast_analysis.py +174 -0
  8. examples/codegen/tests/test_code_analysis.py +134 -0
  9. examples/codegen/tests/test_code_context.py +301 -0
  10. examples/codegen/tests/test_code_nav.py +89 -0
  11. examples/codegen/tests/test_dependency_tools.py +119 -0
  12. examples/codegen/tests/test_example_tools.py +185 -0
  13. examples/codegen/tests/test_git_tools.py +112 -0
  14. examples/codegen/tests/test_impl_agent_schemas.py +193 -0
  15. examples/codegen/tests/test_impl_agent_v4_graph.py +94 -0
  16. examples/codegen/tests/test_jedi_analysis.py +226 -0
  17. examples/codegen/tests/test_meta_tools.py +250 -0
  18. examples/codegen/tests/test_plan_discovery_prompt.py +98 -0
  19. examples/codegen/tests/test_syntax_tools.py +85 -0
  20. examples/codegen/tests/test_synthesize_prompt.py +94 -0
  21. examples/codegen/tests/test_template_tools.py +244 -0
  22. examples/codegen/tools/__init__.py +80 -0
  23. examples/codegen/tools/ai_helpers.py +420 -0
  24. examples/codegen/tools/ast_analysis.py +92 -0
  25. examples/codegen/tools/code_context.py +180 -0
  26. examples/codegen/tools/code_nav.py +52 -0
  27. examples/codegen/tools/dependency_tools.py +120 -0
  28. examples/codegen/tools/example_tools.py +188 -0
  29. examples/codegen/tools/git_tools.py +151 -0
  30. examples/codegen/tools/impl_executor.py +614 -0
  31. examples/codegen/tools/jedi_analysis.py +311 -0
  32. examples/codegen/tools/meta_tools.py +202 -0
  33. examples/codegen/tools/syntax_tools.py +26 -0
  34. examples/codegen/tools/template_tools.py +356 -0
  35. examples/fastapi_interview.py +167 -0
  36. examples/npc/api/__init__.py +1 -0
  37. examples/npc/api/app.py +100 -0
  38. examples/npc/api/routes/__init__.py +5 -0
  39. examples/npc/api/routes/encounter.py +182 -0
  40. examples/npc/api/session.py +330 -0
  41. examples/npc/demo.py +387 -0
  42. examples/npc/nodes/__init__.py +5 -0
  43. examples/npc/nodes/image_node.py +92 -0
  44. examples/npc/run_encounter.py +230 -0
  45. examples/shared/__init__.py +0 -0
  46. examples/shared/replicate_tool.py +238 -0
  47. examples/storyboard/__init__.py +1 -0
  48. examples/storyboard/generate_videos.py +335 -0
  49. examples/storyboard/nodes/__init__.py +12 -0
  50. examples/storyboard/nodes/animated_character_node.py +248 -0
  51. examples/storyboard/nodes/animated_image_node.py +138 -0
  52. examples/storyboard/nodes/character_node.py +162 -0
  53. examples/storyboard/nodes/image_node.py +118 -0
  54. examples/storyboard/nodes/replicate_tool.py +49 -0
  55. examples/storyboard/retry_images.py +118 -0
  56. scripts/demo_async_executor.py +212 -0
  57. scripts/demo_interview_e2e.py +200 -0
  58. scripts/demo_streaming.py +140 -0
  59. scripts/run_interview_demo.py +94 -0
  60. scripts/test_interrupt_fix.py +26 -0
  61. tests/__init__.py +1 -0
  62. tests/conftest.py +178 -0
  63. tests/integration/__init__.py +1 -0
  64. tests/integration/test_animated_storyboard.py +63 -0
  65. tests/integration/test_cli_commands.py +242 -0
  66. tests/integration/test_colocated_prompts.py +139 -0
  67. tests/integration/test_map_demo.py +50 -0
  68. tests/integration/test_memory_demo.py +283 -0
  69. tests/integration/test_npc_api/__init__.py +1 -0
  70. tests/integration/test_npc_api/test_routes.py +357 -0
  71. tests/integration/test_npc_api/test_session.py +216 -0
  72. tests/integration/test_pipeline_flow.py +105 -0
  73. tests/integration/test_providers.py +163 -0
  74. tests/integration/test_resume.py +75 -0
  75. tests/integration/test_subgraph_integration.py +295 -0
  76. tests/integration/test_subgraph_interrupt.py +106 -0
  77. tests/unit/__init__.py +1 -0
  78. tests/unit/test_agent_nodes.py +355 -0
  79. tests/unit/test_async_executor.py +346 -0
  80. tests/unit/test_checkpointer.py +212 -0
  81. tests/unit/test_checkpointer_factory.py +212 -0
  82. tests/unit/test_cli.py +121 -0
  83. tests/unit/test_cli_package.py +81 -0
  84. tests/unit/test_compile_graph_map.py +132 -0
  85. tests/unit/test_conditions_routing.py +253 -0
  86. tests/unit/test_config.py +93 -0
  87. tests/unit/test_conversation_memory.py +276 -0
  88. tests/unit/test_database.py +145 -0
  89. tests/unit/test_deprecation.py +104 -0
  90. tests/unit/test_executor.py +172 -0
  91. tests/unit/test_executor_async.py +179 -0
  92. tests/unit/test_export.py +149 -0
  93. tests/unit/test_expressions.py +178 -0
  94. tests/unit/test_feature_brainstorm.py +194 -0
  95. tests/unit/test_format_prompt.py +145 -0
  96. tests/unit/test_generic_report.py +200 -0
  97. tests/unit/test_graph_commands.py +327 -0
  98. tests/unit/test_graph_linter.py +627 -0
  99. tests/unit/test_graph_loader.py +357 -0
  100. tests/unit/test_graph_schema.py +193 -0
  101. tests/unit/test_inline_schema.py +151 -0
  102. tests/unit/test_interrupt_node.py +182 -0
  103. tests/unit/test_issues.py +164 -0
  104. tests/unit/test_jinja2_prompts.py +85 -0
  105. tests/unit/test_json_extract.py +134 -0
  106. tests/unit/test_langsmith.py +600 -0
  107. tests/unit/test_langsmith_tools.py +204 -0
  108. tests/unit/test_llm_factory.py +109 -0
  109. tests/unit/test_llm_factory_async.py +118 -0
  110. tests/unit/test_loops.py +403 -0
  111. tests/unit/test_map_node.py +144 -0
  112. tests/unit/test_no_backward_compat.py +56 -0
  113. tests/unit/test_node_factory.py +348 -0
  114. tests/unit/test_passthrough_node.py +126 -0
  115. tests/unit/test_prompts.py +324 -0
  116. tests/unit/test_python_nodes.py +198 -0
  117. tests/unit/test_reliability.py +298 -0
  118. tests/unit/test_result_export.py +234 -0
  119. tests/unit/test_router.py +296 -0
  120. tests/unit/test_sanitize.py +99 -0
  121. tests/unit/test_schema_loader.py +295 -0
  122. tests/unit/test_shell_tools.py +229 -0
  123. tests/unit/test_state_builder.py +331 -0
  124. tests/unit/test_state_builder_map.py +104 -0
  125. tests/unit/test_state_config.py +197 -0
  126. tests/unit/test_streaming.py +307 -0
  127. tests/unit/test_subgraph.py +596 -0
  128. tests/unit/test_template.py +190 -0
  129. tests/unit/test_tool_call_integration.py +164 -0
  130. tests/unit/test_tool_call_node.py +178 -0
  131. tests/unit/test_tool_nodes.py +129 -0
  132. tests/unit/test_websearch.py +234 -0
  133. yamlgraph/__init__.py +35 -0
  134. yamlgraph/builder.py +110 -0
  135. yamlgraph/cli/__init__.py +159 -0
  136. yamlgraph/cli/__main__.py +6 -0
  137. yamlgraph/cli/commands.py +231 -0
  138. yamlgraph/cli/deprecation.py +92 -0
  139. yamlgraph/cli/graph_commands.py +541 -0
  140. yamlgraph/cli/validators.py +37 -0
  141. yamlgraph/config.py +67 -0
  142. yamlgraph/constants.py +70 -0
  143. yamlgraph/error_handlers.py +227 -0
  144. yamlgraph/executor.py +290 -0
  145. yamlgraph/executor_async.py +288 -0
  146. yamlgraph/graph_loader.py +451 -0
  147. yamlgraph/map_compiler.py +150 -0
  148. yamlgraph/models/__init__.py +36 -0
  149. yamlgraph/models/graph_schema.py +181 -0
  150. yamlgraph/models/schemas.py +124 -0
  151. yamlgraph/models/state_builder.py +236 -0
  152. yamlgraph/node_factory.py +768 -0
  153. yamlgraph/routing.py +87 -0
  154. yamlgraph/schema_loader.py +240 -0
  155. yamlgraph/storage/__init__.py +20 -0
  156. yamlgraph/storage/checkpointer.py +72 -0
  157. yamlgraph/storage/checkpointer_factory.py +123 -0
  158. yamlgraph/storage/database.py +320 -0
  159. yamlgraph/storage/export.py +269 -0
  160. yamlgraph/tools/__init__.py +1 -0
  161. yamlgraph/tools/agent.py +320 -0
  162. yamlgraph/tools/graph_linter.py +388 -0
  163. yamlgraph/tools/langsmith_tools.py +125 -0
  164. yamlgraph/tools/nodes.py +126 -0
  165. yamlgraph/tools/python_tool.py +179 -0
  166. yamlgraph/tools/shell.py +205 -0
  167. yamlgraph/tools/websearch.py +242 -0
  168. yamlgraph/utils/__init__.py +48 -0
  169. yamlgraph/utils/conditions.py +157 -0
  170. yamlgraph/utils/expressions.py +245 -0
  171. yamlgraph/utils/json_extract.py +104 -0
  172. yamlgraph/utils/langsmith.py +416 -0
  173. yamlgraph/utils/llm_factory.py +118 -0
  174. yamlgraph/utils/llm_factory_async.py +105 -0
  175. yamlgraph/utils/logging.py +104 -0
  176. yamlgraph/utils/prompts.py +171 -0
  177. yamlgraph/utils/sanitize.py +98 -0
  178. yamlgraph/utils/template.py +102 -0
  179. yamlgraph/utils/validators.py +181 -0
  180. yamlgraph-0.3.9.dist-info/METADATA +1105 -0
  181. yamlgraph-0.3.9.dist-info/RECORD +185 -0
  182. yamlgraph-0.3.9.dist-info/WHEEL +5 -0
  183. yamlgraph-0.3.9.dist-info/entry_points.txt +2 -0
  184. yamlgraph-0.3.9.dist-info/licenses/LICENSE +33 -0
  185. yamlgraph-0.3.9.dist-info/top_level.txt +4 -0
@@ -0,0 +1,182 @@
1
+ """Encounter API routes.
2
+
3
+ HTMX-powered endpoints for NPC encounter management.
4
+ Returns HTML fragments for dynamic page updates.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import Annotated
11
+
12
+ from fastapi import APIRouter, Form, HTTPException, Request
13
+ from fastapi.responses import HTMLResponse
14
+ from fastapi.templating import Jinja2Templates
15
+
16
+ from examples.npc.api.session import (
17
+ EncounterSession,
18
+ TurnResult,
19
+ create_npcs_from_concepts,
20
+ get_encounter_graph,
21
+ )
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ router = APIRouter(prefix="/encounter", tags=["encounter"])
26
+
27
+ # Templates - configured at app startup
28
+ templates = Jinja2Templates(directory="examples/npc/api/templates")
29
+
30
+
31
+ async def _get_session(session_id: str) -> EncounterSession:
32
+ """Create session wrapper for given session_id."""
33
+ graph = await get_encounter_graph()
34
+ return EncounterSession(graph, session_id)
35
+
36
+
37
+ def _render_turn_result(
38
+ request: Request,
39
+ result: TurnResult,
40
+ session_id: str,
41
+ ) -> HTMLResponse:
42
+ """Render turn result as HTML fragment."""
43
+ if result.error:
44
+ return templates.TemplateResponse(
45
+ request=request,
46
+ name="components/error.html",
47
+ context={
48
+ "error": result.error,
49
+ "session_id": session_id,
50
+ },
51
+ status_code=400,
52
+ headers={"HX-Trigger": "encounter-error"},
53
+ )
54
+
55
+ return templates.TemplateResponse(
56
+ request=request,
57
+ name="components/turn_result.html",
58
+ context={
59
+ "turn_number": result.turn_number,
60
+ "narrations": result.narrations,
61
+ "scene_image": result.scene_image,
62
+ "turn_summary": result.turn_summary,
63
+ "is_complete": result.is_complete,
64
+ "session_id": session_id,
65
+ },
66
+ headers={"HX-Trigger": "encounter-updated"},
67
+ )
68
+
69
+
70
+ @router.post("/start", response_class=HTMLResponse)
71
+ async def start_encounter(
72
+ request: Request,
73
+ session_id: Annotated[str, Form()],
74
+ location: Annotated[str, Form()] = "tavern",
75
+ ):
76
+ """Start a new encounter session.
77
+
78
+ Args:
79
+ session_id: Unique identifier for this encounter session
80
+ location: Where the encounter takes place
81
+
82
+ Returns:
83
+ HTML fragment with initial encounter state
84
+ """
85
+ logger.info(f"📝 Starting encounter {session_id} at {location}")
86
+
87
+ # Get concepts from form data - always use getlist for multiple values
88
+ form_data = await request.form()
89
+ concepts = form_data.getlist("npc_concepts")
90
+ logger.info(f"📋 NPC concepts received: {concepts}")
91
+
92
+ # Create NPCs from concepts
93
+ if concepts:
94
+ concept_dicts = [{"concept": c} for c in concepts if c.strip()]
95
+ logger.info(f"🎭 Creating {len(concept_dicts)} NPCs from concepts")
96
+ npcs = await create_npcs_from_concepts(concept_dicts)
97
+ else:
98
+ # Default NPC
99
+ logger.info("🎭 No concepts provided, using default NPC")
100
+ npcs = [{"name": "Innkeeper", "race": "human", "character_class": "Commoner"}]
101
+
102
+ logger.info(f"✅ Created {len(npcs)} NPCs")
103
+
104
+ # Start encounter
105
+ session = await _get_session(session_id)
106
+ result = await session.start(npcs=npcs, location=location)
107
+
108
+ return _render_turn_result(request, result, session_id)
109
+
110
+
111
+ @router.post("/turn", response_class=HTMLResponse)
112
+ async def process_turn(
113
+ request: Request,
114
+ session_id: Annotated[str, Form()],
115
+ dm_input: Annotated[str, Form()],
116
+ ):
117
+ """Process a DM input turn.
118
+
119
+ Args:
120
+ session_id: The encounter session to resume
121
+ dm_input: DM's input/narration for the turn
122
+
123
+ Returns:
124
+ HTML fragment with NPC responses
125
+ """
126
+ logger.info(f"🎲 Processing turn for {session_id}")
127
+
128
+ session = await _get_session(session_id)
129
+
130
+ # Check if session exists
131
+ if not await session._is_resume():
132
+ return templates.TemplateResponse(
133
+ request=request,
134
+ name="components/error.html",
135
+ context={
136
+ "error": "Session not found. Please start a new encounter.",
137
+ "session_id": session_id,
138
+ },
139
+ status_code=400,
140
+ headers={"HX-Trigger": "encounter-error"},
141
+ )
142
+
143
+ result = await session.turn(dm_input)
144
+
145
+ return _render_turn_result(request, result, session_id)
146
+
147
+
148
+ @router.get("/{session_id}", response_class=HTMLResponse)
149
+ async def get_encounter_state(
150
+ request: Request,
151
+ session_id: str,
152
+ ):
153
+ """Get current encounter state.
154
+
155
+ Args:
156
+ session_id: The encounter session to retrieve
157
+
158
+ Returns:
159
+ HTML fragment with current encounter state
160
+ """
161
+ session = await _get_session(session_id)
162
+
163
+ # Check if session exists
164
+ if not await session._is_resume():
165
+ raise HTTPException(status_code=404, detail="Session not found")
166
+
167
+ # Get state from graph
168
+ graph = await get_encounter_graph()
169
+ config = {"configurable": {"thread_id": session_id}}
170
+ state = await graph.aget_state(config)
171
+
172
+ return templates.TemplateResponse(
173
+ request=request,
174
+ name="components/encounter_state.html",
175
+ context={
176
+ "session_id": session_id,
177
+ "turn_number": state.values.get("turn_number", 0),
178
+ "narrations": state.values.get("narrations", []),
179
+ "npcs": state.values.get("npcs", []),
180
+ "location": state.values.get("location", "Unknown"),
181
+ },
182
+ )
@@ -0,0 +1,330 @@
1
+ """Session adapter for NPC Encounter API.
2
+
3
+ Provides stateless session management with checkpointer-based state persistence.
4
+ All session state lives in the checkpointer (SQLite or Redis), not in Python objects.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import os
11
+ from dataclasses import dataclass
12
+
13
+ from langgraph.checkpoint.memory import MemorySaver
14
+ from langgraph.errors import GraphInterrupt
15
+ from langgraph.types import Command
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # Module-level caches
20
+ _encounter_graph = None
21
+ _npc_creation_graph = None
22
+ _checkpointer = None
23
+
24
+
25
+ def _reset_checkpointer():
26
+ """Reset checkpointer cache (for testing)."""
27
+ global _checkpointer
28
+ _checkpointer = None
29
+
30
+
31
+ def _reset_graphs():
32
+ """Reset graph caches (for testing)."""
33
+ global _encounter_graph, _npc_creation_graph
34
+ _encounter_graph = None
35
+ _npc_creation_graph = None
36
+
37
+
38
+ def get_checkpointer():
39
+ """Get or create checkpointer.
40
+
41
+ Uses Redis if REDIS_URL is set, otherwise MemorySaver.
42
+ Note: MemorySaver supports both sync and async operations.
43
+ For production, use Redis for persistence across restarts.
44
+ """
45
+ global _checkpointer
46
+ if _checkpointer is None:
47
+ redis_url = os.getenv("REDIS_URL")
48
+ if redis_url:
49
+ from langgraph.checkpoint.redis import RedisSaver
50
+
51
+ _checkpointer = RedisSaver.from_conn_string(redis_url)
52
+ _checkpointer.setup()
53
+ else:
54
+ # MemorySaver works with both sync and async
55
+ # For persistence, set REDIS_URL or use file-based storage
56
+ _checkpointer = MemorySaver()
57
+ return _checkpointer
58
+
59
+
60
+ async def get_encounter_graph():
61
+ """Get cached encounter graph with checkpointer."""
62
+ global _encounter_graph
63
+ if _encounter_graph is None:
64
+ from yamlgraph.graph_loader import compile_graph, load_graph_config
65
+
66
+ config = load_graph_config("examples/npc/encounter-multi.yaml")
67
+ graph = compile_graph(config)
68
+ _encounter_graph = graph.compile(checkpointer=get_checkpointer())
69
+ return _encounter_graph
70
+
71
+
72
+ async def get_npc_creation_graph():
73
+ """Get cached NPC creation graph (no checkpointer - one-shot)."""
74
+ global _npc_creation_graph
75
+ if _npc_creation_graph is None:
76
+ from yamlgraph.graph_loader import compile_graph, load_graph_config
77
+
78
+ config = load_graph_config("examples/npc/npc-creation.yaml")
79
+ graph = compile_graph(config)
80
+ _npc_creation_graph = graph.compile()
81
+ return _npc_creation_graph
82
+
83
+
84
+ @dataclass
85
+ class TurnResult:
86
+ """Result from encounter turn processing."""
87
+
88
+ turn_number: int
89
+ narrations: list[dict]
90
+ scene_image: str | None
91
+ turn_summary: str | None
92
+ is_complete: bool
93
+ error: str | None = None
94
+
95
+
96
+ class EncounterSession:
97
+ """Stateless session adapter - all state in checkpointer."""
98
+
99
+ def __init__(self, app, session_id: str):
100
+ """Initialize session wrapper.
101
+
102
+ Args:
103
+ app: Compiled LangGraph application with checkpointer
104
+ session_id: Unique session identifier for checkpointing
105
+ """
106
+ self._app = app
107
+ self._session_id = session_id
108
+ self._config = {"configurable": {"thread_id": session_id}}
109
+
110
+ async def _is_resume(self) -> bool:
111
+ """Check if session exists via checkpointer."""
112
+ try:
113
+ checkpoint = await self._app.checkpointer.aget(self._config)
114
+ return checkpoint is not None
115
+ except Exception:
116
+ return False
117
+
118
+ async def start(self, npcs: list[dict], location: str) -> TurnResult:
119
+ """Start new encounter with pre-created NPCs."""
120
+ initial_state = {
121
+ "npcs": npcs,
122
+ "location": location,
123
+ "location_description": f"A bustling scene at {location}",
124
+ "turn_number": 1,
125
+ "encounter_history": [],
126
+ "perceptions": [],
127
+ "decisions": [],
128
+ "narrations": [],
129
+ }
130
+ try:
131
+ result = await self._app.ainvoke(initial_state, self._config)
132
+ return await self._parse_result(result)
133
+ except GraphInterrupt:
134
+ # Graph hit interrupt node - encounter started, waiting for DM input
135
+ # This is normal - return a "waiting" state
136
+ return TurnResult(
137
+ turn_number=1,
138
+ narrations=[
139
+ {
140
+ "npc": "System",
141
+ "text": f"Encounter started at {location}. {len(npcs)} NPCs present. Enter your first narration.",
142
+ }
143
+ ],
144
+ scene_image=None,
145
+ turn_summary=None,
146
+ is_complete=False,
147
+ )
148
+ except Exception as e:
149
+ return TurnResult(
150
+ turn_number=1,
151
+ narrations=[],
152
+ scene_image=None,
153
+ turn_summary=None,
154
+ is_complete=False,
155
+ error=str(e),
156
+ )
157
+
158
+ async def turn(self, dm_input: str) -> TurnResult:
159
+ """Process DM input, return NPC responses."""
160
+ try:
161
+ result = await self._app.ainvoke(Command(resume=dm_input), self._config)
162
+ return await self._parse_result(result)
163
+ except Exception as e:
164
+ return TurnResult(
165
+ turn_number=0,
166
+ narrations=[],
167
+ scene_image=None,
168
+ turn_summary=None,
169
+ is_complete=False,
170
+ error=str(e),
171
+ )
172
+
173
+ async def _parse_result(self, result: dict) -> TurnResult:
174
+ """Parse graph result - async for checkpointer access."""
175
+ npcs = result.get("npcs", [])
176
+ raw_narrations = result.get("narrations", [])
177
+ turn_summary = result.get("turn_summary")
178
+ scene_image = result.get("scene_image")
179
+
180
+ # Log what we received
181
+ logger.info(f"📊 Result keys: {list(result.keys())}")
182
+ logger.info(f"🎭 Raw narrations: {len(raw_narrations)}")
183
+ logger.info(f"🖼️ Scene image: {scene_image}")
184
+ logger.info(
185
+ f"📝 Turn summary: {turn_summary[:100] if turn_summary else '(none)'}..."
186
+ )
187
+
188
+ # Check if graph hit an interrupt AND we don't have actual narrations yet
189
+ # (Initial state has no narrations, but after processing we do)
190
+ if "__interrupt__" in result and not raw_narrations and not turn_summary:
191
+ # Graph is waiting for input - return a waiting message
192
+ location = result.get("location", "unknown location")
193
+ npc_names = [n.get("name", "Unknown") for n in npcs] if npcs else []
194
+ return TurnResult(
195
+ turn_number=result.get("turn_number", 1),
196
+ narrations=[
197
+ {
198
+ "npc": "System",
199
+ "text": f"Encounter at {location}. NPCs: {', '.join(npc_names) or 'none'}. Enter your narration.",
200
+ }
201
+ ],
202
+ scene_image=scene_image,
203
+ turn_summary=None,
204
+ is_complete=False,
205
+ )
206
+
207
+ # Check if graph is waiting (interrupted) - use async method
208
+ state = await self._app.aget_state(self._config)
209
+ is_complete = not (hasattr(state, "next") and state.next)
210
+
211
+ # Transform narrations to expected format
212
+ # Map nodes collect raw outputs, we need to pair with NPC names
213
+ # Map nodes return dicts with {'_map_index': N, 'value': '...'}
214
+ formatted_narrations = []
215
+
216
+ # Only take one narration per NPC (map nodes may return multiple)
217
+ seen_indices = set()
218
+ for narration in raw_narrations:
219
+ # Extract the actual text from map node output
220
+ if isinstance(narration, dict):
221
+ # Map node output format: {'_map_index': N, 'value': '...'}
222
+ map_index = narration.get("_map_index", len(seen_indices))
223
+ if map_index in seen_indices:
224
+ continue # Skip duplicates
225
+ seen_indices.add(map_index)
226
+
227
+ # Get the text value
228
+ text = (
229
+ narration.get("value")
230
+ or narration.get("text")
231
+ or narration.get("narration")
232
+ or str(narration)
233
+ )
234
+ npc_name = (
235
+ npcs[map_index].get("name", f"NPC {map_index+1}")
236
+ if map_index < len(npcs)
237
+ else f"NPC {map_index+1}"
238
+ )
239
+ elif isinstance(narration, str):
240
+ npc_index = len(formatted_narrations)
241
+ npc_name = (
242
+ npcs[npc_index].get("name", f"NPC {npc_index+1}")
243
+ if npc_index < len(npcs)
244
+ else f"NPC {npc_index+1}"
245
+ )
246
+ text = narration
247
+ else:
248
+ npc_index = len(formatted_narrations)
249
+ npc_name = (
250
+ npcs[npc_index].get("name", f"NPC {npc_index+1}")
251
+ if npc_index < len(npcs)
252
+ else f"NPC {npc_index+1}"
253
+ )
254
+ text = str(narration)
255
+
256
+ formatted_narrations.append({"npc": npc_name, "text": text})
257
+
258
+ logger.info(
259
+ f"✅ Formatted {len(formatted_narrations)} narrations: {[n['npc'] for n in formatted_narrations]}"
260
+ )
261
+
262
+ return TurnResult(
263
+ turn_number=result.get("turn_number", 1),
264
+ narrations=formatted_narrations,
265
+ scene_image=result.get("scene_image"),
266
+ turn_summary=result.get("turn_summary"),
267
+ is_complete=is_complete,
268
+ )
269
+
270
+
271
+ async def create_npcs_from_concepts(concepts: list[dict]) -> list[dict]:
272
+ """Create NPCs using npc-creation.yaml graph.
273
+
274
+ Args:
275
+ concepts: List of NPC concept dicts with keys like:
276
+ - concept: str (e.g., "a gruff dwarven bartender")
277
+ - race: str (optional)
278
+ - character_class: str (optional)
279
+ - location: str (optional)
280
+
281
+ Returns:
282
+ List of created NPC dicts ready for encounter.
283
+ """
284
+ app = await get_npc_creation_graph()
285
+ npcs = []
286
+
287
+ for i, concept_data in enumerate(concepts):
288
+ thread_id = f"npc-create-{i}-{id(concept_data)}"
289
+ config = {"configurable": {"thread_id": thread_id}}
290
+
291
+ try:
292
+ result = await app.ainvoke(concept_data, config)
293
+
294
+ # Extract NPC from result
295
+ identity = result.get("identity", {})
296
+ if hasattr(identity, "model_dump"):
297
+ identity = identity.model_dump()
298
+
299
+ personality = result.get("personality", {})
300
+ if hasattr(personality, "model_dump"):
301
+ personality = personality.model_dump()
302
+
303
+ behavior = result.get("behavior", {})
304
+ if hasattr(behavior, "model_dump"):
305
+ behavior = behavior.model_dump()
306
+
307
+ npc = {
308
+ "name": identity.get("name", f"NPC {i + 1}"),
309
+ "race": identity.get("race", concept_data.get("race", "Unknown")),
310
+ "character_class": identity.get("character_class", ""),
311
+ "appearance": identity.get("appearance", ""),
312
+ "voice": identity.get("voice", ""),
313
+ "personality": personality,
314
+ "behavior": behavior,
315
+ "goals": behavior.get("goals", []),
316
+ }
317
+ npcs.append(npc)
318
+ except Exception as e:
319
+ # Fallback: use concept as-is
320
+ npcs.append(
321
+ {
322
+ "name": f"NPC {i + 1}",
323
+ "race": concept_data.get("race", "Unknown"),
324
+ "character_class": concept_data.get("character_class", "Commoner"),
325
+ "appearance": concept_data.get("concept", "")[:100],
326
+ "error": str(e),
327
+ }
328
+ )
329
+
330
+ return npcs