yamlgraph 0.1.1__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.
Potentially problematic release.
This version of yamlgraph might be problematic. Click here for more details.
- examples/__init__.py +1 -0
- examples/storyboard/__init__.py +1 -0
- examples/storyboard/generate_videos.py +335 -0
- examples/storyboard/nodes/__init__.py +10 -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 +238 -0
- examples/storyboard/retry_images.py +118 -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_map_demo.py +50 -0
- tests/integration/test_memory_demo.py +281 -0
- tests/integration/test_pipeline_flow.py +105 -0
- tests/integration/test_providers.py +163 -0
- tests/integration/test_resume.py +75 -0
- tests/unit/__init__.py +1 -0
- tests/unit/test_agent_nodes.py +200 -0
- tests/unit/test_checkpointer.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 +270 -0
- tests/unit/test_database.py +145 -0
- tests/unit/test_deprecation.py +104 -0
- tests/unit/test_executor.py +60 -0
- tests/unit/test_executor_async.py +179 -0
- tests/unit/test_export.py +150 -0
- tests/unit/test_expressions.py +178 -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_loader.py +299 -0
- tests/unit/test_graph_schema.py +193 -0
- tests/unit/test_inline_schema.py +151 -0
- tests/unit/test_issues.py +164 -0
- tests/unit/test_jinja2_prompts.py +85 -0
- tests/unit/test_langsmith.py +319 -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 +225 -0
- tests/unit/test_prompts.py +166 -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_template.py +190 -0
- tests/unit/test_tool_nodes.py +129 -0
- yamlgraph/__init__.py +35 -0
- yamlgraph/builder.py +110 -0
- yamlgraph/cli/__init__.py +139 -0
- yamlgraph/cli/__main__.py +6 -0
- yamlgraph/cli/commands.py +232 -0
- yamlgraph/cli/deprecation.py +92 -0
- yamlgraph/cli/graph_commands.py +382 -0
- yamlgraph/cli/validators.py +37 -0
- yamlgraph/config.py +67 -0
- yamlgraph/constants.py +66 -0
- yamlgraph/error_handlers.py +226 -0
- yamlgraph/executor.py +275 -0
- yamlgraph/executor_async.py +122 -0
- yamlgraph/graph_loader.py +337 -0
- yamlgraph/map_compiler.py +138 -0
- yamlgraph/models/__init__.py +36 -0
- yamlgraph/models/graph_schema.py +141 -0
- yamlgraph/models/schemas.py +124 -0
- yamlgraph/models/state_builder.py +236 -0
- yamlgraph/node_factory.py +240 -0
- yamlgraph/routing.py +87 -0
- yamlgraph/schema_loader.py +160 -0
- yamlgraph/storage/__init__.py +17 -0
- yamlgraph/storage/checkpointer.py +72 -0
- yamlgraph/storage/database.py +320 -0
- yamlgraph/storage/export.py +269 -0
- yamlgraph/tools/__init__.py +1 -0
- yamlgraph/tools/agent.py +235 -0
- yamlgraph/tools/nodes.py +124 -0
- yamlgraph/tools/python_tool.py +178 -0
- yamlgraph/tools/shell.py +205 -0
- yamlgraph/utils/__init__.py +47 -0
- yamlgraph/utils/conditions.py +157 -0
- yamlgraph/utils/expressions.py +111 -0
- yamlgraph/utils/langsmith.py +308 -0
- yamlgraph/utils/llm_factory.py +118 -0
- yamlgraph/utils/llm_factory_async.py +105 -0
- yamlgraph/utils/logging.py +127 -0
- yamlgraph/utils/prompts.py +116 -0
- yamlgraph/utils/sanitize.py +98 -0
- yamlgraph/utils/template.py +102 -0
- yamlgraph/utils/validators.py +181 -0
- yamlgraph-0.1.1.dist-info/METADATA +854 -0
- yamlgraph-0.1.1.dist-info/RECORD +111 -0
- yamlgraph-0.1.1.dist-info/WHEEL +5 -0
- yamlgraph-0.1.1.dist-info/entry_points.txt +2 -0
- yamlgraph-0.1.1.dist-info/licenses/LICENSE +21 -0
- yamlgraph-0.1.1.dist-info/top_level.txt +3 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Unified prompt loading and path resolution.
|
|
2
|
+
|
|
3
|
+
This module consolidates prompt loading logic used by executor.py
|
|
4
|
+
and node_factory.py into a single, testable module.
|
|
5
|
+
|
|
6
|
+
Search order for prompts:
|
|
7
|
+
1. {prompts_dir}/{prompt_name}.yaml (standard location)
|
|
8
|
+
2. {parent}/prompts/{basename}.yaml (external examples like examples/storyboard/...)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
from yamlgraph.config import PROMPTS_DIR
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def resolve_prompt_path(
|
|
19
|
+
prompt_name: str,
|
|
20
|
+
prompts_dir: Path | None = None,
|
|
21
|
+
) -> Path:
|
|
22
|
+
"""Resolve a prompt name to its full YAML file path.
|
|
23
|
+
|
|
24
|
+
Search order:
|
|
25
|
+
1. prompts_dir/{prompt_name}.yaml (default: prompts/)
|
|
26
|
+
2. {parent}/prompts/{basename}.yaml (for external examples)
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
prompt_name: Prompt name like "greet" or "examples/storyboard/expand_story"
|
|
30
|
+
prompts_dir: Base prompts directory (defaults to PROMPTS_DIR from config)
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Path to the YAML file
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
FileNotFoundError: If prompt file doesn't exist
|
|
37
|
+
|
|
38
|
+
Examples:
|
|
39
|
+
>>> resolve_prompt_path("greet")
|
|
40
|
+
PosixPath('/path/to/prompts/greet.yaml')
|
|
41
|
+
|
|
42
|
+
>>> resolve_prompt_path("map-demo/generate_ideas")
|
|
43
|
+
PosixPath('/path/to/prompts/map-demo/generate_ideas.yaml')
|
|
44
|
+
"""
|
|
45
|
+
if prompts_dir is None:
|
|
46
|
+
prompts_dir = PROMPTS_DIR
|
|
47
|
+
|
|
48
|
+
prompts_dir = Path(prompts_dir)
|
|
49
|
+
|
|
50
|
+
# Try standard location first: prompts_dir/{prompt_name}.yaml
|
|
51
|
+
yaml_path = prompts_dir / f"{prompt_name}.yaml"
|
|
52
|
+
if yaml_path.exists():
|
|
53
|
+
return yaml_path
|
|
54
|
+
|
|
55
|
+
# Try external example location: {parent}/prompts/{basename}.yaml
|
|
56
|
+
# e.g., "examples/storyboard/expand_story" -> "examples/storyboard/prompts/expand_story.yaml"
|
|
57
|
+
parts = prompt_name.rsplit("/", 1)
|
|
58
|
+
if len(parts) == 2:
|
|
59
|
+
parent_dir, basename = parts
|
|
60
|
+
alt_path = Path(parent_dir) / "prompts" / f"{basename}.yaml"
|
|
61
|
+
if alt_path.exists():
|
|
62
|
+
return alt_path
|
|
63
|
+
|
|
64
|
+
raise FileNotFoundError(f"Prompt not found: {yaml_path}")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def load_prompt(
|
|
68
|
+
prompt_name: str,
|
|
69
|
+
prompts_dir: Path | None = None,
|
|
70
|
+
) -> dict:
|
|
71
|
+
"""Load a YAML prompt template.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
prompt_name: Name of the prompt file (without .yaml extension)
|
|
75
|
+
prompts_dir: Optional prompts directory override
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Dictionary with prompt content (typically 'system' and 'user' keys)
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
FileNotFoundError: If prompt file doesn't exist
|
|
82
|
+
"""
|
|
83
|
+
path = resolve_prompt_path(prompt_name, prompts_dir)
|
|
84
|
+
|
|
85
|
+
with open(path) as f:
|
|
86
|
+
return yaml.safe_load(f)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def load_prompt_path(
|
|
90
|
+
prompt_name: str,
|
|
91
|
+
prompts_dir: Path | None = None,
|
|
92
|
+
) -> tuple[Path, dict]:
|
|
93
|
+
"""Load a prompt and return both path and content.
|
|
94
|
+
|
|
95
|
+
Useful when you need both the file path (for schema loading)
|
|
96
|
+
and the content (for prompt execution).
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
prompt_name: Name of the prompt file (without .yaml extension)
|
|
100
|
+
prompts_dir: Optional prompts directory override
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Tuple of (path, content_dict)
|
|
104
|
+
|
|
105
|
+
Raises:
|
|
106
|
+
FileNotFoundError: If prompt file doesn't exist
|
|
107
|
+
"""
|
|
108
|
+
path = resolve_prompt_path(prompt_name, prompts_dir)
|
|
109
|
+
|
|
110
|
+
with open(path) as f:
|
|
111
|
+
content = yaml.safe_load(f)
|
|
112
|
+
|
|
113
|
+
return path, content
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
__all__ = ["resolve_prompt_path", "load_prompt", "load_prompt_path"]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Input sanitization utilities.
|
|
2
|
+
|
|
3
|
+
Provides functions for validating and sanitizing user input
|
|
4
|
+
to prevent prompt injection and other security issues.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from typing import NamedTuple
|
|
9
|
+
|
|
10
|
+
from yamlgraph.config import DANGEROUS_PATTERNS, MAX_TOPIC_LENGTH
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SanitizationResult(NamedTuple):
|
|
14
|
+
"""Result of input sanitization."""
|
|
15
|
+
|
|
16
|
+
value: str
|
|
17
|
+
is_safe: bool
|
|
18
|
+
warnings: list[str]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def sanitize_topic(topic: str) -> SanitizationResult:
|
|
22
|
+
"""Sanitize a topic string for use in prompts.
|
|
23
|
+
|
|
24
|
+
Checks for:
|
|
25
|
+
- Length limits
|
|
26
|
+
- Potential prompt injection patterns
|
|
27
|
+
- Control characters
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
topic: The raw topic string
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
SanitizationResult with cleaned value and safety status
|
|
34
|
+
|
|
35
|
+
Example:
|
|
36
|
+
>>> result = sanitize_topic("machine learning")
|
|
37
|
+
>>> result.is_safe
|
|
38
|
+
True
|
|
39
|
+
>>> result = sanitize_topic("ignore previous instructions")
|
|
40
|
+
>>> result.is_safe
|
|
41
|
+
False
|
|
42
|
+
"""
|
|
43
|
+
warnings = []
|
|
44
|
+
cleaned = topic.strip()
|
|
45
|
+
|
|
46
|
+
# Check length
|
|
47
|
+
if len(cleaned) > MAX_TOPIC_LENGTH:
|
|
48
|
+
cleaned = cleaned[:MAX_TOPIC_LENGTH]
|
|
49
|
+
warnings.append(f"Topic truncated to {MAX_TOPIC_LENGTH} characters")
|
|
50
|
+
|
|
51
|
+
# Check for empty
|
|
52
|
+
if not cleaned:
|
|
53
|
+
return SanitizationResult(
|
|
54
|
+
value="",
|
|
55
|
+
is_safe=False,
|
|
56
|
+
warnings=["Topic cannot be empty"],
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Remove control characters (except newlines)
|
|
60
|
+
cleaned = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", cleaned)
|
|
61
|
+
|
|
62
|
+
# Check for dangerous patterns (case-insensitive)
|
|
63
|
+
topic_lower = cleaned.lower()
|
|
64
|
+
for pattern in DANGEROUS_PATTERNS:
|
|
65
|
+
if pattern.lower() in topic_lower:
|
|
66
|
+
return SanitizationResult(
|
|
67
|
+
value=cleaned,
|
|
68
|
+
is_safe=False,
|
|
69
|
+
warnings=[f"Topic contains potentially unsafe pattern: '{pattern}'"],
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
return SanitizationResult(
|
|
73
|
+
value=cleaned,
|
|
74
|
+
is_safe=True,
|
|
75
|
+
warnings=warnings,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def sanitize_variables(variables: dict) -> dict:
|
|
80
|
+
"""Sanitize a dictionary of template variables.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
variables: Dictionary of variable name -> value
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Sanitized dictionary with cleaned values
|
|
87
|
+
"""
|
|
88
|
+
sanitized = {}
|
|
89
|
+
|
|
90
|
+
for key, value in variables.items():
|
|
91
|
+
if isinstance(value, str):
|
|
92
|
+
# Remove control characters but preserve newlines
|
|
93
|
+
cleaned = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", value)
|
|
94
|
+
sanitized[key] = cleaned
|
|
95
|
+
else:
|
|
96
|
+
sanitized[key] = value
|
|
97
|
+
|
|
98
|
+
return sanitized
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Template utilities - Variable extraction and validation.
|
|
2
|
+
|
|
3
|
+
This module provides functions to extract required variables from
|
|
4
|
+
prompt templates and validate that all required variables are provided
|
|
5
|
+
before execution.
|
|
6
|
+
|
|
7
|
+
Supports both simple {variable} placeholders and Jinja2 templates.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import re
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def extract_variables(template: str) -> set[str]:
|
|
18
|
+
"""Extract all variable names required by a template.
|
|
19
|
+
|
|
20
|
+
Handles both simple {var} and Jinja2 {{ var }}, {% for x in var %} syntax.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
template: Template string with placeholders
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Set of variable names required by the template
|
|
27
|
+
|
|
28
|
+
Examples:
|
|
29
|
+
>>> extract_variables("Hello {name}")
|
|
30
|
+
{'name'}
|
|
31
|
+
|
|
32
|
+
>>> extract_variables("{% for item in items %}{{ item }}{% endfor %}")
|
|
33
|
+
{'items'}
|
|
34
|
+
"""
|
|
35
|
+
variables: set[str] = set()
|
|
36
|
+
|
|
37
|
+
# Simple format: {var} - but NOT {{ (Jinja2)
|
|
38
|
+
# Match {word} but not {{word}} - use negative lookbehind/lookahead
|
|
39
|
+
simple_pattern = r"(?<!\{)\{(\w+)\}(?!\})"
|
|
40
|
+
variables.update(re.findall(simple_pattern, template))
|
|
41
|
+
|
|
42
|
+
# Jinja2 variable: {{ var }} or {{ var.field }}
|
|
43
|
+
jinja_var_pattern = r"\{\{\s*(\w+)"
|
|
44
|
+
variables.update(re.findall(jinja_var_pattern, template))
|
|
45
|
+
|
|
46
|
+
# Jinja2 loop: {% for x in var %}
|
|
47
|
+
jinja_loop_pattern = r"\{%\s*for\s+\w+\s+in\s+(\w+)"
|
|
48
|
+
variables.update(re.findall(jinja_loop_pattern, template))
|
|
49
|
+
|
|
50
|
+
# Jinja2 condition: {% if var %} or {% if var.field %}
|
|
51
|
+
jinja_if_pattern = r"\{%\s*if\s+(\w+)"
|
|
52
|
+
variables.update(re.findall(jinja_if_pattern, template))
|
|
53
|
+
|
|
54
|
+
# Remove loop iteration variables (they're not inputs)
|
|
55
|
+
# e.g., in "{% for item in items %}", "item" is not required
|
|
56
|
+
loop_iter_pattern = r"\{%\s*for\s+(\w+)\s+in"
|
|
57
|
+
loop_vars = set(re.findall(loop_iter_pattern, template))
|
|
58
|
+
variables -= loop_vars
|
|
59
|
+
|
|
60
|
+
# Remove common non-input variables
|
|
61
|
+
# - state: injected by node_factory
|
|
62
|
+
# - loop: Jinja2 loop context
|
|
63
|
+
# - range: Jinja2 builtin function
|
|
64
|
+
excluded = {"state", "loop", "range", "true", "false", "none"}
|
|
65
|
+
variables -= excluded
|
|
66
|
+
|
|
67
|
+
return variables
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def validate_variables(
|
|
71
|
+
template: str,
|
|
72
|
+
provided: dict[str, Any],
|
|
73
|
+
prompt_name: str,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Validate that all required template variables are provided.
|
|
76
|
+
|
|
77
|
+
Raises ValueError with helpful message listing all missing variables.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
template: Template string with placeholders
|
|
81
|
+
provided: Dictionary of provided variable values
|
|
82
|
+
prompt_name: Name of the prompt (for error messages)
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
ValueError: If any required variables are missing
|
|
86
|
+
|
|
87
|
+
Examples:
|
|
88
|
+
>>> validate_variables("Hello {name}", {"name": "World"}, "greet")
|
|
89
|
+
# No error
|
|
90
|
+
|
|
91
|
+
>>> validate_variables("Hello {name}", {}, "greet")
|
|
92
|
+
ValueError: Missing required variable(s) for prompt 'greet': name
|
|
93
|
+
"""
|
|
94
|
+
required = extract_variables(template)
|
|
95
|
+
provided_keys = set(provided.keys())
|
|
96
|
+
missing = required - provided_keys
|
|
97
|
+
|
|
98
|
+
if missing:
|
|
99
|
+
raise ValueError(
|
|
100
|
+
f"Missing required variable(s) for prompt '{prompt_name}': "
|
|
101
|
+
f"{', '.join(sorted(missing))}"
|
|
102
|
+
)
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Graph configuration validators.
|
|
2
|
+
|
|
3
|
+
Validation functions for YAML graph configuration structures.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from yamlgraph.constants import ErrorHandler, NodeType
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def validate_required_sections(config: dict[str, Any]) -> None:
|
|
12
|
+
"""Validate required top-level sections exist.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
config: Parsed YAML configuration dictionary
|
|
16
|
+
|
|
17
|
+
Raises:
|
|
18
|
+
ValueError: If required sections are missing
|
|
19
|
+
"""
|
|
20
|
+
if not config.get("nodes"):
|
|
21
|
+
raise ValueError("Graph config missing required 'nodes' section")
|
|
22
|
+
if not config.get("edges"):
|
|
23
|
+
raise ValueError("Graph config missing required 'edges' section")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def validate_node_prompt(node_name: str, node_config: dict[str, Any]) -> None:
|
|
27
|
+
"""Validate node has required prompt if applicable.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
node_name: Name of the node
|
|
31
|
+
node_config: Node configuration dictionary
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
ValueError: If prompt is required but missing
|
|
35
|
+
"""
|
|
36
|
+
node_type = node_config.get("type", NodeType.LLM)
|
|
37
|
+
# Only llm and router nodes require prompts
|
|
38
|
+
# tool, python, agent, and map nodes don't require prompts
|
|
39
|
+
if NodeType.requires_prompt(node_type) and not node_config.get("prompt"):
|
|
40
|
+
raise ValueError(f"Node '{node_name}' missing required 'prompt' field")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def validate_router_node(
|
|
44
|
+
node_name: str, node_config: dict[str, Any], all_nodes: dict[str, Any]
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Validate router node has routes pointing to valid nodes.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
node_name: Name of the node
|
|
50
|
+
node_config: Node configuration dictionary
|
|
51
|
+
all_nodes: All nodes in the graph for target validation
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
ValueError: If router configuration is invalid
|
|
55
|
+
"""
|
|
56
|
+
if node_config.get("type") != NodeType.ROUTER:
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
if not node_config.get("routes"):
|
|
60
|
+
raise ValueError(f"Router node '{node_name}' missing required 'routes' field")
|
|
61
|
+
|
|
62
|
+
for route_key, target_node in node_config["routes"].items():
|
|
63
|
+
if target_node not in all_nodes:
|
|
64
|
+
raise ValueError(
|
|
65
|
+
f"Router node '{node_name}' route '{route_key}' points to "
|
|
66
|
+
f"nonexistent node '{target_node}'"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def validate_edges(edges: list[dict[str, Any]]) -> None:
|
|
71
|
+
"""Validate each edge has required from/to fields.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
edges: List of edge configurations
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
ValueError: If edge is missing required fields
|
|
78
|
+
"""
|
|
79
|
+
for i, edge in enumerate(edges):
|
|
80
|
+
if "from" not in edge:
|
|
81
|
+
raise ValueError(f"Edge {i} missing required 'from' field")
|
|
82
|
+
if "to" not in edge:
|
|
83
|
+
raise ValueError(f"Edge {i} missing required 'to' field")
|
|
84
|
+
|
|
85
|
+
# Validate condition expressions at load time
|
|
86
|
+
if "condition" in edge:
|
|
87
|
+
validate_condition_expression(edge["condition"], i)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def validate_condition_expression(condition: str, edge_index: int) -> None:
|
|
91
|
+
"""Validate a condition expression has valid syntax.
|
|
92
|
+
|
|
93
|
+
Performs compile-time validation of condition expressions to catch
|
|
94
|
+
syntax errors early rather than at runtime.
|
|
95
|
+
|
|
96
|
+
Supports expression conditions like "score < 0.8", "a.b >= 1 and c == 'done'"
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
condition: Condition expression like "score < 0.8"
|
|
100
|
+
edge_index: Edge index for error messages
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
ValueError: If condition has invalid syntax
|
|
104
|
+
"""
|
|
105
|
+
import re
|
|
106
|
+
|
|
107
|
+
# Expression syntax check - must match comparison pattern
|
|
108
|
+
# Valid: "score < 0.8", "a.b >= 1", "x == 'done'"
|
|
109
|
+
# Also valid: compound expressions "a > 1 and b < 2"
|
|
110
|
+
comparison_pattern = r"[a-zA-Z_][\w.]*\s*(<=|>=|==|!=|<|>)\s*.+"
|
|
111
|
+
compound_pattern = r"\s+(and|or)\s+"
|
|
112
|
+
|
|
113
|
+
# Split by and/or and validate each part
|
|
114
|
+
parts = re.split(compound_pattern, condition, flags=re.IGNORECASE)
|
|
115
|
+
# parts includes the 'and'/'or' tokens, so filter to just comparisons
|
|
116
|
+
comparisons = [p.strip() for p in parts if p.strip().lower() not in ("and", "or")]
|
|
117
|
+
|
|
118
|
+
for part in comparisons:
|
|
119
|
+
if not re.match(comparison_pattern, part.strip()):
|
|
120
|
+
raise ValueError(
|
|
121
|
+
f"Edge {edge_index} has invalid condition syntax: '{condition}'. "
|
|
122
|
+
f"Expected format: 'field <op> value' (e.g., 'score < 0.8')"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def validate_on_error(node_name: str, node_config: dict[str, Any]) -> None:
|
|
127
|
+
"""Validate on_error value is valid.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
node_name: Name of the node
|
|
131
|
+
node_config: Node configuration dictionary
|
|
132
|
+
|
|
133
|
+
Raises:
|
|
134
|
+
ValueError: If on_error value is invalid
|
|
135
|
+
"""
|
|
136
|
+
on_error = node_config.get("on_error")
|
|
137
|
+
if on_error and on_error not in ErrorHandler.all_values():
|
|
138
|
+
raise ValueError(
|
|
139
|
+
f"Node '{node_name}' has invalid on_error value '{on_error}'. "
|
|
140
|
+
f"Valid values: {', '.join(ErrorHandler.all_values())}"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def validate_map_node(node_name: str, node_config: dict[str, Any]) -> None:
|
|
145
|
+
"""Validate map node has required fields.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
node_name: Name of the node
|
|
149
|
+
node_config: Node configuration dictionary
|
|
150
|
+
|
|
151
|
+
Raises:
|
|
152
|
+
ValueError: If map node configuration is invalid
|
|
153
|
+
"""
|
|
154
|
+
if node_config.get("type") != NodeType.MAP:
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
required_fields = ["over", "as", "node", "collect"]
|
|
158
|
+
for field in required_fields:
|
|
159
|
+
if field not in node_config:
|
|
160
|
+
raise ValueError(f"Map node '{node_name}' missing required '{field}' field")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def validate_config(config: dict[str, Any]) -> None:
|
|
164
|
+
"""Validate YAML configuration structure.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
config: Parsed YAML dictionary
|
|
168
|
+
|
|
169
|
+
Raises:
|
|
170
|
+
ValueError: If required fields are missing or invalid
|
|
171
|
+
"""
|
|
172
|
+
validate_required_sections(config)
|
|
173
|
+
|
|
174
|
+
nodes = config["nodes"]
|
|
175
|
+
for node_name, node_config in nodes.items():
|
|
176
|
+
validate_node_prompt(node_name, node_config)
|
|
177
|
+
validate_router_node(node_name, node_config, nodes)
|
|
178
|
+
validate_on_error(node_name, node_config)
|
|
179
|
+
validate_map_node(node_name, node_config)
|
|
180
|
+
|
|
181
|
+
validate_edges(config["edges"])
|