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,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
|