From af095ea178d9a5bc64a530a1d94ee8224ac996bb Mon Sep 17 00:00:00 2001 From: Yansong Zhang <916125788@qq.com> Date: Wed, 14 Oct 2026 10:25:22 +0800 Subject: [PATCH] fix(variables): resolve hyphenated node references --- src/graphon/nodes/base/template.py | 2 +- .../nodes/base/variable_template_parser.py | 4 +-- src/graphon/variables/template_resolution.py | 2 +- tests/nodes/base/test_template.py | 13 +++++++++ .../base/test_variable_template_parser.py | 22 ++++++++++++++ tests/nodes/llm/test_node.py | 29 +++++++++++++++++++ tests/variables/test_template_resolution.py | 27 +++++++++++++++++ 7 files changed, 95 insertions(+), 4 deletions(-) diff --git a/src/graphon/nodes/base/template.py b/src/graphon/nodes/base/template.py index 5f9a78ef..b21b95be 100644 --- a/src/graphon/nodes/base/template.py +++ b/src/graphon/nodes/base/template.py @@ -85,7 +85,7 @@ def from_answer_template(cls, template_str: str) -> Template: # We need to split the template by variable placeholders while preserving order # Create a regex pattern that matches variable placeholders pattern = ( - r"\{\{(#[a-zA-Z0-9_]{1,50}(?:\.[a-zA-Z_][a-zA-Z0-9_]{0,29}){1,10}#)\}\}" + r"\{\{(#[a-zA-Z0-9_-]{1,50}(?:\.[a-zA-Z_][a-zA-Z0-9_]{0,29}){1,10}#)\}\}" ) # Split template while keeping the delimiters (variable placeholders) diff --git a/src/graphon/nodes/base/variable_template_parser.py b/src/graphon/nodes/base/variable_template_parser.py index 6e1f6911..c5bbf203 100644 --- a/src/graphon/nodes/base/variable_template_parser.py +++ b/src/graphon/nodes/base/variable_template_parser.py @@ -7,11 +7,11 @@ from .entities import VariableSelector REGEX = re.compile( - r"\{\{(#[a-zA-Z0-9_]{1,50}(\.[a-zA-Z_][a-zA-Z0-9_]{0,29}){1,10}#)\}\}", + r"\{\{(#[a-zA-Z0-9_-]{1,50}(\.[a-zA-Z_][a-zA-Z0-9_]{0,29}){1,10}#)\}\}", ) SELECTOR_PATTERN = re.compile( - r"\{\{(#[a-zA-Z0-9_]{1,50}(?:\.[a-zA-Z_][a-zA-Z0-9_]{0,29}){1,10}#)\}\}", + r"\{\{(#[a-zA-Z0-9_-]{1,50}(?:\.[a-zA-Z_][a-zA-Z0-9_]{0,29}){1,10}#)\}\}", ) diff --git a/src/graphon/variables/template_resolution.py b/src/graphon/variables/template_resolution.py index b54ccf73..33b8d81a 100644 --- a/src/graphon/variables/template_resolution.py +++ b/src/graphon/variables/template_resolution.py @@ -10,7 +10,7 @@ from graphon.variables.segments import Segment VARIABLE_PATTERN = re.compile( - r"\{\{#([a-zA-Z0-9_]{1,50}(?:\.[a-zA-Z_][a-zA-Z0-9_]{0,29}){1,10})#\}\}", + r"\{\{#([a-zA-Z0-9_-]{1,50}(?:\.[a-zA-Z_][a-zA-Z0-9_]{0,29}){1,10})#\}\}", ) diff --git a/tests/nodes/base/test_template.py b/tests/nodes/base/test_template.py index 07a19d65..0acfb44d 100644 --- a/tests/nodes/base/test_template.py +++ b/tests/nodes/base/test_template.py @@ -2,6 +2,19 @@ class TestTemplate: + def test_from_answer_template_preserves_hyphenated_selectors(self) -> None: + template = Template.from_answer_template( + "Words: {{#input-check.result.word#}} / {{#input_check.word#}}.", + ) + + assert template.segments == [ + TextSegment("Words: "), + VariableSegment(["input-check", "result", "word"]), + TextSegment(" / "), + VariableSegment(["input_check", "word"]), + TextSegment("."), + ] + def test_from_answer_template_simple(self) -> None: template_str = "Hello, {{#node1.name#}}!" template = Template.from_answer_template(template_str) diff --git a/tests/nodes/base/test_variable_template_parser.py b/tests/nodes/base/test_variable_template_parser.py index f65c85e2..ab3c9f7f 100644 --- a/tests/nodes/base/test_variable_template_parser.py +++ b/tests/nodes/base/test_variable_template_parser.py @@ -1,9 +1,31 @@ import dataclasses +import pytest + from graphon.nodes.base import variable_template_parser from graphon.nodes.base.entities import VariableSelector +@pytest.mark.parametrize( + "node_id", + ["input-check", "550e8400-e29b-41d4-a716-446655440000", "-input-"], +) +def test_extracts_and_formats_hyphenated_node_references(node_id: str) -> None: + variable = "#" + node_id + ".result.word#" + template = "Word: {{" + variable + "}}" + expected = [ + VariableSelector(variable=variable, value_selector=[node_id, "result", "word"]), + ] + + parser = variable_template_parser.VariableTemplateParser(template) + + assert ( + variable_template_parser.extract_selectors_from_template(template) == expected + ) + assert parser.extract_variable_selectors() == expected + assert parser.format({variable: "red"}) == "Word: red" + + def test_extract_selectors_from_template() -> None: template = ( "Hello, {{#sys.user_id#}}! Your query is {{#node_id.custom_query#}}. " diff --git a/tests/nodes/llm/test_node.py b/tests/nodes/llm/test_node.py index 8682b122..dc221998 100644 --- a/tests/nodes/llm/test_node.py +++ b/tests/nodes/llm/test_node.py @@ -478,6 +478,35 @@ def test_fetch_inputs_normalizes_none_to_empty_string() -> None: assert node._fetch_inputs(node.node_data) == {"#source.value#": ""} +@pytest.mark.parametrize("word", ["red", "blue"]) +def test_run_sends_hyphenated_node_output_to_model(word: str) -> None: + model = MagicMock( + provider="openai", + model_name="gpt-4o", + parameters={}, + stop=(), + ) + model.get_model_schema.return_value.supports_prompt_content_type.return_value = True + model.invoke_llm.return_value = _stream_results( + _stream_chunk("ok", usage=LLMUsage.empty_usage(), finish_reason="stop"), + ) + node = _build_llm_node( + model_instance=model, + prompt_text="Please process: {{#input-check.validated_input#}}", + variables=[(("input-check", "validated_input"), word)], + ) + + completed = next( + event for event in node._run() if isinstance(event, StreamCompletedEvent) + ) + + assert completed.node_run_result.status == WorkflowNodeExecutionStatus.SUCCEEDED + model.invoke_llm.assert_called_once() + messages = model.invoke_llm.call_args.kwargs["prompt_messages"] + assert messages[0].content == f"Please process: {word}" + assert completed.node_run_result.inputs["#input-check.validated_input#"] == word + + def test_run_does_not_reuse_file_outputs_after_failure( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/variables/test_template_resolution.py b/tests/variables/test_template_resolution.py index 6bf3e855..cb7dca41 100644 --- a/tests/variables/test_template_resolution.py +++ b/tests/variables/test_template_resolution.py @@ -1,3 +1,5 @@ +import pytest + from graphon.runtime.read_only_wrappers import ReadOnlyVariablePoolWrapper from graphon.runtime.variable_pool import VariablePool from graphon.variables.segments import ArrayObjectSegment @@ -5,6 +7,31 @@ class TestConvertTemplate: + @pytest.mark.parametrize( + "node_id", + ["input-check", "550e8400-e29b-41d4-a716-446655440000", "-input-"], + ) + def test_resolves_hyphenated_node_ids(self, node_id: str) -> None: + pool = VariablePool.empty() + pool.add((node_id, "result"), {"word": "red"}) + pool.add(("input_check", "result"), {"word": "blue"}) + template = "{{#" + node_id + ".result.word#}} / {{#input_check.result.word#}}" + + rendered = convert_template(ReadOnlyVariablePoolWrapper(pool), template) + + assert rendered.text == "red / blue" + + @pytest.mark.parametrize( + "selector", + ["input check.word", "input-check.bad-name", "a" * 51 + ".word"], + ) + def test_preserves_unsupported_references(self, selector: str) -> None: + pool = VariablePool.empty() + pool.add(selector.split("."), "must not be substituted") + template = "{{#" + selector + "#}}" + + assert convert_template(pool, template).text == template + def test_resolves_variables_from_read_only_pool(self) -> None: pool = VariablePool.empty() pool.add(("start", "name"), "Joe")