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
|
+
"""Unit tests for interrupt node functionality.
|
|
2
|
+
|
|
3
|
+
TDD tests for 001: Interrupt Node feature.
|
|
4
|
+
Tests create_interrupt_node() and interrupt YAML handling.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from unittest.mock import patch
|
|
8
|
+
|
|
9
|
+
from yamlgraph.constants import NodeType
|
|
10
|
+
from yamlgraph.node_factory import create_interrupt_node
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestNodeTypeInterrupt:
|
|
14
|
+
"""Test NodeType.INTERRUPT constant exists."""
|
|
15
|
+
|
|
16
|
+
def test_interrupt_constant_exists(self):
|
|
17
|
+
"""NodeType should have INTERRUPT constant."""
|
|
18
|
+
assert hasattr(NodeType, "INTERRUPT")
|
|
19
|
+
assert NodeType.INTERRUPT == "interrupt"
|
|
20
|
+
|
|
21
|
+
def test_interrupt_not_requires_prompt(self):
|
|
22
|
+
"""Interrupt nodes don't require prompt (can use message)."""
|
|
23
|
+
assert not NodeType.requires_prompt("interrupt")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TestCreateInterruptNode:
|
|
27
|
+
"""Test create_interrupt_node() factory function."""
|
|
28
|
+
|
|
29
|
+
def test_create_interrupt_node_with_static_message(self):
|
|
30
|
+
"""Interrupt node with static message should work."""
|
|
31
|
+
config = {
|
|
32
|
+
"message": "What is your name?",
|
|
33
|
+
"resume_key": "user_name",
|
|
34
|
+
}
|
|
35
|
+
node_fn = create_interrupt_node("ask_name", config)
|
|
36
|
+
assert callable(node_fn)
|
|
37
|
+
|
|
38
|
+
def test_create_interrupt_node_with_prompt(self):
|
|
39
|
+
"""Interrupt node with prompt should work."""
|
|
40
|
+
config = {
|
|
41
|
+
"prompt": "dialogue/generate_question",
|
|
42
|
+
"state_key": "pending_question",
|
|
43
|
+
"resume_key": "user_response",
|
|
44
|
+
}
|
|
45
|
+
node_fn = create_interrupt_node("ask_dynamic", config)
|
|
46
|
+
assert callable(node_fn)
|
|
47
|
+
|
|
48
|
+
@patch("langgraph.types.interrupt")
|
|
49
|
+
def test_interrupt_node_calls_native_interrupt(self, mock_interrupt):
|
|
50
|
+
"""Node should call LangGraph's native interrupt()."""
|
|
51
|
+
mock_interrupt.return_value = "Alice" # Simulates resume value
|
|
52
|
+
|
|
53
|
+
config = {"message": "What is your name?"}
|
|
54
|
+
node_fn = create_interrupt_node("ask_name", config)
|
|
55
|
+
|
|
56
|
+
state = {}
|
|
57
|
+
result = node_fn(state)
|
|
58
|
+
|
|
59
|
+
mock_interrupt.assert_called_once_with("What is your name?")
|
|
60
|
+
assert result["user_input"] == "Alice"
|
|
61
|
+
|
|
62
|
+
@patch("langgraph.types.interrupt")
|
|
63
|
+
def test_interrupt_node_stores_payload_in_state_key(self, mock_interrupt):
|
|
64
|
+
"""Payload should be stored in state_key for idempotency."""
|
|
65
|
+
mock_interrupt.return_value = "blue"
|
|
66
|
+
|
|
67
|
+
config = {
|
|
68
|
+
"message": "What is your favorite color?",
|
|
69
|
+
"state_key": "pending_question",
|
|
70
|
+
"resume_key": "color_choice",
|
|
71
|
+
}
|
|
72
|
+
node_fn = create_interrupt_node("ask_color", config)
|
|
73
|
+
|
|
74
|
+
state = {}
|
|
75
|
+
result = node_fn(state)
|
|
76
|
+
|
|
77
|
+
assert result["pending_question"] == "What is your favorite color?"
|
|
78
|
+
assert result["color_choice"] == "blue"
|
|
79
|
+
|
|
80
|
+
@patch("langgraph.types.interrupt")
|
|
81
|
+
def test_interrupt_node_idempotency_skips_prompt_on_resume(self, mock_interrupt):
|
|
82
|
+
"""When state_key exists, should not re-execute prompt."""
|
|
83
|
+
mock_interrupt.return_value = "resumed_value"
|
|
84
|
+
|
|
85
|
+
config = {
|
|
86
|
+
"prompt": "expensive/llm_call",
|
|
87
|
+
"state_key": "pending_question",
|
|
88
|
+
"resume_key": "user_response",
|
|
89
|
+
}
|
|
90
|
+
node_fn = create_interrupt_node("ask_dynamic", config)
|
|
91
|
+
|
|
92
|
+
# Simulate resume: state already has the payload
|
|
93
|
+
state = {"pending_question": "Previously generated question"}
|
|
94
|
+
|
|
95
|
+
with patch("yamlgraph.node_factory.execute_prompt") as mock_prompt:
|
|
96
|
+
result = node_fn(state)
|
|
97
|
+
|
|
98
|
+
# execute_prompt should NOT be called (idempotency)
|
|
99
|
+
mock_prompt.assert_not_called()
|
|
100
|
+
|
|
101
|
+
# Should use existing payload
|
|
102
|
+
mock_interrupt.assert_called_once_with("Previously generated question")
|
|
103
|
+
assert result["user_response"] == "resumed_value"
|
|
104
|
+
|
|
105
|
+
@patch("langgraph.types.interrupt")
|
|
106
|
+
@patch("yamlgraph.node_factory.execute_prompt")
|
|
107
|
+
def test_interrupt_node_with_prompt_calls_execute_prompt(
|
|
108
|
+
self, mock_execute_prompt, mock_interrupt
|
|
109
|
+
):
|
|
110
|
+
"""First execution with prompt should call execute_prompt."""
|
|
111
|
+
mock_execute_prompt.return_value = "Generated question from LLM"
|
|
112
|
+
mock_interrupt.return_value = "user answer"
|
|
113
|
+
|
|
114
|
+
config = {
|
|
115
|
+
"prompt": "dialogue/generate_question",
|
|
116
|
+
"state_key": "pending_question",
|
|
117
|
+
"resume_key": "user_response",
|
|
118
|
+
}
|
|
119
|
+
node_fn = create_interrupt_node("ask_dynamic", config)
|
|
120
|
+
|
|
121
|
+
state = {"context": "some context"}
|
|
122
|
+
result = node_fn(state)
|
|
123
|
+
|
|
124
|
+
mock_execute_prompt.assert_called_once()
|
|
125
|
+
mock_interrupt.assert_called_once_with("Generated question from LLM")
|
|
126
|
+
assert result["pending_question"] == "Generated question from LLM"
|
|
127
|
+
assert result["user_response"] == "user answer"
|
|
128
|
+
|
|
129
|
+
@patch("langgraph.types.interrupt")
|
|
130
|
+
def test_interrupt_node_sets_current_step(self, mock_interrupt):
|
|
131
|
+
"""Result should include current_step for tracking."""
|
|
132
|
+
mock_interrupt.return_value = "answer"
|
|
133
|
+
|
|
134
|
+
config = {"message": "Question?"}
|
|
135
|
+
node_fn = create_interrupt_node("my_node", config)
|
|
136
|
+
|
|
137
|
+
result = node_fn({})
|
|
138
|
+
|
|
139
|
+
assert result["current_step"] == "my_node"
|
|
140
|
+
|
|
141
|
+
def test_interrupt_node_default_keys(self):
|
|
142
|
+
"""Default state_key and resume_key should be used if not specified."""
|
|
143
|
+
config = {"message": "Question?"}
|
|
144
|
+
node_fn = create_interrupt_node("ask", config)
|
|
145
|
+
|
|
146
|
+
# Just verify it creates without error
|
|
147
|
+
assert callable(node_fn)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class TestInterruptNodeEdgeCases:
|
|
151
|
+
"""Edge cases for interrupt node handling."""
|
|
152
|
+
|
|
153
|
+
@patch("langgraph.types.interrupt")
|
|
154
|
+
def test_interrupt_with_dict_payload(self, mock_interrupt):
|
|
155
|
+
"""Interrupt should support dict payloads for structured questions."""
|
|
156
|
+
mock_interrupt.return_value = {"choice": "A", "reason": "because"}
|
|
157
|
+
|
|
158
|
+
config = {
|
|
159
|
+
"message": {"question": "Pick A or B", "options": ["A", "B"]},
|
|
160
|
+
"resume_key": "user_choice",
|
|
161
|
+
}
|
|
162
|
+
node_fn = create_interrupt_node("multi_choice", config)
|
|
163
|
+
|
|
164
|
+
result = node_fn({})
|
|
165
|
+
|
|
166
|
+
mock_interrupt.assert_called_once_with(
|
|
167
|
+
{"question": "Pick A or B", "options": ["A", "B"]}
|
|
168
|
+
)
|
|
169
|
+
assert result["user_choice"] == {"choice": "A", "reason": "because"}
|
|
170
|
+
|
|
171
|
+
@patch("langgraph.types.interrupt")
|
|
172
|
+
def test_interrupt_node_no_message_uses_node_name(self, mock_interrupt):
|
|
173
|
+
"""If no message or prompt, use node name as fallback payload."""
|
|
174
|
+
mock_interrupt.return_value = "answer"
|
|
175
|
+
|
|
176
|
+
config = {} # No message, no prompt
|
|
177
|
+
node_fn = create_interrupt_node("approval_gate", config)
|
|
178
|
+
|
|
179
|
+
_result = node_fn({})
|
|
180
|
+
|
|
181
|
+
# Should use {"node": "approval_gate"} as fallback
|
|
182
|
+
mock_interrupt.assert_called_once_with({"node": "approval_gate"})
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Tests for issues that were identified and fixed.
|
|
2
|
+
|
|
3
|
+
These tests verify the fixes for issues documented in docs/open-issues.md.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from unittest.mock import patch
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from tests.conftest import FixtureAnalysis, FixtureGeneratedContent
|
|
11
|
+
from yamlgraph.builder import build_resume_graph
|
|
12
|
+
from yamlgraph.graph_loader import load_graph_config
|
|
13
|
+
from yamlgraph.models import create_initial_state
|
|
14
|
+
|
|
15
|
+
# =============================================================================
|
|
16
|
+
# Issue 1: Resume Logic - FIXED: skip_if_exists behavior
|
|
17
|
+
# =============================================================================
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TestResumeStartFromParameter:
|
|
21
|
+
"""Issue 1: Resume should skip nodes whose output already exists."""
|
|
22
|
+
|
|
23
|
+
@patch("yamlgraph.node_factory.execute_prompt")
|
|
24
|
+
def test_resume_from_analyze_skips_generate(self, mock_execute):
|
|
25
|
+
"""When state has 'generated', generate node should be skipped.
|
|
26
|
+
|
|
27
|
+
Resume works via skip_if_exists: if output already in state, skip LLM call.
|
|
28
|
+
"""
|
|
29
|
+
# State with generated content already present
|
|
30
|
+
state = create_initial_state(topic="test", thread_id="issue1")
|
|
31
|
+
state["generated"] = FixtureGeneratedContent(
|
|
32
|
+
title="Already Generated",
|
|
33
|
+
content="This was generated in a previous run",
|
|
34
|
+
word_count=10,
|
|
35
|
+
tags=[],
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Only mock analyze and summarize - generate should be skipped
|
|
39
|
+
mock_analysis = FixtureAnalysis(
|
|
40
|
+
summary="Analysis",
|
|
41
|
+
key_points=["Point"],
|
|
42
|
+
sentiment="neutral",
|
|
43
|
+
confidence=0.8,
|
|
44
|
+
)
|
|
45
|
+
mock_execute.side_effect = [mock_analysis, "Final summary"]
|
|
46
|
+
|
|
47
|
+
graph = build_resume_graph().compile()
|
|
48
|
+
result = graph.invoke(state)
|
|
49
|
+
|
|
50
|
+
# Expected: 2 calls (analyze, summarize) - generate skipped
|
|
51
|
+
assert mock_execute.call_count == 2, (
|
|
52
|
+
f"Expected 2 LLM calls (analyze, summarize), "
|
|
53
|
+
f"but got {mock_execute.call_count}. "
|
|
54
|
+
f"Generate should be skipped when 'generated' exists!"
|
|
55
|
+
)
|
|
56
|
+
# Original generated content should be preserved
|
|
57
|
+
assert result["generated"].title == "Already Generated"
|
|
58
|
+
|
|
59
|
+
@patch("yamlgraph.node_factory.execute_prompt")
|
|
60
|
+
def test_resume_from_summarize_skips_generate_and_analyze(self, mock_execute):
|
|
61
|
+
"""When state has 'generated' and 'analysis', only summarize runs."""
|
|
62
|
+
state = create_initial_state(topic="test", thread_id="issue1b")
|
|
63
|
+
state["generated"] = FixtureGeneratedContent(
|
|
64
|
+
title="Done",
|
|
65
|
+
content="Content",
|
|
66
|
+
word_count=5,
|
|
67
|
+
tags=[],
|
|
68
|
+
)
|
|
69
|
+
state["analysis"] = FixtureAnalysis(
|
|
70
|
+
summary="Done",
|
|
71
|
+
key_points=["Point"],
|
|
72
|
+
sentiment="positive",
|
|
73
|
+
confidence=0.9,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
mock_execute.return_value = "Final summary"
|
|
77
|
+
|
|
78
|
+
graph = build_resume_graph().compile()
|
|
79
|
+
result = graph.invoke(state)
|
|
80
|
+
|
|
81
|
+
# Expected: 1 call (summarize only)
|
|
82
|
+
assert mock_execute.call_count == 1, (
|
|
83
|
+
f"Expected 1 LLM call (summarize only), "
|
|
84
|
+
f"but got {mock_execute.call_count}. "
|
|
85
|
+
f"Generate and analyze should be skipped!"
|
|
86
|
+
)
|
|
87
|
+
# Original content should be preserved
|
|
88
|
+
assert result["generated"].title == "Done"
|
|
89
|
+
assert result["analysis"].summary == "Done"
|
|
90
|
+
|
|
91
|
+
def test_resume_preserves_existing_generated_content(self):
|
|
92
|
+
"""Resuming should NOT overwrite already-generated content."""
|
|
93
|
+
# Covered by test_resume_from_analyze_skips_generate
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# =============================================================================
|
|
98
|
+
# Issue 2: Conditions Block is Dead Config
|
|
99
|
+
# =============================================================================
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TestConditionsFromYAML:
|
|
103
|
+
"""Issue 2: Conditions block was dead config - now uses expression routing."""
|
|
104
|
+
|
|
105
|
+
def test_conditions_block_not_in_schema(self):
|
|
106
|
+
"""GraphConfig no longer parses conditions block."""
|
|
107
|
+
from yamlgraph.config import DEFAULT_GRAPH
|
|
108
|
+
|
|
109
|
+
config = load_graph_config(DEFAULT_GRAPH)
|
|
110
|
+
|
|
111
|
+
# conditions attribute should not exist
|
|
112
|
+
assert not hasattr(
|
|
113
|
+
config, "conditions"
|
|
114
|
+
), "GraphConfig should not have 'conditions' attribute - it's dead config"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# =============================================================================
|
|
118
|
+
# Issue 5: _entry_point hack
|
|
119
|
+
# =============================================================================
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class TestEntryPointHack:
|
|
123
|
+
"""Issue 5: Using private _entry_point is fragile."""
|
|
124
|
+
|
|
125
|
+
@pytest.fixture
|
|
126
|
+
def simple_yaml(self, tmp_path):
|
|
127
|
+
"""Minimal YAML for testing."""
|
|
128
|
+
yaml_content = """
|
|
129
|
+
version: "1.0"
|
|
130
|
+
name: test
|
|
131
|
+
nodes:
|
|
132
|
+
first:
|
|
133
|
+
type: llm
|
|
134
|
+
prompt: generate
|
|
135
|
+
output_model: yamlgraph.models.GenericReport
|
|
136
|
+
state_key: generated
|
|
137
|
+
edges:
|
|
138
|
+
- from: START
|
|
139
|
+
to: first
|
|
140
|
+
- from: first
|
|
141
|
+
to: END
|
|
142
|
+
"""
|
|
143
|
+
yaml_file = tmp_path / "test.yaml"
|
|
144
|
+
yaml_file.write_text(yaml_content)
|
|
145
|
+
return yaml_file
|
|
146
|
+
|
|
147
|
+
def test_entry_point_accessible_via_behavior(self, simple_yaml):
|
|
148
|
+
"""Entry point should be testable via graph behavior, not private attrs.
|
|
149
|
+
|
|
150
|
+
Currently graph_loader.py sets graph._entry_point for testing.
|
|
151
|
+
This test shows how to test entry point via behavior instead.
|
|
152
|
+
"""
|
|
153
|
+
from yamlgraph.graph_loader import load_and_compile
|
|
154
|
+
|
|
155
|
+
graph = load_and_compile(simple_yaml)
|
|
156
|
+
_ = graph.compile() # Verify it compiles
|
|
157
|
+
|
|
158
|
+
# Get the graph structure - this is the proper way
|
|
159
|
+
# The first node after START should be 'first'
|
|
160
|
+
nodes = list(graph.nodes.keys())
|
|
161
|
+
assert "first" in nodes
|
|
162
|
+
|
|
163
|
+
# We can also check by looking at edges from __start__
|
|
164
|
+
# But testing via invocation is more robust
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Integration test for Jinja2 prompt templates."""
|
|
2
|
+
|
|
3
|
+
from yamlgraph.executor import format_prompt, load_prompt
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_jinja2_analyze_list_prompt():
|
|
7
|
+
"""Test the analyze_list prompt with Jinja2 features."""
|
|
8
|
+
prompt = load_prompt("analyze_list")
|
|
9
|
+
|
|
10
|
+
# Test data
|
|
11
|
+
variables = {
|
|
12
|
+
"items": [
|
|
13
|
+
{
|
|
14
|
+
"title": "Introduction to AI",
|
|
15
|
+
"topic": "Artificial Intelligence",
|
|
16
|
+
"word_count": 500,
|
|
17
|
+
"tags": ["AI", "machine learning", "technology"],
|
|
18
|
+
"content": "Artificial intelligence is transforming how we interact with technology...",
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"title": "Machine Learning Basics",
|
|
22
|
+
"topic": "ML Fundamentals",
|
|
23
|
+
"word_count": 750,
|
|
24
|
+
"tags": ["ML", "algorithms", "data"],
|
|
25
|
+
"content": "Machine learning involves training models on data to make predictions...",
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
"min_confidence": 0.8,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# Format the template field
|
|
32
|
+
result = format_prompt(prompt["template"], variables)
|
|
33
|
+
|
|
34
|
+
# Verify Jinja2 features are working
|
|
35
|
+
assert "2 items" in result # {{ items|length }} filter
|
|
36
|
+
assert "1. Introduction to AI" in result # {{ loop.index }}
|
|
37
|
+
assert "2. Machine Learning Basics" in result
|
|
38
|
+
assert "**Tags**: AI, machine learning, technology" in result # join filter
|
|
39
|
+
assert "**Tags**: ML, algorithms, data" in result
|
|
40
|
+
assert "confidence >= 0.8" in result # conditional rendering
|
|
41
|
+
assert "**Content**:" in result # if/else conditional
|
|
42
|
+
|
|
43
|
+
# Verify loop counter
|
|
44
|
+
assert "### 1." in result
|
|
45
|
+
assert "### 2." in result
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_jinja2_prompt_with_empty_list():
|
|
49
|
+
"""Test analyze_list prompt with empty items."""
|
|
50
|
+
prompt = load_prompt("analyze_list")
|
|
51
|
+
|
|
52
|
+
variables = {"items": [], "min_confidence": None}
|
|
53
|
+
|
|
54
|
+
result = format_prompt(prompt["template"], variables)
|
|
55
|
+
|
|
56
|
+
# Should handle empty list gracefully
|
|
57
|
+
assert "0 items" in result
|
|
58
|
+
assert "### 1." not in result # No items to iterate
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_jinja2_prompt_without_optional_fields():
|
|
62
|
+
"""Test analyze_list prompt without optional fields."""
|
|
63
|
+
prompt = load_prompt("analyze_list")
|
|
64
|
+
|
|
65
|
+
variables = {
|
|
66
|
+
"items": [
|
|
67
|
+
{
|
|
68
|
+
"title": "Short Content",
|
|
69
|
+
"topic": "Brief",
|
|
70
|
+
"word_count": 100,
|
|
71
|
+
"tags": [], # Empty tags
|
|
72
|
+
"content": "Short content without tags",
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
result = format_prompt(prompt["template"], variables)
|
|
78
|
+
|
|
79
|
+
# Should handle missing/empty optional fields
|
|
80
|
+
assert "1 items" in result
|
|
81
|
+
assert "Short Content" in result
|
|
82
|
+
# Should not show tags section if empty
|
|
83
|
+
assert "**Tags**:" not in result or "**Tags**: \n" in result
|
|
84
|
+
# Should not show min_confidence note if not provided
|
|
85
|
+
assert "confidence >=" not in result
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Tests for JSON extraction from LLM output (FR-B)."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
from yamlgraph.utils.json_extract import extract_json
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TestExtractJson:
|
|
8
|
+
"""Tests for extract_json utility."""
|
|
9
|
+
|
|
10
|
+
def test_extract_raw_json_object(self):
|
|
11
|
+
"""Should parse raw JSON object."""
|
|
12
|
+
text = '{"name": "test", "value": 42}'
|
|
13
|
+
result = extract_json(text)
|
|
14
|
+
|
|
15
|
+
assert result == {"name": "test", "value": 42}
|
|
16
|
+
|
|
17
|
+
def test_extract_raw_json_array(self):
|
|
18
|
+
"""Should parse raw JSON array."""
|
|
19
|
+
text = '[1, 2, 3]'
|
|
20
|
+
result = extract_json(text)
|
|
21
|
+
|
|
22
|
+
assert result == [1, 2, 3]
|
|
23
|
+
|
|
24
|
+
def test_extract_json_codeblock(self):
|
|
25
|
+
"""Should extract JSON from ```json ... ``` block."""
|
|
26
|
+
text = '''Here is the result:
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{"frequency": 3, "amount": null}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Reasoning: The user mentioned drinking 2-3 times per week.
|
|
33
|
+
'''
|
|
34
|
+
result = extract_json(text)
|
|
35
|
+
|
|
36
|
+
assert result == {"frequency": 3, "amount": None}
|
|
37
|
+
|
|
38
|
+
def test_extract_any_codeblock(self):
|
|
39
|
+
"""Should extract JSON from ``` ... ``` block without language."""
|
|
40
|
+
text = '''```
|
|
41
|
+
{"status": "ok"}
|
|
42
|
+
```'''
|
|
43
|
+
result = extract_json(text)
|
|
44
|
+
|
|
45
|
+
assert result == {"status": "ok"}
|
|
46
|
+
|
|
47
|
+
def test_extract_curly_pattern(self):
|
|
48
|
+
"""Should extract JSON from {...} pattern in text."""
|
|
49
|
+
text = 'The extracted data is {"key": "value"} from the input.'
|
|
50
|
+
result = extract_json(text)
|
|
51
|
+
|
|
52
|
+
assert result == {"key": "value"}
|
|
53
|
+
|
|
54
|
+
def test_extract_array_pattern(self):
|
|
55
|
+
"""Should extract JSON from [...] pattern in text."""
|
|
56
|
+
text = 'Items: ["a", "b", "c"] found.'
|
|
57
|
+
result = extract_json(text)
|
|
58
|
+
|
|
59
|
+
assert result == ["a", "b", "c"]
|
|
60
|
+
|
|
61
|
+
def test_returns_original_on_failure(self):
|
|
62
|
+
"""Should return original text if no JSON found."""
|
|
63
|
+
text = "This is just plain text with no JSON."
|
|
64
|
+
result = extract_json(text)
|
|
65
|
+
|
|
66
|
+
assert result == text
|
|
67
|
+
|
|
68
|
+
def test_handles_nested_json(self):
|
|
69
|
+
"""Should handle nested JSON structures."""
|
|
70
|
+
text = '''```json
|
|
71
|
+
{
|
|
72
|
+
"person": {
|
|
73
|
+
"name": "Alice",
|
|
74
|
+
"scores": [95, 87, 92]
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
```'''
|
|
78
|
+
result = extract_json(text)
|
|
79
|
+
|
|
80
|
+
assert result == {"person": {"name": "Alice", "scores": [95, 87, 92]}}
|
|
81
|
+
|
|
82
|
+
def test_handles_whitespace(self):
|
|
83
|
+
"""Should handle JSON with extra whitespace."""
|
|
84
|
+
text = ''' {
|
|
85
|
+
"key" : "value"
|
|
86
|
+
} '''
|
|
87
|
+
result = extract_json(text)
|
|
88
|
+
|
|
89
|
+
assert result == {"key": "value"}
|
|
90
|
+
|
|
91
|
+
def test_prefers_json_codeblock_over_raw(self):
|
|
92
|
+
"""Should extract from codeblock even if other JSON present."""
|
|
93
|
+
text = '''Some intro {"wrong": true}
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
{"correct": true}
|
|
97
|
+
```
|
|
98
|
+
'''
|
|
99
|
+
result = extract_json(text)
|
|
100
|
+
|
|
101
|
+
# Should find the codeblock, not the inline JSON
|
|
102
|
+
assert result == {"correct": True}
|
|
103
|
+
|
|
104
|
+
def test_invalid_json_in_codeblock_falls_through(self):
|
|
105
|
+
"""Invalid JSON in codeblock should try next strategy."""
|
|
106
|
+
text = '''```json
|
|
107
|
+
{not valid json}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The actual data is {"valid": true}.
|
|
111
|
+
'''
|
|
112
|
+
result = extract_json(text)
|
|
113
|
+
|
|
114
|
+
# Should fall through to curly pattern
|
|
115
|
+
assert result == {"valid": True}
|
|
116
|
+
|
|
117
|
+
def test_empty_string(self):
|
|
118
|
+
"""Should handle empty string."""
|
|
119
|
+
result = extract_json("")
|
|
120
|
+
|
|
121
|
+
assert result == ""
|
|
122
|
+
|
|
123
|
+
def test_multiline_json(self):
|
|
124
|
+
"""Should handle multiline JSON in code block."""
|
|
125
|
+
text = '''```json
|
|
126
|
+
{
|
|
127
|
+
"line1": "value1",
|
|
128
|
+
"line2": "value2",
|
|
129
|
+
"line3": "value3"
|
|
130
|
+
}
|
|
131
|
+
```'''
|
|
132
|
+
result = extract_json(text)
|
|
133
|
+
|
|
134
|
+
assert result == {"line1": "value1", "line2": "value2", "line3": "value3"}
|