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,348 @@
|
|
|
1
|
+
"""Tests for node factory - class resolution, template resolution, and node creation.
|
|
2
|
+
|
|
3
|
+
Split from test_graph_loader.py for better organization and file size management.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from unittest.mock import patch
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from tests.conftest import FixtureGeneratedContent
|
|
11
|
+
from yamlgraph.node_factory import (
|
|
12
|
+
create_node_function,
|
|
13
|
+
resolve_class,
|
|
14
|
+
resolve_template,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
# =============================================================================
|
|
18
|
+
# Fixtures
|
|
19
|
+
# =============================================================================
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture
|
|
23
|
+
def sample_state():
|
|
24
|
+
"""Sample pipeline state."""
|
|
25
|
+
return {
|
|
26
|
+
"thread_id": "test-123",
|
|
27
|
+
"topic": "machine learning",
|
|
28
|
+
"style": "informative",
|
|
29
|
+
"word_count": 300,
|
|
30
|
+
"generated": None,
|
|
31
|
+
"analysis": None,
|
|
32
|
+
"final_summary": None,
|
|
33
|
+
"current_step": "init",
|
|
34
|
+
"error": None,
|
|
35
|
+
"errors": [],
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.fixture
|
|
40
|
+
def state_with_generated(sample_state):
|
|
41
|
+
"""State with generated content."""
|
|
42
|
+
state = dict(sample_state)
|
|
43
|
+
state["generated"] = FixtureGeneratedContent(
|
|
44
|
+
title="Test Title",
|
|
45
|
+
content="Test content about ML.",
|
|
46
|
+
word_count=50,
|
|
47
|
+
tags=["test"],
|
|
48
|
+
)
|
|
49
|
+
return state
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# =============================================================================
|
|
53
|
+
# TestResolveClass
|
|
54
|
+
# =============================================================================
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TestResolveClass:
|
|
58
|
+
"""Tests for dynamic class importing."""
|
|
59
|
+
|
|
60
|
+
def test_resolve_existing_class(self):
|
|
61
|
+
"""Import a real class from dotted path."""
|
|
62
|
+
cls = resolve_class("yamlgraph.models.GenericReport")
|
|
63
|
+
# Just verify it resolves to a class with expected attributes
|
|
64
|
+
assert cls is not None
|
|
65
|
+
assert hasattr(cls, "model_fields") # Pydantic model check
|
|
66
|
+
|
|
67
|
+
def test_resolve_state_class(self):
|
|
68
|
+
"""Dynamic state class can be built."""
|
|
69
|
+
from yamlgraph.models.state_builder import build_state_class
|
|
70
|
+
|
|
71
|
+
cls = build_state_class({"nodes": {}})
|
|
72
|
+
# Dynamic state is a TypedDict
|
|
73
|
+
assert cls is not None
|
|
74
|
+
assert hasattr(cls, "__annotations__")
|
|
75
|
+
|
|
76
|
+
def test_resolve_invalid_module_raises(self):
|
|
77
|
+
"""Invalid module raises ImportError."""
|
|
78
|
+
with pytest.raises((ImportError, ModuleNotFoundError)):
|
|
79
|
+
resolve_class("nonexistent.module.Class")
|
|
80
|
+
|
|
81
|
+
def test_resolve_invalid_class_raises(self):
|
|
82
|
+
"""Invalid class name raises AttributeError."""
|
|
83
|
+
with pytest.raises(AttributeError):
|
|
84
|
+
resolve_class("yamlgraph.models.NonexistentClass")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# =============================================================================
|
|
88
|
+
# TestResolveTemplate
|
|
89
|
+
# =============================================================================
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class TestResolveTemplate:
|
|
93
|
+
"""Tests for template resolution against state."""
|
|
94
|
+
|
|
95
|
+
def test_simple_state_access(self, sample_state):
|
|
96
|
+
"""'{state.topic}' resolves to state['topic']."""
|
|
97
|
+
result = resolve_template("{state.topic}", sample_state)
|
|
98
|
+
assert result == "machine learning"
|
|
99
|
+
|
|
100
|
+
def test_nested_state_access(self, state_with_generated):
|
|
101
|
+
"""'{state.generated.content}' resolves nested attrs."""
|
|
102
|
+
result = resolve_template("{state.generated.content}", state_with_generated)
|
|
103
|
+
assert result == "Test content about ML."
|
|
104
|
+
|
|
105
|
+
def test_missing_state_returns_none(self, sample_state):
|
|
106
|
+
"""Missing state key returns None."""
|
|
107
|
+
result = resolve_template("{state.generated.content}", sample_state)
|
|
108
|
+
assert result is None
|
|
109
|
+
|
|
110
|
+
def test_literal_string_unchanged(self, sample_state):
|
|
111
|
+
"""Non-template strings returned as-is."""
|
|
112
|
+
result = resolve_template("literal value", sample_state)
|
|
113
|
+
assert result == "literal value"
|
|
114
|
+
|
|
115
|
+
def test_int_access(self, sample_state):
|
|
116
|
+
"""Integer values resolved correctly."""
|
|
117
|
+
result = resolve_template("{state.word_count}", sample_state)
|
|
118
|
+
assert result == 300
|
|
119
|
+
|
|
120
|
+
def test_list_access(self, state_with_generated):
|
|
121
|
+
"""List values resolved correctly."""
|
|
122
|
+
result = resolve_template("{state.generated.tags}", state_with_generated)
|
|
123
|
+
assert result == ["test"]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# =============================================================================
|
|
127
|
+
# TestCreateNodeFunction
|
|
128
|
+
# =============================================================================
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class TestCreateNodeFunction:
|
|
132
|
+
"""Tests for node function factory."""
|
|
133
|
+
|
|
134
|
+
def test_node_calls_execute_prompt(self, sample_state):
|
|
135
|
+
"""Generated node calls execute_prompt with config."""
|
|
136
|
+
node_config = {
|
|
137
|
+
"type": "llm",
|
|
138
|
+
"prompt": "generate",
|
|
139
|
+
"output_model": "yamlgraph.models.GenericReport",
|
|
140
|
+
"temperature": 0.8,
|
|
141
|
+
"variables": {"topic": "{state.topic}"},
|
|
142
|
+
"state_key": "generated",
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
mock_result = FixtureGeneratedContent(
|
|
146
|
+
title="Test",
|
|
147
|
+
content="Content",
|
|
148
|
+
word_count=100,
|
|
149
|
+
tags=[],
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
with patch(
|
|
153
|
+
"yamlgraph.node_factory.execute_prompt", return_value=mock_result
|
|
154
|
+
) as mock:
|
|
155
|
+
node_fn = create_node_function(
|
|
156
|
+
"generate", node_config, {"provider": "mistral"}
|
|
157
|
+
)
|
|
158
|
+
result = node_fn(sample_state)
|
|
159
|
+
|
|
160
|
+
mock.assert_called_once()
|
|
161
|
+
call_kwargs = mock.call_args
|
|
162
|
+
assert call_kwargs[1]["prompt_name"] == "generate"
|
|
163
|
+
assert call_kwargs[1]["temperature"] == 0.8
|
|
164
|
+
assert call_kwargs[1]["variables"]["topic"] == "machine learning"
|
|
165
|
+
|
|
166
|
+
assert result["generated"] == mock_result
|
|
167
|
+
assert result["current_step"] == "generate"
|
|
168
|
+
|
|
169
|
+
def test_node_checks_requirements(self, sample_state):
|
|
170
|
+
"""Node returns error if requires not met."""
|
|
171
|
+
node_config = {
|
|
172
|
+
"type": "llm",
|
|
173
|
+
"prompt": "analyze",
|
|
174
|
+
"variables": {},
|
|
175
|
+
"state_key": "analysis",
|
|
176
|
+
"requires": ["generated"], # generated is None in sample_state
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
node_fn = create_node_function("analyze", node_config, {})
|
|
180
|
+
result = node_fn(sample_state)
|
|
181
|
+
|
|
182
|
+
assert result.get("errors")
|
|
183
|
+
assert "generated" in result["errors"][0].message
|
|
184
|
+
|
|
185
|
+
def test_node_handles_exception(self, sample_state):
|
|
186
|
+
"""Exceptions become PipelineError."""
|
|
187
|
+
node_config = {
|
|
188
|
+
"type": "llm",
|
|
189
|
+
"prompt": "generate",
|
|
190
|
+
"variables": {"topic": "{state.topic}"},
|
|
191
|
+
"state_key": "generated",
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
with patch(
|
|
195
|
+
"yamlgraph.node_factory.execute_prompt", side_effect=ValueError("API Error")
|
|
196
|
+
):
|
|
197
|
+
node_fn = create_node_function("generate", node_config, {})
|
|
198
|
+
result = node_fn(sample_state)
|
|
199
|
+
|
|
200
|
+
assert result.get("errors")
|
|
201
|
+
assert "API Error" in result["errors"][0].message
|
|
202
|
+
|
|
203
|
+
def test_node_uses_defaults(self, sample_state):
|
|
204
|
+
"""Node uses default provider/temperature from config."""
|
|
205
|
+
node_config = {
|
|
206
|
+
"type": "llm",
|
|
207
|
+
"prompt": "generate",
|
|
208
|
+
"variables": {},
|
|
209
|
+
"state_key": "generated",
|
|
210
|
+
# No temperature specified - should use default
|
|
211
|
+
}
|
|
212
|
+
defaults = {"provider": "anthropic", "temperature": 0.5}
|
|
213
|
+
|
|
214
|
+
mock_result = FixtureGeneratedContent(
|
|
215
|
+
title="T", content="C", word_count=1, tags=[]
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
with patch(
|
|
219
|
+
"yamlgraph.node_factory.execute_prompt", return_value=mock_result
|
|
220
|
+
) as mock:
|
|
221
|
+
node_fn = create_node_function("generate", node_config, defaults)
|
|
222
|
+
node_fn(sample_state)
|
|
223
|
+
|
|
224
|
+
assert mock.call_args[1]["temperature"] == 0.5
|
|
225
|
+
assert mock.call_args[1]["provider"] == "anthropic"
|
|
226
|
+
|
|
227
|
+
def test_node_uses_graph_relative_prompts(self, sample_state, tmp_path):
|
|
228
|
+
"""Node uses graph_path for relative prompt resolution."""
|
|
229
|
+
|
|
230
|
+
# Create colocated graph+prompts structure
|
|
231
|
+
graph_dir = tmp_path / "questionnaires" / "audit"
|
|
232
|
+
prompts_dir = graph_dir / "prompts"
|
|
233
|
+
prompts_dir.mkdir(parents=True)
|
|
234
|
+
|
|
235
|
+
graph_file = graph_dir / "graph.yaml"
|
|
236
|
+
graph_file.write_text("name: audit")
|
|
237
|
+
|
|
238
|
+
prompt_file = prompts_dir / "opening.yaml"
|
|
239
|
+
prompt_file.write_text("system: Welcome\nuser: Start")
|
|
240
|
+
|
|
241
|
+
node_config = {
|
|
242
|
+
"type": "llm",
|
|
243
|
+
"prompt": "prompts/opening",
|
|
244
|
+
"variables": {},
|
|
245
|
+
"state_key": "opening",
|
|
246
|
+
}
|
|
247
|
+
defaults = {"prompts_relative": True}
|
|
248
|
+
|
|
249
|
+
mock_result = FixtureGeneratedContent(
|
|
250
|
+
title="T", content="C", word_count=1, tags=[]
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
with patch(
|
|
254
|
+
"yamlgraph.node_factory.execute_prompt", return_value=mock_result
|
|
255
|
+
) as mock:
|
|
256
|
+
node_fn = create_node_function(
|
|
257
|
+
"generate_opening",
|
|
258
|
+
node_config,
|
|
259
|
+
defaults,
|
|
260
|
+
graph_path=graph_file,
|
|
261
|
+
)
|
|
262
|
+
result = node_fn(sample_state)
|
|
263
|
+
|
|
264
|
+
# Prompt should have been resolved
|
|
265
|
+
mock.assert_called_once()
|
|
266
|
+
assert result["opening"] == mock_result
|
|
267
|
+
|
|
268
|
+
def test_node_uses_explicit_prompts_dir(self, sample_state, tmp_path):
|
|
269
|
+
"""Node uses explicit prompts_dir from defaults."""
|
|
270
|
+
|
|
271
|
+
# Create explicit prompts directory
|
|
272
|
+
shared_prompts = tmp_path / "shared" / "prompts"
|
|
273
|
+
shared_prompts.mkdir(parents=True)
|
|
274
|
+
|
|
275
|
+
prompt_file = shared_prompts / "greet.yaml"
|
|
276
|
+
prompt_file.write_text("system: Hi\nuser: Hello")
|
|
277
|
+
|
|
278
|
+
node_config = {
|
|
279
|
+
"type": "llm",
|
|
280
|
+
"prompt": "greet",
|
|
281
|
+
"variables": {},
|
|
282
|
+
"state_key": "greeting",
|
|
283
|
+
}
|
|
284
|
+
defaults = {"prompts_dir": str(shared_prompts)}
|
|
285
|
+
|
|
286
|
+
mock_result = FixtureGeneratedContent(
|
|
287
|
+
title="T", content="C", word_count=1, tags=[]
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
with patch(
|
|
291
|
+
"yamlgraph.node_factory.execute_prompt", return_value=mock_result
|
|
292
|
+
) as mock:
|
|
293
|
+
node_fn = create_node_function(
|
|
294
|
+
"greet",
|
|
295
|
+
node_config,
|
|
296
|
+
defaults,
|
|
297
|
+
)
|
|
298
|
+
result = node_fn(sample_state)
|
|
299
|
+
|
|
300
|
+
mock.assert_called_once()
|
|
301
|
+
assert result["greeting"] == mock_result
|
|
302
|
+
|
|
303
|
+
def test_node_parse_json_enabled(self, sample_state):
|
|
304
|
+
"""Node with parse_json: true extracts JSON from response."""
|
|
305
|
+
node_config = {
|
|
306
|
+
"type": "llm",
|
|
307
|
+
"prompt": "generate",
|
|
308
|
+
"variables": {},
|
|
309
|
+
"state_key": "extracted",
|
|
310
|
+
"parse_json": True,
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
# Simulate LLM returning JSON in markdown
|
|
314
|
+
mock_result = '''```json
|
|
315
|
+
{"name": "test", "value": 42}
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
Reasoning: I extracted the name and value.
|
|
319
|
+
'''
|
|
320
|
+
with patch(
|
|
321
|
+
"yamlgraph.node_factory.execute_prompt", return_value=mock_result
|
|
322
|
+
):
|
|
323
|
+
node_fn = create_node_function("extract", node_config, {})
|
|
324
|
+
result = node_fn(sample_state)
|
|
325
|
+
|
|
326
|
+
# Should be parsed dict, not raw string
|
|
327
|
+
assert result["extracted"] == {"name": "test", "value": 42}
|
|
328
|
+
|
|
329
|
+
def test_node_parse_json_disabled_by_default(self, sample_state):
|
|
330
|
+
"""Node without parse_json returns raw string."""
|
|
331
|
+
node_config = {
|
|
332
|
+
"type": "llm",
|
|
333
|
+
"prompt": "generate",
|
|
334
|
+
"variables": {},
|
|
335
|
+
"state_key": "raw",
|
|
336
|
+
# parse_json not specified - defaults to False
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
mock_result = '{"key": "value"}'
|
|
340
|
+
|
|
341
|
+
with patch(
|
|
342
|
+
"yamlgraph.node_factory.execute_prompt", return_value=mock_result
|
|
343
|
+
):
|
|
344
|
+
node_fn = create_node_function("raw_node", node_config, {})
|
|
345
|
+
result = node_fn(sample_state)
|
|
346
|
+
|
|
347
|
+
# Should be raw string (though it's valid JSON, we don't auto-parse)
|
|
348
|
+
assert result["raw"] == '{"key": "value"}'
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Unit tests for passthrough node functionality."""
|
|
2
|
+
|
|
3
|
+
from yamlgraph.node_factory import create_passthrough_node
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TestPassthroughNode:
|
|
7
|
+
"""Tests for create_passthrough_node."""
|
|
8
|
+
|
|
9
|
+
def test_increment_counter(self):
|
|
10
|
+
"""Test basic counter increment."""
|
|
11
|
+
config = {
|
|
12
|
+
"output": {
|
|
13
|
+
"counter": "{state.counter + 1}",
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
node_fn = create_passthrough_node("next_turn", config)
|
|
17
|
+
|
|
18
|
+
state = {"counter": 5}
|
|
19
|
+
result = node_fn(state)
|
|
20
|
+
|
|
21
|
+
assert result["counter"] == 6
|
|
22
|
+
assert result["current_step"] == "next_turn"
|
|
23
|
+
|
|
24
|
+
def test_append_to_list(self):
|
|
25
|
+
"""Test appending item to list."""
|
|
26
|
+
config = {
|
|
27
|
+
"output": {
|
|
28
|
+
"history": "{state.history + [state.current]}",
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
node_fn = create_passthrough_node("save_history", config)
|
|
32
|
+
|
|
33
|
+
state = {"history": ["a", "b"], "current": "c"}
|
|
34
|
+
result = node_fn(state)
|
|
35
|
+
|
|
36
|
+
assert result["history"] == ["a", "b", "c"]
|
|
37
|
+
|
|
38
|
+
def test_append_dict_to_list(self):
|
|
39
|
+
"""Test appending dict to list using simple syntax."""
|
|
40
|
+
# Note: Complex dict literals not yet supported
|
|
41
|
+
# Use Python node for complex transformations
|
|
42
|
+
config = {
|
|
43
|
+
"output": {
|
|
44
|
+
"log": "{state.log + [state.entry]}",
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
node_fn = create_passthrough_node("log_action", config)
|
|
48
|
+
|
|
49
|
+
state = {"log": [], "entry": {"turn": 1, "action": "attack"}}
|
|
50
|
+
result = node_fn(state)
|
|
51
|
+
|
|
52
|
+
assert result["log"] == [{"turn": 1, "action": "attack"}]
|
|
53
|
+
|
|
54
|
+
def test_multiple_outputs(self):
|
|
55
|
+
"""Test multiple output fields."""
|
|
56
|
+
config = {
|
|
57
|
+
"output": {
|
|
58
|
+
"counter": "{state.counter + 1}",
|
|
59
|
+
"doubled": "{state.value * 2}",
|
|
60
|
+
"message": "{state.prefix + ': done'}",
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
node_fn = create_passthrough_node("transform", config)
|
|
64
|
+
|
|
65
|
+
state = {"counter": 0, "value": 5, "prefix": "Status"}
|
|
66
|
+
result = node_fn(state)
|
|
67
|
+
|
|
68
|
+
assert result["counter"] == 1
|
|
69
|
+
assert result["doubled"] == 10
|
|
70
|
+
assert result["message"] == "Status: done"
|
|
71
|
+
|
|
72
|
+
def test_empty_output(self):
|
|
73
|
+
"""Test passthrough with no output (just sets current_step)."""
|
|
74
|
+
config = {"output": {}}
|
|
75
|
+
node_fn = create_passthrough_node("noop", config)
|
|
76
|
+
|
|
77
|
+
state = {"x": 1}
|
|
78
|
+
result = node_fn(state)
|
|
79
|
+
|
|
80
|
+
assert result["current_step"] == "noop"
|
|
81
|
+
assert "x" not in result # Doesn't copy state
|
|
82
|
+
|
|
83
|
+
def test_no_output_key(self):
|
|
84
|
+
"""Test passthrough with missing output key."""
|
|
85
|
+
config = {} # No output defined
|
|
86
|
+
node_fn = create_passthrough_node("minimal", config)
|
|
87
|
+
|
|
88
|
+
state = {"x": 1}
|
|
89
|
+
result = node_fn(state)
|
|
90
|
+
|
|
91
|
+
assert result["current_step"] == "minimal"
|
|
92
|
+
|
|
93
|
+
def test_error_keeps_original_value(self):
|
|
94
|
+
"""Test that errors preserve original state values."""
|
|
95
|
+
config = {
|
|
96
|
+
"output": {
|
|
97
|
+
"result": "{state.undefined_field + 1}", # Will fail
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
node_fn = create_passthrough_node("error_test", config)
|
|
101
|
+
|
|
102
|
+
state = {"result": 42} # Has existing value, but undefined_field missing
|
|
103
|
+
result = node_fn(state)
|
|
104
|
+
|
|
105
|
+
# Should keep original value on error (undefined_field resolves to None)
|
|
106
|
+
# When left operand is None, we keep original
|
|
107
|
+
assert result["result"] == 42
|
|
108
|
+
|
|
109
|
+
def test_function_name(self):
|
|
110
|
+
"""Test that function has descriptive name."""
|
|
111
|
+
config = {"output": {}}
|
|
112
|
+
node_fn = create_passthrough_node("my_node", config)
|
|
113
|
+
|
|
114
|
+
assert node_fn.__name__ == "my_node_passthrough"
|
|
115
|
+
|
|
116
|
+
def test_string_concatenation(self):
|
|
117
|
+
"""Test string concatenation with + operator."""
|
|
118
|
+
config = {
|
|
119
|
+
"output": {
|
|
120
|
+
"message": "{state.prefix + state.suffix}",
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
node_fn = create_passthrough_node("concat", config)
|
|
124
|
+
|
|
125
|
+
result = node_fn({"prefix": "Hello, ", "suffix": "World!"})
|
|
126
|
+
assert result["message"] == "Hello, World!"
|