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.
- examples/__init__.py +1 -0
- examples/codegen/__init__.py +5 -0
- examples/codegen/models/__init__.py +13 -0
- examples/codegen/models/schemas.py +76 -0
- examples/codegen/tests/__init__.py +1 -0
- examples/codegen/tests/test_ai_helpers.py +235 -0
- examples/codegen/tests/test_ast_analysis.py +174 -0
- examples/codegen/tests/test_code_analysis.py +134 -0
- examples/codegen/tests/test_code_context.py +301 -0
- examples/codegen/tests/test_code_nav.py +89 -0
- examples/codegen/tests/test_dependency_tools.py +119 -0
- examples/codegen/tests/test_example_tools.py +185 -0
- examples/codegen/tests/test_git_tools.py +112 -0
- examples/codegen/tests/test_impl_agent_schemas.py +193 -0
- examples/codegen/tests/test_impl_agent_v4_graph.py +94 -0
- examples/codegen/tests/test_jedi_analysis.py +226 -0
- examples/codegen/tests/test_meta_tools.py +250 -0
- examples/codegen/tests/test_plan_discovery_prompt.py +98 -0
- examples/codegen/tests/test_syntax_tools.py +85 -0
- examples/codegen/tests/test_synthesize_prompt.py +94 -0
- examples/codegen/tests/test_template_tools.py +244 -0
- examples/codegen/tools/__init__.py +80 -0
- examples/codegen/tools/ai_helpers.py +420 -0
- examples/codegen/tools/ast_analysis.py +92 -0
- examples/codegen/tools/code_context.py +180 -0
- examples/codegen/tools/code_nav.py +52 -0
- examples/codegen/tools/dependency_tools.py +120 -0
- examples/codegen/tools/example_tools.py +188 -0
- examples/codegen/tools/git_tools.py +151 -0
- examples/codegen/tools/impl_executor.py +614 -0
- examples/codegen/tools/jedi_analysis.py +311 -0
- examples/codegen/tools/meta_tools.py +202 -0
- examples/codegen/tools/syntax_tools.py +26 -0
- examples/codegen/tools/template_tools.py +356 -0
- examples/fastapi_interview.py +167 -0
- examples/npc/api/__init__.py +1 -0
- examples/npc/api/app.py +100 -0
- examples/npc/api/routes/__init__.py +5 -0
- examples/npc/api/routes/encounter.py +182 -0
- examples/npc/api/session.py +330 -0
- examples/npc/demo.py +387 -0
- examples/npc/nodes/__init__.py +5 -0
- examples/npc/nodes/image_node.py +92 -0
- examples/npc/run_encounter.py +230 -0
- examples/shared/__init__.py +0 -0
- examples/shared/replicate_tool.py +238 -0
- examples/storyboard/__init__.py +1 -0
- examples/storyboard/generate_videos.py +335 -0
- examples/storyboard/nodes/__init__.py +12 -0
- examples/storyboard/nodes/animated_character_node.py +248 -0
- examples/storyboard/nodes/animated_image_node.py +138 -0
- examples/storyboard/nodes/character_node.py +162 -0
- examples/storyboard/nodes/image_node.py +118 -0
- examples/storyboard/nodes/replicate_tool.py +49 -0
- examples/storyboard/retry_images.py +118 -0
- scripts/demo_async_executor.py +212 -0
- scripts/demo_interview_e2e.py +200 -0
- scripts/demo_streaming.py +140 -0
- scripts/run_interview_demo.py +94 -0
- scripts/test_interrupt_fix.py +26 -0
- tests/__init__.py +1 -0
- tests/conftest.py +178 -0
- tests/integration/__init__.py +1 -0
- tests/integration/test_animated_storyboard.py +63 -0
- tests/integration/test_cli_commands.py +242 -0
- tests/integration/test_colocated_prompts.py +139 -0
- tests/integration/test_map_demo.py +50 -0
- tests/integration/test_memory_demo.py +283 -0
- tests/integration/test_npc_api/__init__.py +1 -0
- tests/integration/test_npc_api/test_routes.py +357 -0
- tests/integration/test_npc_api/test_session.py +216 -0
- tests/integration/test_pipeline_flow.py +105 -0
- tests/integration/test_providers.py +163 -0
- tests/integration/test_resume.py +75 -0
- tests/integration/test_subgraph_integration.py +295 -0
- tests/integration/test_subgraph_interrupt.py +106 -0
- tests/unit/__init__.py +1 -0
- tests/unit/test_agent_nodes.py +355 -0
- tests/unit/test_async_executor.py +346 -0
- tests/unit/test_checkpointer.py +212 -0
- tests/unit/test_checkpointer_factory.py +212 -0
- tests/unit/test_cli.py +121 -0
- tests/unit/test_cli_package.py +81 -0
- tests/unit/test_compile_graph_map.py +132 -0
- tests/unit/test_conditions_routing.py +253 -0
- tests/unit/test_config.py +93 -0
- tests/unit/test_conversation_memory.py +276 -0
- tests/unit/test_database.py +145 -0
- tests/unit/test_deprecation.py +104 -0
- tests/unit/test_executor.py +172 -0
- tests/unit/test_executor_async.py +179 -0
- tests/unit/test_export.py +149 -0
- tests/unit/test_expressions.py +178 -0
- tests/unit/test_feature_brainstorm.py +194 -0
- tests/unit/test_format_prompt.py +145 -0
- tests/unit/test_generic_report.py +200 -0
- tests/unit/test_graph_commands.py +327 -0
- tests/unit/test_graph_linter.py +627 -0
- tests/unit/test_graph_loader.py +357 -0
- tests/unit/test_graph_schema.py +193 -0
- tests/unit/test_inline_schema.py +151 -0
- tests/unit/test_interrupt_node.py +182 -0
- tests/unit/test_issues.py +164 -0
- tests/unit/test_jinja2_prompts.py +85 -0
- tests/unit/test_json_extract.py +134 -0
- tests/unit/test_langsmith.py +600 -0
- tests/unit/test_langsmith_tools.py +204 -0
- tests/unit/test_llm_factory.py +109 -0
- tests/unit/test_llm_factory_async.py +118 -0
- tests/unit/test_loops.py +403 -0
- tests/unit/test_map_node.py +144 -0
- tests/unit/test_no_backward_compat.py +56 -0
- tests/unit/test_node_factory.py +348 -0
- tests/unit/test_passthrough_node.py +126 -0
- tests/unit/test_prompts.py +324 -0
- tests/unit/test_python_nodes.py +198 -0
- tests/unit/test_reliability.py +298 -0
- tests/unit/test_result_export.py +234 -0
- tests/unit/test_router.py +296 -0
- tests/unit/test_sanitize.py +99 -0
- tests/unit/test_schema_loader.py +295 -0
- tests/unit/test_shell_tools.py +229 -0
- tests/unit/test_state_builder.py +331 -0
- tests/unit/test_state_builder_map.py +104 -0
- tests/unit/test_state_config.py +197 -0
- tests/unit/test_streaming.py +307 -0
- tests/unit/test_subgraph.py +596 -0
- tests/unit/test_template.py +190 -0
- tests/unit/test_tool_call_integration.py +164 -0
- tests/unit/test_tool_call_node.py +178 -0
- tests/unit/test_tool_nodes.py +129 -0
- tests/unit/test_websearch.py +234 -0
- yamlgraph/__init__.py +35 -0
- yamlgraph/builder.py +110 -0
- yamlgraph/cli/__init__.py +159 -0
- yamlgraph/cli/__main__.py +6 -0
- yamlgraph/cli/commands.py +231 -0
- yamlgraph/cli/deprecation.py +92 -0
- yamlgraph/cli/graph_commands.py +541 -0
- yamlgraph/cli/validators.py +37 -0
- yamlgraph/config.py +67 -0
- yamlgraph/constants.py +70 -0
- yamlgraph/error_handlers.py +227 -0
- yamlgraph/executor.py +290 -0
- yamlgraph/executor_async.py +288 -0
- yamlgraph/graph_loader.py +451 -0
- yamlgraph/map_compiler.py +150 -0
- yamlgraph/models/__init__.py +36 -0
- yamlgraph/models/graph_schema.py +181 -0
- yamlgraph/models/schemas.py +124 -0
- yamlgraph/models/state_builder.py +236 -0
- yamlgraph/node_factory.py +768 -0
- yamlgraph/routing.py +87 -0
- yamlgraph/schema_loader.py +240 -0
- yamlgraph/storage/__init__.py +20 -0
- yamlgraph/storage/checkpointer.py +72 -0
- yamlgraph/storage/checkpointer_factory.py +123 -0
- yamlgraph/storage/database.py +320 -0
- yamlgraph/storage/export.py +269 -0
- yamlgraph/tools/__init__.py +1 -0
- yamlgraph/tools/agent.py +320 -0
- yamlgraph/tools/graph_linter.py +388 -0
- yamlgraph/tools/langsmith_tools.py +125 -0
- yamlgraph/tools/nodes.py +126 -0
- yamlgraph/tools/python_tool.py +179 -0
- yamlgraph/tools/shell.py +205 -0
- yamlgraph/tools/websearch.py +242 -0
- yamlgraph/utils/__init__.py +48 -0
- yamlgraph/utils/conditions.py +157 -0
- yamlgraph/utils/expressions.py +245 -0
- yamlgraph/utils/json_extract.py +104 -0
- yamlgraph/utils/langsmith.py +416 -0
- yamlgraph/utils/llm_factory.py +118 -0
- yamlgraph/utils/llm_factory_async.py +105 -0
- yamlgraph/utils/logging.py +104 -0
- yamlgraph/utils/prompts.py +171 -0
- yamlgraph/utils/sanitize.py +98 -0
- yamlgraph/utils/template.py +102 -0
- yamlgraph/utils/validators.py +181 -0
- yamlgraph-0.3.9.dist-info/METADATA +1105 -0
- yamlgraph-0.3.9.dist-info/RECORD +185 -0
- yamlgraph-0.3.9.dist-info/WHEEL +5 -0
- yamlgraph-0.3.9.dist-info/entry_points.txt +2 -0
- yamlgraph-0.3.9.dist-info/licenses/LICENSE +33 -0
- 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
|