soprano-sdk 0.2.2__tar.gz → 0.2.3__tar.gz
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.
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/PKG-INFO +1 -1
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/pyproject.toml +1 -1
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/__init__.py +2 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/authenticators/mfa.py +25 -13
- soprano_sdk-0.2.3/soprano_sdk/core/constants.py +114 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/core/engine.py +18 -5
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/core/state.py +1 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/nodes/call_function.py +1 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/nodes/collect_input.py +3 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/tools.py +9 -3
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/validation/validator.py +34 -17
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/tests/test_mfa_scenarios.py +16 -25
- soprano_sdk-0.2.3/uv.lock +5163 -0
- soprano_sdk-0.2.2/examples/concert_booking/README.md +0 -108
- soprano_sdk-0.2.2/examples/concert_booking/TEST_RESULTS.md +0 -179
- soprano_sdk-0.2.2/examples/debit_card_block.yaml +0 -608
- soprano_sdk-0.2.2/soprano_sdk/core/constants.py +0 -76
- soprano_sdk-0.2.2/tests/test_mfa_error_handling.py +0 -778
- soprano_sdk-0.2.2/tests/test_mfa_validation.py +0 -545
- soprano_sdk-0.2.2/uv.lock +0 -5165
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/.github/workflows/test_build_and_publish.yaml +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/.gitignore +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/.python-version +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/CLAUDE.md +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/LICENSE +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/README.md +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/concert_booking/__init__.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/concert_booking/booking_helpers.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/concert_booking/concert_ticket_booking.yaml +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/framework_example.yaml +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/greeting_functions.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/greeting_workflow.yaml +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/main.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/persistence/README.md +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/persistence/conversation_based.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/persistence/entity_based.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/persistence/mongodb_demo.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/return_functions.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/return_workflow.yaml +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/structured_output_example.yaml +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/supervisors/README.md +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/supervisors/crewai_supervisor_ui.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/supervisors/langgraph_supervisor_ui.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/supervisors/tools/__init__.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/supervisors/tools/crewai_tools.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/supervisors/tools/langgraph_tools.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/supervisors/workflow_tools.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/tools/__init__.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/tools/address.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/examples/validator.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/legacy/langgraph_demo.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/legacy/langgraph_selfloop_demo.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/legacy/langgraph_v.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/legacy/main.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/legacy/return_fsm.excalidraw +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/legacy/return_state_machine.png +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/legacy/ui.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/scripts/visualize_workflow.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/scripts/workflow_demo.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/scripts/workflow_demo_ui.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/agents/__init__.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/agents/adaptor.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/agents/factory.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/agents/structured_output.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/authenticators/__init__.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/core/__init__.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/core/rollback_strategies.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/engine.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/nodes/__init__.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/nodes/base.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/nodes/factory.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/routing/__init__.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/routing/router.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/utils/__init__.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/utils/function.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/utils/logger.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/utils/template.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/utils/tool.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/utils/tracing.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/validation/__init__.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/soprano_sdk/validation/schema.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/tests/debug_jinja2.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/tests/test_agent_factory.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/tests/test_collect_input_refactor.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/tests/test_external_values.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/tests/test_inputs_validation.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/tests/test_jinja2_path.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/tests/test_jinja2_standalone.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/tests/test_persistence.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/tests/test_structured_output.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/tests/test_transition_routing.py +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/todo.md +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/workflow-visualizer/.eslintrc.cjs +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/workflow-visualizer/.gitignore +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/workflow-visualizer/README.md +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/workflow-visualizer/index.html +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/workflow-visualizer/package-lock.json +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/workflow-visualizer/package.json +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/workflow-visualizer/src/App.jsx +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/workflow-visualizer/src/CustomNode.jsx +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/workflow-visualizer/src/StepDetailsModal.jsx +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/workflow-visualizer/src/WorkflowGraph.jsx +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/workflow-visualizer/src/WorkflowInfoPanel.jsx +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/workflow-visualizer/src/assets/react.svg +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/workflow-visualizer/src/main.jsx +0 -0
- {soprano_sdk-0.2.2 → soprano_sdk-0.2.3}/workflow-visualizer/vite.config.js +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "soprano-sdk"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.3"
|
|
8
8
|
description = "YAML-driven workflow engine with AI agent integration for building conversational SOPs"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from .core.engine import WorkflowEngine, load_workflow
|
|
2
|
+
from .core.constants import MFAConfig
|
|
2
3
|
from .tools import WorkflowTool
|
|
3
4
|
|
|
4
5
|
__version__ = "0.1.0"
|
|
@@ -6,5 +7,6 @@ __version__ = "0.1.0"
|
|
|
6
7
|
__all__ = [
|
|
7
8
|
"WorkflowEngine",
|
|
8
9
|
"load_workflow",
|
|
10
|
+
"MFAConfig",
|
|
9
11
|
"WorkflowTool",
|
|
10
12
|
]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import requests
|
|
2
|
-
from typing import TypedDict, Literal, NotRequired
|
|
3
|
-
from soprano_sdk.core.constants import
|
|
2
|
+
from typing import TypedDict, Literal, NotRequired, Optional
|
|
3
|
+
from soprano_sdk.core.constants import MFAConfig
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
class MFAChallenge(TypedDict):
|
|
@@ -28,15 +28,21 @@ def build_path(base_url: str, path: str):
|
|
|
28
28
|
return f"{base_url.rstrip('/')}/{path.lstrip('/')}"
|
|
29
29
|
|
|
30
30
|
|
|
31
|
-
def enforce_mfa_if_required(state: dict):
|
|
31
|
+
def enforce_mfa_if_required(state: dict, mfa_config: Optional[MFAConfig] = None):
|
|
32
|
+
if mfa_config is None:
|
|
33
|
+
mfa_config = state.get('_mfa_config') or MFAConfig()
|
|
34
|
+
|
|
32
35
|
_mfa : MFAState = state['_mfa']
|
|
33
36
|
if _mfa['status'] == 'COMPLETED':
|
|
34
37
|
return True
|
|
35
38
|
generate_token_response = requests.post(
|
|
36
39
|
build_path(
|
|
37
|
-
base_url=
|
|
38
|
-
path=
|
|
39
|
-
),
|
|
40
|
+
base_url=mfa_config.generate_token_base_url,
|
|
41
|
+
path=mfa_config.generate_token_path
|
|
42
|
+
),
|
|
43
|
+
json=_mfa['post_payload'],
|
|
44
|
+
timeout=mfa_config.api_timeout,
|
|
45
|
+
headers={"Authorization": f"Bearer {state['bearer_token']}"}
|
|
40
46
|
)
|
|
41
47
|
_, error = get_response(generate_token_response)
|
|
42
48
|
|
|
@@ -50,7 +56,10 @@ def enforce_mfa_if_required(state: dict):
|
|
|
50
56
|
return False
|
|
51
57
|
|
|
52
58
|
|
|
53
|
-
def mfa_validate_user_input(**state: dict):
|
|
59
|
+
def mfa_validate_user_input(mfa_config: Optional[MFAConfig] = None, **state: dict):
|
|
60
|
+
if mfa_config is None:
|
|
61
|
+
mfa_config = state.get('_mfa_config') or MFAConfig()
|
|
62
|
+
|
|
54
63
|
_mfa : MFAState = state['_mfa']
|
|
55
64
|
input_field_name = state['_active_input_field']
|
|
56
65
|
if not state[input_field_name]:
|
|
@@ -61,9 +70,12 @@ def mfa_validate_user_input(**state: dict):
|
|
|
61
70
|
post_payload.update({challenge_field_name: {"value": state[input_field_name]}})
|
|
62
71
|
validate_token_response = requests.post(
|
|
63
72
|
build_path(
|
|
64
|
-
base_url=
|
|
65
|
-
path=
|
|
66
|
-
),
|
|
73
|
+
base_url=mfa_config.validate_token_base_url,
|
|
74
|
+
path=mfa_config.validate_token_path
|
|
75
|
+
),
|
|
76
|
+
json=post_payload,
|
|
77
|
+
timeout=mfa_config.api_timeout,
|
|
78
|
+
headers={"Authorization": f"Bearer {state['bearer_token']}"}
|
|
67
79
|
)
|
|
68
80
|
_mfa['retry_count'] += 1
|
|
69
81
|
response, error = get_response(validate_token_response)
|
|
@@ -78,11 +90,11 @@ def mfa_validate_user_input(**state: dict):
|
|
|
78
90
|
|
|
79
91
|
authorize = requests.post(
|
|
80
92
|
build_path(
|
|
81
|
-
base_url=
|
|
82
|
-
path=
|
|
93
|
+
base_url=mfa_config.authorize_token_base_url,
|
|
94
|
+
path=mfa_config.authorize_token_path
|
|
83
95
|
),
|
|
84
96
|
json=post_payload,
|
|
85
|
-
timeout=
|
|
97
|
+
timeout=mfa_config.api_timeout,
|
|
86
98
|
headers={"Authorization": f"Bearer {state['bearer_token']}"}
|
|
87
99
|
)
|
|
88
100
|
if authorize.status_code == 204:
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class WorkflowKeys:
|
|
8
|
+
STEP_ID = '_step_id'
|
|
9
|
+
STATUS = '_status'
|
|
10
|
+
OUTCOME_ID = '_outcome_id'
|
|
11
|
+
MESSAGES = '_messages'
|
|
12
|
+
CONVERSATIONS = '_conversations'
|
|
13
|
+
STATE_HISTORY = '_state_history'
|
|
14
|
+
COLLECTOR_NODES = '_collector_nodes'
|
|
15
|
+
ATTEMPT_COUNTS = '_attempt_counts'
|
|
16
|
+
NODE_EXECUTION_ORDER = '_node_execution_order'
|
|
17
|
+
NODE_FIELD_MAP = '_node_field_map'
|
|
18
|
+
COMPUTED_FIELDS = '_computed_fields'
|
|
19
|
+
ERROR = 'error'
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ActionType(Enum):
|
|
23
|
+
COLLECT_INPUT_WITH_AGENT = 'collect_input_with_agent'
|
|
24
|
+
CALL_FUNCTION = 'call_function'
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DataType(Enum):
|
|
28
|
+
TEXT = 'text'
|
|
29
|
+
NUMBER = 'number'
|
|
30
|
+
DOUBLE = 'double'
|
|
31
|
+
BOOLEAN = 'boolean'
|
|
32
|
+
LIST = 'list'
|
|
33
|
+
DICT = 'dict'
|
|
34
|
+
ANY = "any"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class OutcomeType(Enum):
|
|
38
|
+
SUCCESS = 'success'
|
|
39
|
+
FAILURE = 'failure'
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class StatusPattern:
|
|
43
|
+
COLLECTING = '{step_id}_collecting'
|
|
44
|
+
MAX_ATTEMPTS = '{step_id}_max_attempts'
|
|
45
|
+
NEXT_STEP = '{step_id}_{next_step}'
|
|
46
|
+
SUCCESS = '{step_id}_success'
|
|
47
|
+
FAILED = '{step_id}_failed'
|
|
48
|
+
INTENT_CHANGE = '{step_id}_{target_node}'
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TransitionPattern:
|
|
52
|
+
CAPTURED = '{field}_CAPTURED:'
|
|
53
|
+
FAILED = '{field}_FAILED:'
|
|
54
|
+
INTENT_CHANGE = 'INTENT_CHANGE:'
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
DEFAULT_MAX_ATTEMPTS = 3
|
|
58
|
+
DEFAULT_MODEL = 'gpt-4o-mini'
|
|
59
|
+
DEFAULT_TIMEOUT = 300
|
|
60
|
+
|
|
61
|
+
MAX_ATTEMPTS_MESSAGE = "I'm having trouble understanding your {field}. Please contact customer service for assistance."
|
|
62
|
+
WORKFLOW_COMPLETE_MESSAGE = "Workflow completed."
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class MFAConfig(BaseSettings):
|
|
66
|
+
"""
|
|
67
|
+
Configuration for MFA REST API endpoints.
|
|
68
|
+
|
|
69
|
+
Values can be provided during initialization or will be automatically
|
|
70
|
+
loaded from environment variables with the same name (uppercase).
|
|
71
|
+
|
|
72
|
+
Example:
|
|
73
|
+
# Load from environment variables
|
|
74
|
+
config = MFAConfig()
|
|
75
|
+
|
|
76
|
+
# Or provide specific values
|
|
77
|
+
config = MFAConfig(
|
|
78
|
+
generate_token_base_url="https://api.example.com",
|
|
79
|
+
generate_token_path="/v1/mfa/generate"
|
|
80
|
+
)
|
|
81
|
+
"""
|
|
82
|
+
generate_token_base_url: Optional[str] = Field(
|
|
83
|
+
default=None,
|
|
84
|
+
description="Base URL for the generate token endpoint"
|
|
85
|
+
)
|
|
86
|
+
generate_token_path: Optional[str] = Field(
|
|
87
|
+
default=None,
|
|
88
|
+
description="Path for the generate token endpoint"
|
|
89
|
+
)
|
|
90
|
+
validate_token_base_url: Optional[str] = Field(
|
|
91
|
+
default=None,
|
|
92
|
+
description="Base URL for the validate token endpoint"
|
|
93
|
+
)
|
|
94
|
+
validate_token_path: Optional[str] = Field(
|
|
95
|
+
default=None,
|
|
96
|
+
description="Path for the validate token endpoint"
|
|
97
|
+
)
|
|
98
|
+
authorize_token_base_url: Optional[str] = Field(
|
|
99
|
+
default=None,
|
|
100
|
+
description="Base URL for the authorize token endpoint"
|
|
101
|
+
)
|
|
102
|
+
authorize_token_path: Optional[str] = Field(
|
|
103
|
+
default=None,
|
|
104
|
+
description="Path for the authorize token endpoint"
|
|
105
|
+
)
|
|
106
|
+
api_timeout: int = Field(
|
|
107
|
+
default=30,
|
|
108
|
+
description="API request timeout in seconds"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
model_config = SettingsConfigDict(
|
|
112
|
+
case_sensitive=False,
|
|
113
|
+
extra='ignore'
|
|
114
|
+
)
|
|
@@ -7,7 +7,7 @@ from langgraph.constants import START
|
|
|
7
7
|
from langgraph.graph import StateGraph
|
|
8
8
|
from langgraph.graph.state import CompiledStateGraph
|
|
9
9
|
|
|
10
|
-
from .constants import WorkflowKeys
|
|
10
|
+
from .constants import WorkflowKeys, MFAConfig
|
|
11
11
|
from .state import create_state_model
|
|
12
12
|
from ..nodes.factory import NodeFactory
|
|
13
13
|
from ..routing.router import WorkflowRouter
|
|
@@ -19,7 +19,7 @@ from soprano_sdk.authenticators.mfa import MFANodeConfig
|
|
|
19
19
|
|
|
20
20
|
class WorkflowEngine:
|
|
21
21
|
|
|
22
|
-
def __init__(self, yaml_path: str, configs: dict):
|
|
22
|
+
def __init__(self, yaml_path: str, configs: dict, mfa_config: Optional[MFAConfig] = None):
|
|
23
23
|
self.yaml_path = yaml_path
|
|
24
24
|
self.configs = configs or {}
|
|
25
25
|
logger.info(f"Loading workflow from: {yaml_path}")
|
|
@@ -29,7 +29,7 @@ class WorkflowEngine:
|
|
|
29
29
|
self.config = yaml.safe_load(f)
|
|
30
30
|
|
|
31
31
|
logger.info("Validating workflow configuration")
|
|
32
|
-
validate_workflow(self.config)
|
|
32
|
+
validate_workflow(self.config, mfa_config=mfa_config or MFAConfig())
|
|
33
33
|
|
|
34
34
|
self.workflow_name = self.config['name']
|
|
35
35
|
self.workflow_description = self.config['description']
|
|
@@ -37,6 +37,7 @@ class WorkflowEngine:
|
|
|
37
37
|
self.mfa_validator_steps: set[str] = set()
|
|
38
38
|
self.steps: list = self.load_steps()
|
|
39
39
|
self.step_map = {step['id']: step for step in self.steps}
|
|
40
|
+
self.mfa_config = (mfa_config or MFAConfig()) if self.mfa_validator_steps else None
|
|
40
41
|
self.data_fields = self.load_data()
|
|
41
42
|
|
|
42
43
|
self.outcomes = self.config['outcomes']
|
|
@@ -259,7 +260,7 @@ class WorkflowEngine:
|
|
|
259
260
|
return data
|
|
260
261
|
|
|
261
262
|
|
|
262
|
-
def load_workflow(yaml_path: str, checkpointer=None, config=None) -> Tuple[CompiledStateGraph, WorkflowEngine]:
|
|
263
|
+
def load_workflow(yaml_path: str, checkpointer=None, config=None, mfa_config: Optional[MFAConfig] = None) -> Tuple[CompiledStateGraph, WorkflowEngine]:
|
|
263
264
|
"""
|
|
264
265
|
Load a workflow from YAML configuration.
|
|
265
266
|
|
|
@@ -270,6 +271,8 @@ def load_workflow(yaml_path: str, checkpointer=None, config=None) -> Tuple[Compi
|
|
|
270
271
|
checkpointer: Optional checkpointer for state persistence.
|
|
271
272
|
Defaults to InMemorySaver() if not provided.
|
|
272
273
|
Example: MongoDBSaver for production persistence.
|
|
274
|
+
config: Optional configuration dictionary
|
|
275
|
+
mfa_config: Optional MFA configuration. If not provided, will load from environment variables.
|
|
273
276
|
|
|
274
277
|
Returns:
|
|
275
278
|
Tuple of (compiled_graph, engine) where:
|
|
@@ -278,11 +281,21 @@ def load_workflow(yaml_path: str, checkpointer=None, config=None) -> Tuple[Compi
|
|
|
278
281
|
|
|
279
282
|
Example:
|
|
280
283
|
```python
|
|
284
|
+
# Load with environment variables
|
|
281
285
|
graph, engine = load_workflow("workflow.yaml")
|
|
286
|
+
|
|
287
|
+
# Or provide MFA configuration explicitly
|
|
288
|
+
from soprano_sdk.core.constants import MFAConfig
|
|
289
|
+
mfa_config = MFAConfig(
|
|
290
|
+
generate_token_base_url="https://api.example.com",
|
|
291
|
+
generate_token_path="/v1/mfa/generate"
|
|
292
|
+
)
|
|
293
|
+
graph, engine = load_workflow("workflow.yaml", mfa_config=mfa_config)
|
|
294
|
+
|
|
282
295
|
result = graph.invoke({}, config={"configurable": {"thread_id": "123"}})
|
|
283
296
|
message = engine.get_outcome_message(result)
|
|
284
297
|
```
|
|
285
298
|
"""
|
|
286
|
-
engine = WorkflowEngine(yaml_path, configs=config)
|
|
299
|
+
engine = WorkflowEngine(yaml_path, configs=config, mfa_config=mfa_config)
|
|
287
300
|
graph = engine.build_graph(checkpointer=checkpointer)
|
|
288
301
|
return graph, engine
|
|
@@ -48,6 +48,7 @@ def create_state_model(data_fields: List[dict]):
|
|
|
48
48
|
fields['_computed_fields'] = Annotated[List[str], replace]
|
|
49
49
|
fields['error'] = Annotated[Optional[Dict[str, str]], replace]
|
|
50
50
|
fields['_mfa'] = Annotated[Optional[Dict[str, str]], replace]
|
|
51
|
+
fields['_mfa_config'] = Annotated[Optional[Any], replace]
|
|
51
52
|
fields['mfa_input'] = Annotated[Optional[Dict[str, str]], replace]
|
|
52
53
|
|
|
53
54
|
return types.new_class('WorkflowState', (TypedDict,), {}, lambda ns: ns.update({'__annotations__': fields}))
|
|
@@ -39,6 +39,7 @@ class CallFunctionStrategy(ActionStrategy):
|
|
|
39
39
|
if 'mfa' in self.step_config:
|
|
40
40
|
state['_mfa'] = state.get('_mfa', {})
|
|
41
41
|
state['_mfa']['post_payload'] = dict(transactionId=str(uuid.uuid4()))
|
|
42
|
+
state['_mfa_config'] = self.engine_context.mfa_config
|
|
42
43
|
template_loader = self.engine_context.get_config_value("template_loader", Environment())
|
|
43
44
|
for k, v in self.step_config['mfa']['payload'].items():
|
|
44
45
|
state['_mfa']['post_payload'][k] = compile_values(template_loader, state, v)
|
|
@@ -116,6 +116,9 @@ class CollectInputStrategy(ActionStrategy):
|
|
|
116
116
|
|
|
117
117
|
def pre_execute(self, state: Dict[str, Any]) -> Dict[str, Any]:
|
|
118
118
|
state['_active_input_field'] = self.step_config.get('field')
|
|
119
|
+
# Inject MFA config for MFA validator nodes
|
|
120
|
+
if self.step_id in self.engine_context.mfa_validator_steps:
|
|
121
|
+
state['_mfa_config'] = self.engine_context.mfa_config
|
|
119
122
|
|
|
120
123
|
@property
|
|
121
124
|
def _formatted_field_name(self) -> str:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Workflow Tools - Wraps workflows as callable tools for agent frameworks
|
|
3
3
|
"""
|
|
4
|
-
|
|
4
|
+
from __future__ import annotations
|
|
5
5
|
import uuid
|
|
6
6
|
from typing import Optional, Dict, Any
|
|
7
7
|
from .utils.logger import logger
|
|
@@ -9,6 +9,7 @@ from .utils.logger import logger
|
|
|
9
9
|
from langfuse.langchain import CallbackHandler
|
|
10
10
|
|
|
11
11
|
from .core.engine import load_workflow
|
|
12
|
+
from .core.constants import MFAConfig
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
class WorkflowTool:
|
|
@@ -25,7 +26,8 @@ class WorkflowTool:
|
|
|
25
26
|
name: str,
|
|
26
27
|
description: str,
|
|
27
28
|
checkpointer=None,
|
|
28
|
-
config: Optional[Dict]=None
|
|
29
|
+
config: Optional[Dict]=None,
|
|
30
|
+
mfa_config: Optional[MFAConfig] = None
|
|
29
31
|
):
|
|
30
32
|
"""Initialize workflow tool
|
|
31
33
|
|
|
@@ -39,9 +41,13 @@ class WorkflowTool:
|
|
|
39
41
|
self.name = name
|
|
40
42
|
self.description = description
|
|
41
43
|
self.checkpointer = checkpointer
|
|
44
|
+
self.mfa_config = mfa_config
|
|
42
45
|
|
|
43
46
|
# Load workflow
|
|
44
|
-
self.graph, self.engine = load_workflow(
|
|
47
|
+
self.graph, self.engine = load_workflow(
|
|
48
|
+
yaml_path, checkpointer=checkpointer,
|
|
49
|
+
config=config, mfa_config=mfa_config
|
|
50
|
+
)
|
|
45
51
|
|
|
46
52
|
def execute(
|
|
47
53
|
self,
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
from typing import List, Set
|
|
1
|
+
from typing import List, Set, Optional, TYPE_CHECKING
|
|
2
2
|
|
|
3
3
|
import jsonschema
|
|
4
4
|
|
|
5
5
|
from .schema import WORKFLOW_SCHEMA
|
|
6
|
-
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from soprano_sdk.core.constants import MFAConfig
|
|
7
9
|
|
|
8
10
|
|
|
9
11
|
class ValidationResult:
|
|
@@ -21,8 +23,9 @@ class ValidationResult:
|
|
|
21
23
|
|
|
22
24
|
|
|
23
25
|
class WorkflowValidator:
|
|
24
|
-
def __init__(self, config: dict):
|
|
26
|
+
def __init__(self, config: dict, mfa_config: Optional['MFAConfig'] = None):
|
|
25
27
|
self.config = config
|
|
28
|
+
self.mfa_config = mfa_config
|
|
26
29
|
self.errors: List[str] = []
|
|
27
30
|
|
|
28
31
|
def validate(self) -> ValidationResult:
|
|
@@ -110,20 +113,10 @@ class WorkflowValidator:
|
|
|
110
113
|
)
|
|
111
114
|
|
|
112
115
|
def _validate_authorizer(self, step):
|
|
113
|
-
|
|
114
|
-
def _validate_rest_fields():
|
|
115
|
-
if mfa_authorizer['type'] == 'REST':
|
|
116
|
-
for field in MFARestAuthorizerEnv:
|
|
117
|
-
is_present = field.get_from_env()
|
|
118
|
-
if not is_present:
|
|
119
|
-
self.errors.append(f"`{field.value}` needs to be set as Environment for REST MFA")
|
|
120
|
-
else:
|
|
121
|
-
self.errors.append(f"step({step['id']}) -> mfa -> type is unsupported")
|
|
122
|
-
|
|
123
116
|
mfa_authorizer = step.get("mfa", None)
|
|
124
117
|
if mfa_authorizer is None:
|
|
125
118
|
return
|
|
126
|
-
|
|
119
|
+
|
|
127
120
|
if mfa_authorizer and step['action'] != 'call_function':
|
|
128
121
|
self.errors.append(
|
|
129
122
|
f"MFA is enabled in step({step['id']}). MFA is supported only for `call_function` nodes"
|
|
@@ -133,7 +126,31 @@ class WorkflowValidator:
|
|
|
133
126
|
if not model:
|
|
134
127
|
self.errors.append(f"step({step['id']}) -> mfa -> model is missing")
|
|
135
128
|
|
|
136
|
-
|
|
129
|
+
mfa_type = mfa_authorizer.get('type')
|
|
130
|
+
if mfa_type and mfa_type != 'REST':
|
|
131
|
+
self.errors.append(f"step({step['id']}) -> mfa -> type '{mfa_type}' is unsupported. Only 'REST' is supported.")
|
|
132
|
+
|
|
133
|
+
# Validate mfa_config if provided
|
|
134
|
+
if self.mfa_config is not None:
|
|
135
|
+
missing_fields = []
|
|
136
|
+
if not self.mfa_config.generate_token_base_url:
|
|
137
|
+
missing_fields.append('generate_token_base_url')
|
|
138
|
+
if not self.mfa_config.generate_token_path:
|
|
139
|
+
missing_fields.append('generate_token_path')
|
|
140
|
+
if not self.mfa_config.validate_token_base_url:
|
|
141
|
+
missing_fields.append('validate_token_base_url')
|
|
142
|
+
if not self.mfa_config.validate_token_path:
|
|
143
|
+
missing_fields.append('validate_token_path')
|
|
144
|
+
if not self.mfa_config.authorize_token_base_url:
|
|
145
|
+
missing_fields.append('authorize_token_base_url')
|
|
146
|
+
if not self.mfa_config.authorize_token_path:
|
|
147
|
+
missing_fields.append('authorize_token_path')
|
|
148
|
+
|
|
149
|
+
if missing_fields:
|
|
150
|
+
self.errors.append(
|
|
151
|
+
f"MFA configuration is missing required fields: {', '.join(missing_fields)}. "
|
|
152
|
+
f"Either provide them via mfa_config parameter or set corresponding environment variables."
|
|
153
|
+
)
|
|
137
154
|
|
|
138
155
|
|
|
139
156
|
def _validate_data_fields(self):
|
|
@@ -200,8 +217,8 @@ class WorkflowValidator:
|
|
|
200
217
|
)
|
|
201
218
|
|
|
202
219
|
|
|
203
|
-
def validate_workflow(config: dict) -> ValidationResult:
|
|
204
|
-
validator = WorkflowValidator(config)
|
|
220
|
+
def validate_workflow(config: dict, mfa_config: Optional['MFAConfig'] = None) -> ValidationResult:
|
|
221
|
+
validator = WorkflowValidator(config, mfa_config=mfa_config)
|
|
205
222
|
result = validator.validate()
|
|
206
223
|
|
|
207
224
|
if not result.is_valid:
|
|
@@ -1,19 +1,10 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Comprehensive test cases for MFA (Multi-Factor Authentication) functionality
|
|
3
|
-
Tests cover all scenarios including forward flow, loop backs, and multiple MFA steps
|
|
4
|
-
|
|
5
|
-
Test Scenario: Concert Ticket Booking System
|
|
6
|
-
"""
|
|
7
1
|
import os
|
|
8
|
-
import sys
|
|
9
|
-
from typing import Dict, Any
|
|
10
2
|
import pytest
|
|
3
|
+
from soprano_sdk import load_workflow
|
|
11
4
|
|
|
12
|
-
# Add examples directory to path
|
|
13
|
-
examples_dir = os.path.join(os.path.dirname(__file__), "..", "examples", "concert_booking")
|
|
14
|
-
sys.path.insert(0, os.path.abspath(examples_dir))
|
|
15
5
|
|
|
16
|
-
|
|
6
|
+
def get_examples_dir():
|
|
7
|
+
return os.path.join(os.path.dirname(__file__), "..", "examples", "concert_booking")
|
|
17
8
|
|
|
18
9
|
|
|
19
10
|
@pytest.fixture(scope="module", autouse=True)
|
|
@@ -57,14 +48,14 @@ def test_mfa_nodes_created():
|
|
|
57
48
|
print("TEST 1: MFA Node Creation")
|
|
58
49
|
print("=" * 80)
|
|
59
50
|
|
|
60
|
-
yaml_path = os.path.join(
|
|
51
|
+
yaml_path = os.path.join(get_examples_dir(), "concert_ticket_booking.yaml")
|
|
61
52
|
graph, engine = load_workflow(yaml_path)
|
|
62
53
|
|
|
63
54
|
# Count MFA-protected steps in original config
|
|
64
55
|
mfa_protected_steps = ['process_payment', 'send_confirmation']
|
|
65
56
|
|
|
66
57
|
print(f"\nOriginal MFA-protected steps: {mfa_protected_steps}")
|
|
67
|
-
print(
|
|
58
|
+
print("Expected MFA nodes per protected step: 2 (start + collector)")
|
|
68
59
|
print(f"Total expected MFA nodes: {len(mfa_protected_steps) * 2}")
|
|
69
60
|
|
|
70
61
|
# Verify MFA nodes were created
|
|
@@ -104,7 +95,7 @@ def test_mfa_previous_step_redirection():
|
|
|
104
95
|
print("TEST 2: Previous Step Redirection (Forward Flow)")
|
|
105
96
|
print("=" * 80)
|
|
106
97
|
|
|
107
|
-
yaml_path = os.path.join(
|
|
98
|
+
yaml_path = os.path.join(get_examples_dir(), "concert_ticket_booking.yaml")
|
|
108
99
|
graph, engine = load_workflow(yaml_path)
|
|
109
100
|
|
|
110
101
|
# Test Case 2a: check_seat_availability should point to process_payment_mfa_start
|
|
@@ -153,7 +144,7 @@ def test_mfa_loop_back_redirection():
|
|
|
153
144
|
print("TEST 3: Loop Back Redirection (CRITICAL BUG FIX TEST)")
|
|
154
145
|
print("=" * 80)
|
|
155
146
|
|
|
156
|
-
yaml_path = os.path.join(
|
|
147
|
+
yaml_path = os.path.join(get_examples_dir(), "concert_ticket_booking.yaml")
|
|
157
148
|
graph, engine = load_workflow(yaml_path)
|
|
158
149
|
|
|
159
150
|
print("\nScenario: User completes booking, then requests modification")
|
|
@@ -162,7 +153,7 @@ def test_mfa_loop_back_redirection():
|
|
|
162
153
|
|
|
163
154
|
# Verify ask_modification_needed loops back to collect_seat_preference
|
|
164
155
|
modification_step = engine.get_step_info('ask_modification_needed')
|
|
165
|
-
print(
|
|
156
|
+
print("\nStep: ask_modification_needed")
|
|
166
157
|
|
|
167
158
|
if transitions := modification_step.get('transitions'):
|
|
168
159
|
loop_back = next((t for t in transitions if 'collect_seat_preference' in t.get('next', '')), None)
|
|
@@ -203,7 +194,7 @@ def test_mfa_loop_back_redirection():
|
|
|
203
194
|
|
|
204
195
|
# Check if we've reached payment MFA
|
|
205
196
|
if 'process_payment' in next_step and '_mfa_start' in next_step:
|
|
206
|
-
print(
|
|
197
|
+
print("\n✅ CRITICAL: Loop back correctly goes through MFA!")
|
|
207
198
|
print(f" Full path: {' -> '.join(path)}")
|
|
208
199
|
break
|
|
209
200
|
else:
|
|
@@ -231,7 +222,7 @@ def test_mfa_multiple_steps_sequence():
|
|
|
231
222
|
print("TEST 4: Multiple MFA Steps in Sequence")
|
|
232
223
|
print("=" * 80)
|
|
233
224
|
|
|
234
|
-
yaml_path = os.path.join(
|
|
225
|
+
yaml_path = os.path.join(get_examples_dir(), "concert_ticket_booking.yaml")
|
|
235
226
|
graph, engine = load_workflow(yaml_path)
|
|
236
227
|
|
|
237
228
|
print("\nScenario: Two consecutive MFA-protected steps")
|
|
@@ -239,7 +230,7 @@ def test_mfa_multiple_steps_sequence():
|
|
|
239
230
|
|
|
240
231
|
# Get process_payment step
|
|
241
232
|
payment_step = engine.get_step_info('process_payment')
|
|
242
|
-
print(
|
|
233
|
+
print("\nStep: process_payment")
|
|
243
234
|
|
|
244
235
|
# Find transition to send_confirmation
|
|
245
236
|
next_after_payment = None
|
|
@@ -286,7 +277,7 @@ def test_mfa_data_fields_created():
|
|
|
286
277
|
print("TEST 5: MFA Data Fields Creation")
|
|
287
278
|
print("=" * 80)
|
|
288
279
|
|
|
289
|
-
yaml_path = os.path.join(
|
|
280
|
+
yaml_path = os.path.join(get_examples_dir(), "concert_ticket_booking.yaml")
|
|
290
281
|
graph, engine = load_workflow(yaml_path)
|
|
291
282
|
|
|
292
283
|
print("\nChecking if MFA-specific data fields were created...")
|
|
@@ -308,7 +299,7 @@ def test_mfa_data_fields_created():
|
|
|
308
299
|
|
|
309
300
|
assert field_name in data_fields, \
|
|
310
301
|
f"MFA field {field_name} should be in data fields"
|
|
311
|
-
print(
|
|
302
|
+
print(" ✅ Field exists in data schema")
|
|
312
303
|
|
|
313
304
|
print("\n✅ TEST PASSED: MFA data fields created correctly")
|
|
314
305
|
print("=" * 80)
|
|
@@ -327,7 +318,7 @@ def test_mfa_alternative_paths():
|
|
|
327
318
|
print("TEST 6: Alternative Paths to MFA Steps")
|
|
328
319
|
print("=" * 80)
|
|
329
320
|
|
|
330
|
-
yaml_path = os.path.join(
|
|
321
|
+
yaml_path = os.path.join(get_examples_dir(), "concert_ticket_booking.yaml")
|
|
331
322
|
graph, engine = load_workflow(yaml_path)
|
|
332
323
|
|
|
333
324
|
print("\nScenario: User selects unavailable seats, tries alternatives")
|
|
@@ -335,7 +326,7 @@ def test_mfa_alternative_paths():
|
|
|
335
326
|
|
|
336
327
|
# Check offer_alternative_seats loops back correctly
|
|
337
328
|
alternative_step = engine.get_step_info('offer_alternative_seats')
|
|
338
|
-
print(
|
|
329
|
+
print("\nStep: offer_alternative_seats")
|
|
339
330
|
|
|
340
331
|
if transitions := alternative_step.get('transitions'):
|
|
341
332
|
for t in transitions:
|
|
@@ -371,7 +362,7 @@ def test_mfa_comprehensive_flow():
|
|
|
371
362
|
print("TEST 7: Comprehensive MFA Flow Validation")
|
|
372
363
|
print("=" * 80)
|
|
373
364
|
|
|
374
|
-
yaml_path = os.path.join(
|
|
365
|
+
yaml_path = os.path.join(get_examples_dir(), "concert_ticket_booking.yaml")
|
|
375
366
|
graph, engine = load_workflow(yaml_path)
|
|
376
367
|
|
|
377
368
|
mfa_protected_original = ['process_payment', 'send_confirmation']
|