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
+ """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"}