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,242 @@
1
+ """Web search tool for LLM agents.
2
+
3
+ This module provides web search functionality using DuckDuckGo,
4
+ allowing agents to search the internet for current information.
5
+
6
+ No API key required for DuckDuckGo. Results include title, URL, and snippet.
7
+
8
+ Example usage in graph YAML:
9
+ tools:
10
+ search_web:
11
+ type: websearch
12
+ provider: duckduckgo
13
+ max_results: 5
14
+ description: "Search the web for information"
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ from dataclasses import dataclass
21
+ from typing import Any
22
+
23
+ from langchain_core.tools import StructuredTool
24
+ from pydantic import BaseModel, Field
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ # Import DuckDuckGo - try new package name first, fallback to old
29
+ try:
30
+ from ddgs import DDGS
31
+
32
+ DUCKDUCKGO_AVAILABLE = True
33
+ except ImportError:
34
+ try:
35
+ from duckduckgo_search import DDGS
36
+
37
+ DUCKDUCKGO_AVAILABLE = True
38
+ except ImportError:
39
+ DDGS = None # type: ignore[assignment, misc]
40
+ DUCKDUCKGO_AVAILABLE = False
41
+
42
+
43
+ @dataclass
44
+ class WebSearchToolConfig:
45
+ """Configuration for a web search tool.
46
+
47
+ Attributes:
48
+ provider: Search provider (currently only 'duckduckgo' supported)
49
+ max_results: Maximum number of results to return
50
+ description: Human-readable description for LLM tool selection
51
+ timeout: Max seconds before search is cancelled
52
+ """
53
+
54
+ provider: str = "duckduckgo"
55
+ max_results: int = 5
56
+ description: str = ""
57
+ timeout: int = 30
58
+
59
+
60
+ @dataclass
61
+ class WebSearchResult:
62
+ """Result from executing a web search.
63
+
64
+ Attributes:
65
+ success: Whether the search succeeded
66
+ results: List of search result dicts with title, href, body
67
+ query: The search query used
68
+ error: Error message if failed
69
+ """
70
+
71
+ success: bool
72
+ results: list[dict[str, str]]
73
+ query: str
74
+ error: str | None = None
75
+
76
+
77
+ def execute_web_search(query: str, config: WebSearchToolConfig) -> WebSearchResult:
78
+ """Execute a web search using the configured provider.
79
+
80
+ Args:
81
+ query: Search query string
82
+ config: WebSearchToolConfig with provider settings
83
+
84
+ Returns:
85
+ WebSearchResult with search results or error
86
+ """
87
+ if not query or not query.strip():
88
+ return WebSearchResult(
89
+ success=False,
90
+ results=[],
91
+ query=query,
92
+ error="Search query is empty",
93
+ )
94
+
95
+ if config.provider != "duckduckgo":
96
+ return WebSearchResult(
97
+ success=False,
98
+ results=[],
99
+ query=query,
100
+ error=f"Unsupported provider: {config.provider}",
101
+ )
102
+
103
+ if not DUCKDUCKGO_AVAILABLE:
104
+ return WebSearchResult(
105
+ success=False,
106
+ results=[],
107
+ query=query,
108
+ error="duckduckgo-search package not installed. Run: pip install duckduckgo-search",
109
+ )
110
+
111
+ try:
112
+ with DDGS() as ddgs:
113
+ results = list(ddgs.text(query, max_results=config.max_results))
114
+
115
+ logger.debug(f"Web search for '{query}' returned {len(results)} results")
116
+
117
+ return WebSearchResult(
118
+ success=True,
119
+ results=results,
120
+ query=query,
121
+ )
122
+
123
+ except Exception as e:
124
+ logger.warning(f"Web search failed: {e}")
125
+ return WebSearchResult(
126
+ success=False,
127
+ results=[],
128
+ query=query,
129
+ error=str(e),
130
+ )
131
+
132
+
133
+ def format_search_results(result: WebSearchResult) -> str:
134
+ """Format search results as readable text for LLM consumption.
135
+
136
+ Args:
137
+ result: WebSearchResult from execute_web_search
138
+
139
+ Returns:
140
+ Formatted string with results or error message
141
+ """
142
+ if not result.success:
143
+ return f"Search error: {result.error}"
144
+
145
+ if not result.results:
146
+ return f"No results found for query: '{result.query}'"
147
+
148
+ lines = [f"Search results for '{result.query}':\n"]
149
+
150
+ for i, item in enumerate(result.results, 1):
151
+ title = item.get("title", "No title")
152
+ url = item.get("href", item.get("url", "No URL"))
153
+ body = item.get("body", item.get("snippet", ""))
154
+
155
+ lines.append(f"{i}. {title}")
156
+ lines.append(f" URL: {url}")
157
+ if body:
158
+ lines.append(f" {body}")
159
+ lines.append("")
160
+
161
+ return "\n".join(lines)
162
+
163
+
164
+ class WebSearchInput(BaseModel):
165
+ """Input schema for web search tool."""
166
+
167
+ query: str = Field(description="The search query to look up on the web")
168
+
169
+
170
+ def create_web_search_tool(name: str, config: WebSearchToolConfig) -> StructuredTool:
171
+ """Create a LangChain-compatible web search tool.
172
+
173
+ Args:
174
+ name: Tool name for LLM to reference
175
+ config: WebSearchToolConfig with settings
176
+
177
+ Returns:
178
+ StructuredTool that can be used with LangChain agents
179
+ """
180
+
181
+ def search_func(query: str) -> str:
182
+ """Execute web search and return formatted results."""
183
+ result = execute_web_search(query, config)
184
+ return format_search_results(result)
185
+
186
+ description = (
187
+ config.description or "Search the web for current information on any topic"
188
+ )
189
+
190
+ return StructuredTool.from_function(
191
+ func=search_func,
192
+ name=name,
193
+ description=description,
194
+ args_schema=WebSearchInput,
195
+ )
196
+
197
+
198
+ def create_websearch_tool_from_config(
199
+ name: str, tool_config: dict[str, Any]
200
+ ) -> StructuredTool:
201
+ """Create a web search tool from YAML config dict.
202
+
203
+ This is the entry point used by the graph loader when parsing
204
+ tool definitions from YAML.
205
+
206
+ Args:
207
+ name: Tool name
208
+ tool_config: Dict with provider, max_results, description, etc.
209
+
210
+ Returns:
211
+ StructuredTool for use in agent nodes
212
+ """
213
+ config = WebSearchToolConfig(
214
+ provider=tool_config.get("provider", "duckduckgo"),
215
+ max_results=tool_config.get("max_results", 5),
216
+ description=tool_config.get("description", ""),
217
+ timeout=tool_config.get("timeout", 30),
218
+ )
219
+
220
+ return create_web_search_tool(name, config)
221
+
222
+
223
+ def parse_websearch_tools(tools_config: dict[str, Any]) -> dict[str, Any]:
224
+ """Parse tools: section from YAML for websearch tools.
225
+
226
+ Only parses tools with type: websearch.
227
+
228
+ Args:
229
+ tools_config: Dict from YAML tools: section
230
+
231
+ Returns:
232
+ Registry mapping tool names to LangChain StructuredTool objects
233
+ """
234
+ registry: dict[str, Any] = {}
235
+
236
+ for name, config in tools_config.items():
237
+ if config.get("type") != "websearch":
238
+ continue
239
+
240
+ registry[name] = create_websearch_tool_from_config(name, config)
241
+
242
+ return registry
@@ -0,0 +1,48 @@
1
+ """Utility functions for observability and logging."""
2
+
3
+ from yamlgraph.utils.conditions import evaluate_condition
4
+ from yamlgraph.utils.expressions import (
5
+ resolve_state_expression,
6
+ resolve_state_path,
7
+ resolve_template,
8
+ )
9
+ from yamlgraph.utils.json_extract import extract_json
10
+ from yamlgraph.utils.langsmith import (
11
+ get_client,
12
+ get_latest_run_id,
13
+ get_project_name,
14
+ get_run_url,
15
+ is_tracing_enabled,
16
+ print_run_tree,
17
+ )
18
+ from yamlgraph.utils.logging import get_logger, setup_logging
19
+ from yamlgraph.utils.prompts import load_prompt, load_prompt_path, resolve_prompt_path
20
+ from yamlgraph.utils.template import extract_variables, validate_variables
21
+
22
+ __all__ = [
23
+ # Conditions
24
+ "evaluate_condition",
25
+ # Expression resolution (consolidated)
26
+ "resolve_state_path",
27
+ "resolve_state_expression",
28
+ "resolve_template",
29
+ # JSON extraction
30
+ "extract_json",
31
+ # LangSmith
32
+ "get_client",
33
+ "get_project_name",
34
+ "is_tracing_enabled",
35
+ "get_latest_run_id",
36
+ "print_run_tree",
37
+ "get_run_url",
38
+ # Logging
39
+ "get_logger",
40
+ "setup_logging",
41
+ # Prompts
42
+ "resolve_prompt_path",
43
+ "load_prompt",
44
+ "load_prompt_path",
45
+ # Template utilities
46
+ "extract_variables",
47
+ "validate_variables",
48
+ ]
@@ -0,0 +1,157 @@
1
+ """Condition expression evaluation for graph routing.
2
+
3
+ Provides safe evaluation of condition expressions without using eval().
4
+ Supports comparisons and compound boolean expressions.
5
+
6
+ Examples:
7
+ >>> evaluate_condition("score < 0.8", {"score": 0.5})
8
+ True
9
+ >>> evaluate_condition("a > 1 and b < 2", {"a": 2, "b": 1})
10
+ True
11
+ """
12
+
13
+ import re
14
+ from typing import Any
15
+
16
+ from yamlgraph.utils.expressions import resolve_state_path
17
+
18
+ # Regex patterns for expression parsing
19
+ # Valid operators: <=, >=, ==, !=, <, > (strict matching)
20
+ COMPARISON_PATTERN = re.compile(
21
+ r"^\s*([a-zA-Z_][\w.]*)\s*(<=|>=|==|!=|<(?!<)|>(?!>))\s*(.+?)\s*$"
22
+ )
23
+ COMPOUND_AND_PATTERN = re.compile(r"\s+and\s+", re.IGNORECASE)
24
+ COMPOUND_OR_PATTERN = re.compile(r"\s+or\s+", re.IGNORECASE)
25
+
26
+
27
+ def resolve_value(path: str, state: dict) -> Any:
28
+ """Resolve a dotted path to a value from state.
29
+
30
+ Delegates to consolidated resolve_state_path in expressions module.
31
+
32
+ Args:
33
+ path: Dotted path like "critique.score"
34
+ state: State dictionary
35
+
36
+ Returns:
37
+ Resolved value or None if not found
38
+ """
39
+ return resolve_state_path(path, state)
40
+
41
+
42
+ def parse_literal(value_str: str) -> Any:
43
+ """Parse a literal value from expression.
44
+
45
+ Args:
46
+ value_str: String representation of value
47
+
48
+ Returns:
49
+ Parsed Python value
50
+ """
51
+ value_str = value_str.strip()
52
+
53
+ # Handle quoted strings
54
+ if (value_str.startswith('"') and value_str.endswith('"')) or (
55
+ value_str.startswith("'") and value_str.endswith("'")
56
+ ):
57
+ return value_str[1:-1]
58
+
59
+ # Handle special keywords
60
+ if value_str.lower() == "true":
61
+ return True
62
+ if value_str.lower() == "false":
63
+ return False
64
+ if value_str.lower() == "null" or value_str.lower() == "none":
65
+ return None
66
+
67
+ # Handle numbers
68
+ try:
69
+ if "." in value_str:
70
+ return float(value_str)
71
+ return int(value_str)
72
+ except ValueError:
73
+ # Return as string if not a number
74
+ return value_str
75
+
76
+
77
+ def evaluate_comparison(
78
+ left_path: str, operator: str, right_str: str, state: dict[str, Any]
79
+ ) -> bool:
80
+ """Evaluate a single comparison expression.
81
+
82
+ Args:
83
+ left_path: Dotted path to left value
84
+ operator: Comparison operator
85
+ right_str: String representation of right value
86
+ state: State dictionary
87
+
88
+ Returns:
89
+ Boolean result of comparison
90
+ """
91
+ left_value = resolve_value(left_path, state)
92
+ right_value = parse_literal(right_str)
93
+
94
+ # Handle missing left value
95
+ if left_value is None and operator not in ("==", "!="):
96
+ return False
97
+
98
+ try:
99
+ if operator == "<":
100
+ return left_value < right_value
101
+ elif operator == ">":
102
+ return left_value > right_value
103
+ elif operator == "<=":
104
+ return left_value <= right_value
105
+ elif operator == ">=":
106
+ return left_value >= right_value
107
+ elif operator == "==":
108
+ return left_value == right_value
109
+ elif operator == "!=":
110
+ return left_value != right_value
111
+ else:
112
+ raise ValueError(f"Unknown operator: {operator}")
113
+ except TypeError:
114
+ # Comparison failed (e.g., comparing None with number)
115
+ return False
116
+
117
+
118
+ def evaluate_condition(expr: str, state: dict) -> bool:
119
+ """Safely evaluate a condition expression against state.
120
+
121
+ Uses pattern matching - no eval() for security.
122
+
123
+ Args:
124
+ expr: Condition expression like "score < 0.8" or "a > 1 and b < 2"
125
+ state: State dictionary to evaluate against
126
+
127
+ Returns:
128
+ Boolean result of evaluation
129
+
130
+ Raises:
131
+ ValueError: If expression is malformed
132
+
133
+ Examples:
134
+ >>> evaluate_condition("score < 0.8", {"score": 0.5})
135
+ True
136
+ >>> evaluate_condition("critique.score >= 0.8", {"critique": obj})
137
+ True
138
+ """
139
+ expr = expr.strip()
140
+
141
+ # Handle compound OR (lower precedence)
142
+ if COMPOUND_OR_PATTERN.search(expr):
143
+ parts = COMPOUND_OR_PATTERN.split(expr)
144
+ return any(evaluate_condition(part, state) for part in parts)
145
+
146
+ # Handle compound AND
147
+ if COMPOUND_AND_PATTERN.search(expr):
148
+ parts = COMPOUND_AND_PATTERN.split(expr)
149
+ return all(evaluate_condition(part, state) for part in parts)
150
+
151
+ # Parse single comparison
152
+ match = COMPARISON_PATTERN.match(expr)
153
+ if not match:
154
+ raise ValueError(f"Invalid condition expression: {expr}")
155
+
156
+ left_path, operator, right_str = match.groups()
157
+ return evaluate_comparison(left_path, operator, right_str, state)
@@ -0,0 +1,245 @@
1
+ """Expression resolution utilities for YAML graphs.
2
+
3
+ Consolidated module for all state path/expression resolution.
4
+ Use these functions instead of duplicating resolution logic elsewhere.
5
+ """
6
+
7
+ import re
8
+ from typing import Any
9
+
10
+ # Pattern for arithmetic expressions: {state.field + 1} or {state.a + state.b}
11
+ ARITHMETIC_PATTERN = re.compile(r"^\{(state\.[a-zA-Z_][\w.]*)\s*([+\-*/])\s*(.+)\}$")
12
+
13
+
14
+ def resolve_state_path(path: str, state: dict[str, Any]) -> Any:
15
+ """Resolve a dotted path to a value from state.
16
+
17
+ Core resolution function - handles nested dict access and object attributes.
18
+ This is the single source of truth for path resolution.
19
+
20
+ Args:
21
+ path: Dotted path like "critique.score" or "story.panels"
22
+ state: State dictionary
23
+
24
+ Returns:
25
+ Resolved value or None if not found
26
+ """
27
+ if not path:
28
+ return None
29
+
30
+ parts = path.split(".")
31
+ value = state
32
+
33
+ for part in parts:
34
+ if value is None:
35
+ return None
36
+ if isinstance(value, dict):
37
+ value = value.get(part)
38
+ else:
39
+ # Try attribute access for objects (Pydantic models, etc.)
40
+ value = getattr(value, part, None)
41
+
42
+ return value
43
+
44
+
45
+ def resolve_state_expression(expr: str | Any, state: dict[str, Any]) -> Any:
46
+ """Resolve {state.path.to.value} expressions.
47
+
48
+ Supports expressions like:
49
+ - "{name}" -> state["name"]
50
+ - "{state.story.panels}" -> state["story"]["panels"]
51
+ - "{story.title}" -> state["story"]["title"]
52
+
53
+ Non-expression values (no braces) pass through unchanged.
54
+
55
+ Args:
56
+ expr: Expression string like "{state.story.panels}" or any value
57
+ state: Current graph state dict
58
+
59
+ Returns:
60
+ Resolved value from state, or original value if not an expression
61
+
62
+ Raises:
63
+ KeyError: If path cannot be resolved in state
64
+ """
65
+ if not isinstance(expr, str):
66
+ return expr
67
+
68
+ if not (expr.startswith("{") and expr.endswith("}")):
69
+ return expr
70
+
71
+ path = expr[1:-1] # Remove braces
72
+
73
+ # Handle "state." prefix (optional)
74
+ if path.startswith("state."):
75
+ path = path[6:] # Remove "state."
76
+
77
+ # Navigate nested path
78
+ value = state
79
+ for key in path.split("."):
80
+ if isinstance(value, dict) and key in value:
81
+ value = value[key]
82
+ elif hasattr(value, key):
83
+ # Support object attribute access (Pydantic models, etc.)
84
+ value = getattr(value, key)
85
+ else:
86
+ raise KeyError(f"Cannot resolve '{key}' in path '{expr}'")
87
+
88
+ return value
89
+
90
+
91
+ def _parse_operand(operand_str: str, state: dict[str, Any]) -> Any:
92
+ """Parse an operand - either a state reference or a literal.
93
+
94
+ Args:
95
+ operand_str: String like "state.counter", "1", "[state.item]", etc.
96
+ state: Current pipeline state
97
+
98
+ Returns:
99
+ Resolved value
100
+ """
101
+ operand_str = operand_str.strip()
102
+
103
+ # State reference: state.field
104
+ if operand_str.startswith("state."):
105
+ path = operand_str[6:] # Remove "state."
106
+ return resolve_state_path(path, state)
107
+
108
+ # List literal with state reference: [state.item]
109
+ if operand_str.startswith("[") and operand_str.endswith("]"):
110
+ inner = operand_str[1:-1].strip()
111
+ if inner.startswith("state."):
112
+ item = resolve_state_path(inner[6:], state)
113
+ return [item]
114
+ # Try to parse as literal
115
+ return [_parse_literal(inner)]
116
+
117
+ # Dict literal: {'key': state.value}
118
+ if operand_str.startswith("{") and operand_str.endswith("}"):
119
+ # Simple dict parsing - limited support
120
+ inner = operand_str[1:-1].strip()
121
+ result = {}
122
+ # Parse simple key-value pairs
123
+ for pair in inner.split(","):
124
+ if ":" not in pair:
125
+ continue
126
+ key_part, val_part = pair.split(":", 1)
127
+ key = key_part.strip().strip("'\"")
128
+ val = val_part.strip()
129
+ if val.startswith("state."):
130
+ result[key] = resolve_state_path(val[6:], state)
131
+ else:
132
+ result[key] = _parse_literal(val)
133
+ return result
134
+
135
+ # Literal value
136
+ return _parse_literal(operand_str)
137
+
138
+
139
+ def _parse_literal(value_str: str) -> Any:
140
+ """Parse a literal value.
141
+
142
+ Args:
143
+ value_str: String representation
144
+
145
+ Returns:
146
+ Parsed Python value
147
+ """
148
+ value_str = value_str.strip()
149
+
150
+ # Quoted string
151
+ if (value_str.startswith('"') and value_str.endswith('"')) or (
152
+ value_str.startswith("'") and value_str.endswith("'")
153
+ ):
154
+ return value_str[1:-1]
155
+
156
+ # Boolean
157
+ if value_str.lower() == "true":
158
+ return True
159
+ if value_str.lower() == "false":
160
+ return False
161
+
162
+ # None
163
+ if value_str.lower() in ("null", "none"):
164
+ return None
165
+
166
+ # Number
167
+ try:
168
+ if "." in value_str:
169
+ return float(value_str)
170
+ return int(value_str)
171
+ except ValueError:
172
+ return value_str
173
+
174
+
175
+ def _apply_operator(left: Any, operator: str, right: Any) -> Any:
176
+ """Apply an arithmetic operator.
177
+
178
+ Args:
179
+ left: Left operand
180
+ operator: One of +, -, *, /
181
+ right: Right operand
182
+
183
+ Returns:
184
+ Result of operation
185
+ """
186
+ if operator == "+":
187
+ # List concatenation or addition
188
+ if isinstance(left, list):
189
+ if isinstance(right, list):
190
+ return left + right
191
+ return left + [right]
192
+ return left + right
193
+ elif operator == "-":
194
+ return left - right
195
+ elif operator == "*":
196
+ return left * right
197
+ elif operator == "/":
198
+ return left / right
199
+ else:
200
+ raise ValueError(f"Unknown operator: {operator}")
201
+
202
+
203
+ def resolve_template(template: str | Any, state: dict[str, Any]) -> Any:
204
+ """Resolve a {state.field} template to its value.
205
+
206
+ Supports:
207
+ - Simple paths: {state.field}
208
+ - Arithmetic: {state.counter + 1}
209
+ - List operations: {state.history + [state.item]}
210
+
211
+ Args:
212
+ template: Template string like "{state.field}" or "{state.a + 1}"
213
+ state: Current pipeline state
214
+
215
+ Returns:
216
+ Resolved value or None if not found
217
+ """
218
+ if not isinstance(template, str):
219
+ return template
220
+
221
+ if not (template.startswith("{") and template.endswith("}")):
222
+ return template
223
+
224
+ # Check for arithmetic expression first
225
+ match = ARITHMETIC_PATTERN.match(template)
226
+ if match:
227
+ left_ref = match.group(1) # e.g., "state.counter"
228
+ operator = match.group(2) # e.g., "+"
229
+ right_str = match.group(3) # e.g., "1" or "state.other"
230
+
231
+ left = _parse_operand(left_ref, state)
232
+ right = _parse_operand(right_str, state)
233
+
234
+ if left is None:
235
+ return None
236
+
237
+ return _apply_operator(left, operator, right)
238
+
239
+ # Simple state path
240
+ STATE_PREFIX = "{state."
241
+ if template.startswith(STATE_PREFIX) and template.endswith("}"):
242
+ path = template[len(STATE_PREFIX) : -1]
243
+ return resolve_state_path(path, state)
244
+
245
+ return template