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,216 @@
|
|
|
1
|
+
"""Tests for NPC session adapter.
|
|
2
|
+
|
|
3
|
+
TDD: Red → Green → Refactor
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestGetCheckpointer:
|
|
12
|
+
"""Tests for checkpointer factory."""
|
|
13
|
+
|
|
14
|
+
def test_returns_memory_saver_by_default(self):
|
|
15
|
+
"""Without REDIS_URL, should return MemorySaver."""
|
|
16
|
+
from examples.npc.api.session import _reset_checkpointer, get_checkpointer
|
|
17
|
+
|
|
18
|
+
_reset_checkpointer()
|
|
19
|
+
|
|
20
|
+
with patch.dict("os.environ", {}, clear=True):
|
|
21
|
+
checkpointer = get_checkpointer()
|
|
22
|
+
assert checkpointer is not None
|
|
23
|
+
# MemorySaver type check
|
|
24
|
+
assert "memory" in type(checkpointer).__module__.lower()
|
|
25
|
+
|
|
26
|
+
def test_caches_checkpointer(self):
|
|
27
|
+
"""Should return same instance on repeated calls."""
|
|
28
|
+
from examples.npc.api.session import _reset_checkpointer, get_checkpointer
|
|
29
|
+
|
|
30
|
+
_reset_checkpointer()
|
|
31
|
+
|
|
32
|
+
cp1 = get_checkpointer()
|
|
33
|
+
cp2 = get_checkpointer()
|
|
34
|
+
assert cp1 is cp2
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TestEncounterSession:
|
|
38
|
+
"""Tests for EncounterSession class."""
|
|
39
|
+
|
|
40
|
+
@pytest.fixture
|
|
41
|
+
def mock_app(self):
|
|
42
|
+
"""Create mock LangGraph app."""
|
|
43
|
+
app = MagicMock()
|
|
44
|
+
app.checkpointer = MagicMock()
|
|
45
|
+
app.checkpointer.aget = AsyncMock(return_value=None)
|
|
46
|
+
app.aget_state = AsyncMock(return_value=MagicMock(next=[], values={}))
|
|
47
|
+
return app
|
|
48
|
+
|
|
49
|
+
@pytest.mark.asyncio
|
|
50
|
+
async def test_is_resume_false_for_new_session(self, mock_app):
|
|
51
|
+
"""New session should return False for _is_resume."""
|
|
52
|
+
from examples.npc.api.session import EncounterSession
|
|
53
|
+
|
|
54
|
+
mock_app.checkpointer.aget.return_value = None
|
|
55
|
+
|
|
56
|
+
session = EncounterSession(mock_app, "test-session-123")
|
|
57
|
+
result = await session._is_resume()
|
|
58
|
+
|
|
59
|
+
assert result is False
|
|
60
|
+
mock_app.checkpointer.aget.assert_called_once()
|
|
61
|
+
|
|
62
|
+
@pytest.mark.asyncio
|
|
63
|
+
async def test_is_resume_true_for_existing_session(self, mock_app):
|
|
64
|
+
"""Existing session should return True for _is_resume."""
|
|
65
|
+
from examples.npc.api.session import EncounterSession
|
|
66
|
+
|
|
67
|
+
mock_app.checkpointer.aget.return_value = {"some": "checkpoint"}
|
|
68
|
+
|
|
69
|
+
session = EncounterSession(mock_app, "test-session-123")
|
|
70
|
+
result = await session._is_resume()
|
|
71
|
+
|
|
72
|
+
assert result is True
|
|
73
|
+
|
|
74
|
+
@pytest.mark.asyncio
|
|
75
|
+
async def test_start_creates_initial_state(self, mock_app):
|
|
76
|
+
"""Start should invoke graph with initial state."""
|
|
77
|
+
from examples.npc.api.session import EncounterSession
|
|
78
|
+
|
|
79
|
+
mock_app.ainvoke = AsyncMock(
|
|
80
|
+
return_value={
|
|
81
|
+
"turn_number": 1,
|
|
82
|
+
"narrations": [],
|
|
83
|
+
"scene_image": None,
|
|
84
|
+
}
|
|
85
|
+
)
|
|
86
|
+
mock_app.aget_state.return_value = MagicMock(next=["await_dm"], values={})
|
|
87
|
+
|
|
88
|
+
session = EncounterSession(mock_app, "test-session-123")
|
|
89
|
+
npcs = [{"name": "Thorin", "race": "Dwarf"}]
|
|
90
|
+
result = await session.start(npcs, "The Red Dragon Inn")
|
|
91
|
+
|
|
92
|
+
assert result.turn_number == 1
|
|
93
|
+
assert result.is_complete is False # Has next node
|
|
94
|
+
mock_app.ainvoke.assert_called_once()
|
|
95
|
+
|
|
96
|
+
@pytest.mark.asyncio
|
|
97
|
+
async def test_turn_resumes_with_command(self, mock_app):
|
|
98
|
+
"""Turn should resume graph with Command."""
|
|
99
|
+
from examples.npc.api.session import EncounterSession
|
|
100
|
+
|
|
101
|
+
mock_app.ainvoke = AsyncMock(
|
|
102
|
+
return_value={
|
|
103
|
+
"turn_number": 2,
|
|
104
|
+
"narrations": [{"npc": "Thorin", "text": "Hmm..."}],
|
|
105
|
+
"scene_image": None,
|
|
106
|
+
}
|
|
107
|
+
)
|
|
108
|
+
mock_app.aget_state.return_value = MagicMock(next=["await_dm"], values={})
|
|
109
|
+
|
|
110
|
+
session = EncounterSession(mock_app, "test-session-123")
|
|
111
|
+
result = await session.turn("A stranger enters the tavern")
|
|
112
|
+
|
|
113
|
+
assert result.turn_number == 2
|
|
114
|
+
assert len(result.narrations) == 1
|
|
115
|
+
|
|
116
|
+
@pytest.mark.asyncio
|
|
117
|
+
async def test_turn_catches_errors(self, mock_app):
|
|
118
|
+
"""Turn should catch exceptions and return error."""
|
|
119
|
+
from examples.npc.api.session import EncounterSession
|
|
120
|
+
|
|
121
|
+
mock_app.ainvoke = AsyncMock(side_effect=Exception("LLM failed"))
|
|
122
|
+
|
|
123
|
+
session = EncounterSession(mock_app, "test-session-123")
|
|
124
|
+
result = await session.turn("A stranger enters")
|
|
125
|
+
|
|
126
|
+
assert result.error is not None
|
|
127
|
+
assert "LLM failed" in result.error
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class TestTurnResult:
|
|
131
|
+
"""Tests for TurnResult dataclass."""
|
|
132
|
+
|
|
133
|
+
def test_creates_with_defaults(self):
|
|
134
|
+
"""TurnResult should have sensible defaults."""
|
|
135
|
+
from examples.npc.api.session import TurnResult
|
|
136
|
+
|
|
137
|
+
result = TurnResult(
|
|
138
|
+
turn_number=1,
|
|
139
|
+
narrations=[],
|
|
140
|
+
scene_image=None,
|
|
141
|
+
turn_summary=None,
|
|
142
|
+
is_complete=False,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
assert result.turn_number == 1
|
|
146
|
+
assert result.error is None
|
|
147
|
+
|
|
148
|
+
def test_error_field_optional(self):
|
|
149
|
+
"""Error field should be optional."""
|
|
150
|
+
from examples.npc.api.session import TurnResult
|
|
151
|
+
|
|
152
|
+
result = TurnResult(
|
|
153
|
+
turn_number=1,
|
|
154
|
+
narrations=[],
|
|
155
|
+
scene_image=None,
|
|
156
|
+
turn_summary=None,
|
|
157
|
+
is_complete=False,
|
|
158
|
+
error="Something went wrong",
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
assert result.error == "Something went wrong"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class TestCreateNpcsFromConcepts:
|
|
165
|
+
"""Tests for NPC creation helper."""
|
|
166
|
+
|
|
167
|
+
@pytest.mark.asyncio
|
|
168
|
+
async def test_creates_npcs_from_concepts(self):
|
|
169
|
+
"""Should create NPCs using npc-creation graph."""
|
|
170
|
+
from examples.npc.api.session import create_npcs_from_concepts
|
|
171
|
+
|
|
172
|
+
concepts = [
|
|
173
|
+
{"concept": "a gruff dwarven bartender", "race": "Dwarf"},
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
with patch("examples.npc.api.session.get_npc_creation_graph") as mock_get:
|
|
177
|
+
mock_app = MagicMock()
|
|
178
|
+
mock_app.ainvoke = AsyncMock(
|
|
179
|
+
return_value={
|
|
180
|
+
"identity": {
|
|
181
|
+
"name": "Thorin Ironfoot",
|
|
182
|
+
"race": "Dwarf",
|
|
183
|
+
"character_class": "Commoner",
|
|
184
|
+
"appearance": "Stocky with copper beard",
|
|
185
|
+
},
|
|
186
|
+
"personality": {"traits": ["Gruff", "Loyal"]},
|
|
187
|
+
"behavior": {"goals": ["Run the tavern"]},
|
|
188
|
+
}
|
|
189
|
+
)
|
|
190
|
+
mock_get.return_value = mock_app
|
|
191
|
+
|
|
192
|
+
npcs = await create_npcs_from_concepts(concepts)
|
|
193
|
+
|
|
194
|
+
assert len(npcs) == 1
|
|
195
|
+
assert npcs[0]["name"] == "Thorin Ironfoot"
|
|
196
|
+
assert npcs[0]["race"] == "Dwarf"
|
|
197
|
+
|
|
198
|
+
@pytest.mark.asyncio
|
|
199
|
+
async def test_handles_creation_errors_gracefully(self):
|
|
200
|
+
"""Should return fallback NPC on error."""
|
|
201
|
+
from examples.npc.api.session import create_npcs_from_concepts
|
|
202
|
+
|
|
203
|
+
concepts = [
|
|
204
|
+
{"concept": "a mysterious elf", "race": "Elf"},
|
|
205
|
+
]
|
|
206
|
+
|
|
207
|
+
with patch("examples.npc.api.session.get_npc_creation_graph") as mock_get:
|
|
208
|
+
mock_app = MagicMock()
|
|
209
|
+
mock_app.ainvoke = AsyncMock(side_effect=Exception("Graph failed"))
|
|
210
|
+
mock_get.return_value = mock_app
|
|
211
|
+
|
|
212
|
+
npcs = await create_npcs_from_concepts(concepts)
|
|
213
|
+
|
|
214
|
+
assert len(npcs) == 1
|
|
215
|
+
assert "error" in npcs[0]
|
|
216
|
+
assert npcs[0]["race"] == "Elf"
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Integration tests for the complete pipeline flow."""
|
|
2
|
+
|
|
3
|
+
from unittest.mock import patch
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from tests.conftest import FixtureAnalysis, FixtureGeneratedContent
|
|
8
|
+
from yamlgraph.builder import build_graph, build_resume_graph, run_pipeline
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestBuildGraph:
|
|
12
|
+
"""Tests for build_graph function."""
|
|
13
|
+
|
|
14
|
+
def test_graph_compiles(self):
|
|
15
|
+
"""Graph should compile without errors."""
|
|
16
|
+
graph = build_graph()
|
|
17
|
+
compiled = graph.compile()
|
|
18
|
+
assert compiled is not None
|
|
19
|
+
|
|
20
|
+
def test_graph_has_expected_nodes(self):
|
|
21
|
+
"""Graph should have generate, analyze, summarize nodes."""
|
|
22
|
+
graph = build_graph()
|
|
23
|
+
# StateGraph stores nodes internally
|
|
24
|
+
assert "generate" in graph.nodes
|
|
25
|
+
assert "analyze" in graph.nodes
|
|
26
|
+
assert "summarize" in graph.nodes
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TestBuildResumeGraph:
|
|
30
|
+
"""Tests for build_resume_graph function.
|
|
31
|
+
|
|
32
|
+
Resume works via skip_if_exists: nodes skip LLM calls if output exists in state.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def test_resume_graph_loads_full_pipeline(self):
|
|
36
|
+
"""Resume graph loads the full pipeline (same as main graph)."""
|
|
37
|
+
graph = build_resume_graph()
|
|
38
|
+
# All nodes present - skip_if_exists handles resume logic
|
|
39
|
+
assert "generate" in graph.nodes
|
|
40
|
+
assert "analyze" in graph.nodes
|
|
41
|
+
assert "summarize" in graph.nodes
|
|
42
|
+
|
|
43
|
+
def test_resume_graph_same_as_main(self):
|
|
44
|
+
"""Resume graph is identical to main graph."""
|
|
45
|
+
main_graph = build_graph()
|
|
46
|
+
resume_graph = build_resume_graph()
|
|
47
|
+
|
|
48
|
+
assert set(main_graph.nodes.keys()) == set(resume_graph.nodes.keys())
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TestRunPipeline:
|
|
52
|
+
"""Tests for run_pipeline function with mocked LLM."""
|
|
53
|
+
|
|
54
|
+
@patch("yamlgraph.node_factory.execute_prompt")
|
|
55
|
+
def test_full_pipeline_success(self, mock_execute):
|
|
56
|
+
"""Full pipeline should execute all steps."""
|
|
57
|
+
# Setup mock returns for each call
|
|
58
|
+
mock_generated = FixtureGeneratedContent(
|
|
59
|
+
title="Test Title",
|
|
60
|
+
content="Test content " * 50,
|
|
61
|
+
word_count=100,
|
|
62
|
+
tags=["test"],
|
|
63
|
+
)
|
|
64
|
+
mock_analysis = FixtureAnalysis(
|
|
65
|
+
summary="Test summary",
|
|
66
|
+
key_points=["Point 1"],
|
|
67
|
+
sentiment="positive",
|
|
68
|
+
confidence=0.9,
|
|
69
|
+
)
|
|
70
|
+
mock_summary = "Final test summary"
|
|
71
|
+
|
|
72
|
+
mock_execute.side_effect = [mock_generated, mock_analysis, mock_summary]
|
|
73
|
+
|
|
74
|
+
result = run_pipeline(topic="test", style="informative", word_count=100)
|
|
75
|
+
|
|
76
|
+
assert result["generated"] == mock_generated
|
|
77
|
+
assert result["analysis"] == mock_analysis
|
|
78
|
+
assert result["final_summary"] == mock_summary
|
|
79
|
+
assert mock_execute.call_count == 3
|
|
80
|
+
|
|
81
|
+
@patch("yamlgraph.node_factory.execute_prompt")
|
|
82
|
+
def test_pipeline_stops_on_generate_error(self, mock_execute):
|
|
83
|
+
"""Pipeline should stop and raise exception on generate failure."""
|
|
84
|
+
mock_execute.side_effect = Exception("API Error")
|
|
85
|
+
|
|
86
|
+
with pytest.raises(Exception) as exc_info:
|
|
87
|
+
run_pipeline(topic="test")
|
|
88
|
+
|
|
89
|
+
assert "API Error" in str(exc_info.value)
|
|
90
|
+
|
|
91
|
+
@patch("yamlgraph.node_factory.execute_prompt")
|
|
92
|
+
def test_pipeline_state_progression(self, mock_execute):
|
|
93
|
+
"""Pipeline should update current_step as it progresses."""
|
|
94
|
+
mock_generated = FixtureGeneratedContent(
|
|
95
|
+
title="Test", content="Content", word_count=1, tags=[]
|
|
96
|
+
)
|
|
97
|
+
mock_analysis = FixtureAnalysis(
|
|
98
|
+
summary="Summary", key_points=[], sentiment="neutral", confidence=0.5
|
|
99
|
+
)
|
|
100
|
+
mock_execute.side_effect = [mock_generated, mock_analysis, "Summary"]
|
|
101
|
+
|
|
102
|
+
result = run_pipeline(topic="test")
|
|
103
|
+
|
|
104
|
+
# Final step should be summarize
|
|
105
|
+
assert result["current_step"] == "summarize"
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Integration tests for multi-provider LLM support."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from yamlgraph.executor import execute_prompt, load_prompt
|
|
10
|
+
from yamlgraph.utils.llm_factory import clear_cache
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ProviderTestContent(BaseModel):
|
|
14
|
+
"""Test model for provider tests - replaces demo model dependency."""
|
|
15
|
+
|
|
16
|
+
title: str = Field(description="Title of the generated content")
|
|
17
|
+
content: str = Field(description="The main generated text")
|
|
18
|
+
word_count: int = Field(description="Approximate word count")
|
|
19
|
+
tags: list[str] = Field(default_factory=list, description="Relevant tags")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TestProviderIntegration:
|
|
23
|
+
"""Test multi-provider functionality end-to-end."""
|
|
24
|
+
|
|
25
|
+
def setup_method(self):
|
|
26
|
+
"""Clear LLM cache before each test."""
|
|
27
|
+
clear_cache()
|
|
28
|
+
|
|
29
|
+
def test_execute_prompt_with_anthropic_provider(self):
|
|
30
|
+
"""Should execute prompt with explicit Anthropic provider."""
|
|
31
|
+
result = execute_prompt(
|
|
32
|
+
prompt_name="greet",
|
|
33
|
+
variables={"name": "Alice", "style": "formal"},
|
|
34
|
+
provider="anthropic",
|
|
35
|
+
)
|
|
36
|
+
assert isinstance(result, str)
|
|
37
|
+
assert len(result) > 0
|
|
38
|
+
|
|
39
|
+
@pytest.mark.skipif(
|
|
40
|
+
not os.getenv("MISTRAL_API_KEY"), reason="MISTRAL_API_KEY not set"
|
|
41
|
+
)
|
|
42
|
+
def test_execute_prompt_with_mistral_provider(self):
|
|
43
|
+
"""Should execute prompt with Mistral provider."""
|
|
44
|
+
result = execute_prompt(
|
|
45
|
+
prompt_name="greet",
|
|
46
|
+
variables={"name": "Bob", "style": "casual"},
|
|
47
|
+
provider="mistral",
|
|
48
|
+
)
|
|
49
|
+
assert isinstance(result, str)
|
|
50
|
+
assert len(result) > 0
|
|
51
|
+
|
|
52
|
+
@pytest.mark.skipif(
|
|
53
|
+
not os.getenv("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set"
|
|
54
|
+
)
|
|
55
|
+
def test_execute_prompt_with_openai_provider(self):
|
|
56
|
+
"""Should execute prompt with OpenAI provider."""
|
|
57
|
+
result = execute_prompt(
|
|
58
|
+
prompt_name="greet",
|
|
59
|
+
variables={"name": "Charlie", "style": "friendly"},
|
|
60
|
+
provider="openai",
|
|
61
|
+
)
|
|
62
|
+
assert isinstance(result, str)
|
|
63
|
+
assert len(result) > 0
|
|
64
|
+
|
|
65
|
+
def test_provider_from_environment_variable(self):
|
|
66
|
+
"""Should use provider from PROVIDER env var."""
|
|
67
|
+
with patch.dict(os.environ, {"PROVIDER": "anthropic"}):
|
|
68
|
+
result = execute_prompt(
|
|
69
|
+
prompt_name="greet",
|
|
70
|
+
variables={"name": "Dave", "style": "formal"},
|
|
71
|
+
)
|
|
72
|
+
assert isinstance(result, str)
|
|
73
|
+
assert len(result) > 0
|
|
74
|
+
|
|
75
|
+
def test_provider_in_yaml_metadata(self):
|
|
76
|
+
"""Should extract provider from YAML metadata."""
|
|
77
|
+
# greet.yaml doesn't have provider metadata,
|
|
78
|
+
# so this just verifies the load works
|
|
79
|
+
_ = load_prompt("greet")
|
|
80
|
+
|
|
81
|
+
# Even though greet.yaml doesn't have provider,
|
|
82
|
+
# the executor should handle it gracefully
|
|
83
|
+
result = execute_prompt(
|
|
84
|
+
prompt_name="greet",
|
|
85
|
+
variables={"name": "Eve", "style": "casual"},
|
|
86
|
+
)
|
|
87
|
+
assert isinstance(result, str)
|
|
88
|
+
|
|
89
|
+
def test_structured_output_with_different_providers(self):
|
|
90
|
+
"""Should work with structured outputs across providers."""
|
|
91
|
+
result = execute_prompt(
|
|
92
|
+
prompt_name="generate",
|
|
93
|
+
variables={
|
|
94
|
+
"topic": "Python testing",
|
|
95
|
+
"style": "technical",
|
|
96
|
+
"word_count": 50,
|
|
97
|
+
},
|
|
98
|
+
output_model=ProviderTestContent,
|
|
99
|
+
provider="anthropic",
|
|
100
|
+
)
|
|
101
|
+
assert isinstance(result, ProviderTestContent)
|
|
102
|
+
assert result.content
|
|
103
|
+
assert isinstance(result.tags, list)
|
|
104
|
+
|
|
105
|
+
def test_temperature_and_provider_together(self):
|
|
106
|
+
"""Should handle both temperature and provider parameters."""
|
|
107
|
+
result = execute_prompt(
|
|
108
|
+
prompt_name="greet",
|
|
109
|
+
variables={"name": "Frank", "style": "formal"},
|
|
110
|
+
temperature=0.3,
|
|
111
|
+
provider="anthropic",
|
|
112
|
+
)
|
|
113
|
+
assert isinstance(result, str)
|
|
114
|
+
assert len(result) > 0
|
|
115
|
+
|
|
116
|
+
def test_invalid_provider_raises_error(self):
|
|
117
|
+
"""Should raise error for invalid provider."""
|
|
118
|
+
with pytest.raises(ValueError, match="Invalid provider"):
|
|
119
|
+
execute_prompt(
|
|
120
|
+
prompt_name="greet",
|
|
121
|
+
variables={"name": "Greg", "style": "casual"},
|
|
122
|
+
provider="invalid-provider",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def test_caching_across_calls_with_same_provider(self):
|
|
126
|
+
"""Should reuse LLM instances for same provider/temperature."""
|
|
127
|
+
# First call
|
|
128
|
+
result1 = execute_prompt(
|
|
129
|
+
prompt_name="greet",
|
|
130
|
+
variables={"name": "Harry", "style": "formal"},
|
|
131
|
+
temperature=0.7,
|
|
132
|
+
provider="anthropic",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Second call with same params should use cached LLM
|
|
136
|
+
result2 = execute_prompt(
|
|
137
|
+
prompt_name="greet",
|
|
138
|
+
variables={"name": "Ivy", "style": "casual"},
|
|
139
|
+
temperature=0.7,
|
|
140
|
+
provider="anthropic",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Both should succeed (testing cache doesn't break functionality)
|
|
144
|
+
assert isinstance(result1, str)
|
|
145
|
+
assert isinstance(result2, str)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class TestJinja2WithProviders:
|
|
149
|
+
"""Test Jinja2 templates work with different providers."""
|
|
150
|
+
|
|
151
|
+
def setup_method(self):
|
|
152
|
+
"""Clear LLM cache before each test."""
|
|
153
|
+
clear_cache()
|
|
154
|
+
|
|
155
|
+
def test_simple_prompt_template_format(self):
|
|
156
|
+
"""Should work with simple {variable} templates on any provider."""
|
|
157
|
+
result = execute_prompt(
|
|
158
|
+
prompt_name="greet",
|
|
159
|
+
variables={"name": "TestUser", "style": "formal"},
|
|
160
|
+
provider="anthropic",
|
|
161
|
+
)
|
|
162
|
+
assert isinstance(result, str)
|
|
163
|
+
assert len(result) > 0
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Integration tests for pipeline resume functionality."""
|
|
2
|
+
|
|
3
|
+
from unittest.mock import patch
|
|
4
|
+
|
|
5
|
+
from tests.conftest import FixtureAnalysis, FixtureGeneratedContent
|
|
6
|
+
from yamlgraph.builder import build_resume_graph
|
|
7
|
+
from yamlgraph.models import create_initial_state
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestResumeFromAnalyze:
|
|
11
|
+
"""Tests for resuming pipeline with existing generated content."""
|
|
12
|
+
|
|
13
|
+
@patch("yamlgraph.node_factory.execute_prompt")
|
|
14
|
+
def test_resume_with_generated_skips_generate(self, mock_execute):
|
|
15
|
+
"""Should skip generate when generated content exists."""
|
|
16
|
+
# Create state with generated content
|
|
17
|
+
state = create_initial_state(topic="test", thread_id="resume1")
|
|
18
|
+
state["generated"] = FixtureGeneratedContent(
|
|
19
|
+
title="Existing Title",
|
|
20
|
+
content="Existing content",
|
|
21
|
+
word_count=10,
|
|
22
|
+
tags=[],
|
|
23
|
+
)
|
|
24
|
+
state["current_step"] = "generate"
|
|
25
|
+
|
|
26
|
+
# Mock returns: analyze, summarize (generate is skipped)
|
|
27
|
+
mock_analysis = FixtureAnalysis(
|
|
28
|
+
summary="Resume summary",
|
|
29
|
+
key_points=["Point"],
|
|
30
|
+
sentiment="neutral",
|
|
31
|
+
confidence=0.7,
|
|
32
|
+
)
|
|
33
|
+
mock_execute.side_effect = [mock_analysis, "Final summary"]
|
|
34
|
+
|
|
35
|
+
graph = build_resume_graph().compile()
|
|
36
|
+
result = graph.invoke(state)
|
|
37
|
+
|
|
38
|
+
assert mock_execute.call_count == 2 # analyze + summarize only
|
|
39
|
+
assert result["analysis"] == mock_analysis
|
|
40
|
+
assert result["final_summary"] == "Final summary"
|
|
41
|
+
assert result["generated"].title == "Existing Title" # Preserved
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TestResumeFromSummarize:
|
|
45
|
+
"""Tests for resuming pipeline with existing analysis."""
|
|
46
|
+
|
|
47
|
+
@patch("yamlgraph.node_factory.execute_prompt")
|
|
48
|
+
def test_resume_with_analysis_skips_generate_and_analyze(self, mock_execute):
|
|
49
|
+
"""Should skip generate and analyze when both exist."""
|
|
50
|
+
# Create state with generated content and analysis
|
|
51
|
+
state = create_initial_state(topic="test", thread_id="resume2")
|
|
52
|
+
state["generated"] = FixtureGeneratedContent(
|
|
53
|
+
title="Title",
|
|
54
|
+
content="Content",
|
|
55
|
+
word_count=5,
|
|
56
|
+
tags=[],
|
|
57
|
+
)
|
|
58
|
+
state["analysis"] = FixtureAnalysis(
|
|
59
|
+
summary="Existing analysis",
|
|
60
|
+
key_points=["Point"],
|
|
61
|
+
sentiment="positive",
|
|
62
|
+
confidence=0.8,
|
|
63
|
+
)
|
|
64
|
+
state["current_step"] = "analyze"
|
|
65
|
+
|
|
66
|
+
# Mock returns: only summarize (generate and analyze skipped)
|
|
67
|
+
mock_execute.return_value = "Resumed final summary"
|
|
68
|
+
|
|
69
|
+
graph = build_resume_graph().compile()
|
|
70
|
+
result = graph.invoke(state)
|
|
71
|
+
|
|
72
|
+
assert mock_execute.call_count == 1 # summarize only
|
|
73
|
+
assert result["final_summary"] == "Resumed final summary"
|
|
74
|
+
assert result["generated"].title == "Title" # Preserved
|
|
75
|
+
assert result["analysis"].summary == "Existing analysis" # Preserved
|