diff --git a/api/core/app/app_config/workflow_ui_based_app/variables/manager.py b/api/core/app/app_config/workflow_ui_based_app/variables/manager.py index 13ace32fd6..c02a5aa3d8 100644 --- a/api/core/app/app_config/workflow_ui_based_app/variables/manager.py +++ b/api/core/app/app_config/workflow_ui_based_app/variables/manager.py @@ -1,4 +1,6 @@ +import json import re +from typing import Any from core.app.app_config.entities import RagPipelineVariableEntity from graphon.variables.input_entities import VariableEntity @@ -20,10 +22,32 @@ class WorkflowVariablesConfigManager: # variables for variable in user_input_form: + cls._normalize_json_schema(variable) variables.append(VariableEntity.model_validate(variable)) return variables + @staticmethod + def _normalize_json_schema(variable: dict[str, Any]) -> None: + """ + Normalize ``json_schema`` from a JSON string to a dict. + + The workflow graph is stored as JSON in the database. When a JSON + object variable carries a ``json_schema`` field, nested dicts are + preserved correctly, but older data or certain serialization paths + may store it as a JSON *string* instead of a native dict. + + ``VariableEntity.json_schema`` expects ``dict | None``, so we + deserialize the string here before handing it to Pydantic. + """ + json_schema = variable.get("json_schema") + if isinstance(json_schema, str): + try: + variable["json_schema"] = json.loads(json_schema) + except (json.JSONDecodeError, TypeError): + # Leave as-is; Pydantic validation will surface the error. + pass + @classmethod def convert_rag_pipeline_variable(cls, workflow: Workflow, start_node_id: str) -> list[RagPipelineVariableEntity]: """ diff --git a/api/tests/unit_tests/core/app/app_config/workflow_ui_based_app/test_workflow_ui_based_app_manager.py b/api/tests/unit_tests/core/app/app_config/workflow_ui_based_app/test_workflow_ui_based_app_manager.py index dacd69a578..e55109e296 100644 --- a/api/tests/unit_tests/core/app/app_config/workflow_ui_based_app/test_workflow_ui_based_app_manager.py +++ b/api/tests/unit_tests/core/app/app_config/workflow_ui_based_app/test_workflow_ui_based_app_manager.py @@ -74,6 +74,87 @@ class TestWorkflowVariablesConfigManagerConvert: with pytest.raises(ValueError): WorkflowVariablesConfigManager.convert(mock_workflow) + def test_convert_normalizes_json_schema_string_to_dict(self, mock_workflow, mock_variable_entity): + """Regression test for #36766: json_schema stored as a JSON string.""" + import json + + schema_dict = {"type": "object", "properties": {"name": {"type": "string"}}} + input_variables = [ + { + "variable": "profile", + "label": "Profile", + "type": "json_object", + "json_schema": json.dumps(schema_dict), + } + ] + mock_workflow.user_input_form.return_value = input_variables + mock_variable_entity.model_validate.side_effect = lambda x: x + + # Act + result = WorkflowVariablesConfigManager.convert(mock_workflow) + + # Assert — the string was deserialized before reaching model_validate + assert result[0]["json_schema"] == schema_dict + assert isinstance(result[0]["json_schema"], dict) + + def test_convert_normalizes_json_schema_dict_passthrough(self, mock_workflow, mock_variable_entity): + """json_schema already a dict should pass through unchanged.""" + schema_dict = {"type": "object", "properties": {"age": {"type": "number"}}} + input_variables = [ + { + "variable": "profile", + "label": "Profile", + "type": "json_object", + "json_schema": schema_dict, + } + ] + mock_workflow.user_input_form.return_value = input_variables + mock_variable_entity.model_validate.side_effect = lambda x: x + + # Act + result = WorkflowVariablesConfigManager.convert(mock_workflow) + + # Assert + assert result[0]["json_schema"] == schema_dict + + def test_convert_normalizes_json_schema_none_passthrough(self, mock_workflow, mock_variable_entity): + """json_schema=None should pass through unchanged.""" + input_variables = [ + { + "variable": "name", + "label": "Name", + "type": "text-input", + "json_schema": None, + } + ] + mock_workflow.user_input_form.return_value = input_variables + mock_variable_entity.model_validate.side_effect = lambda x: x + + # Act + result = WorkflowVariablesConfigManager.convert(mock_workflow) + + # Assert + assert result[0]["json_schema"] is None + + def test_convert_normalizes_json_schema_invalid_string_passthrough(self, mock_workflow, mock_variable_entity): + """Invalid JSON string should be left as-is for Pydantic to reject.""" + input_variables = [ + { + "variable": "profile", + "label": "Profile", + "type": "json_object", + "json_schema": "{invalid-json", + } + ] + mock_workflow.user_input_form.return_value = input_variables + mock_variable_entity.model_validate.side_effect = lambda x: x + + # Act + result = WorkflowVariablesConfigManager.convert(mock_workflow) + + # Assert — string left as-is + assert result[0]["json_schema"] == "{invalid-json" + # ============================= # Test convert_rag_pipeline_variable