mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-19 08:28:10 +02:00
feat: add Tuner Integration to Dograh (#311)
* Add tuner integration * bump pipecat version * chore: update pipecat submodule to match upstream and use tuner-pipecat-sdk 0.2.0 Update pipecat submodule from 0.0.109.dev23 to 13e98d0d9 (the exact commit upstream dograh-hq/dograh uses after v1.30.1). This installs pipecat-ai as 1.1.0.post277 via setuptools_scm, satisfying tuner-pipecat-sdk 0.2.0's pipecat-ai>=1.0.0 requirement. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * wire tuner * feat: refactor integrations into self contained packages * chore: simplify ensure_public_access_token * fix: remove NodeSpec and make DTOs the source of truth * feat: send relevant signal to mcp using to_mcp_dict * fix: fix tests * cleanup: remove nango integrations * feat: add agents.md for integrations --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Abhishek Kumar <abhishek@a6k.me>
This commit is contained in:
parent
afa78fe859
commit
5f28c1b2a9
93 changed files with 3388 additions and 3414 deletions
|
|
@ -15,16 +15,14 @@ import pytest
|
|||
|
||||
from api.services.workflow.dto import (
|
||||
AgentNodeData,
|
||||
AgentRFNode,
|
||||
EdgeDataDTO,
|
||||
EndCallNodeData,
|
||||
EndCallRFNode,
|
||||
ExtractionVariableDTO,
|
||||
Position,
|
||||
ReactFlowDTO,
|
||||
RFEdgeDTO,
|
||||
RFNodeDTO,
|
||||
StartCallNodeData,
|
||||
StartCallRFNode,
|
||||
VariableType,
|
||||
)
|
||||
from api.services.workflow.workflow_graph import WorkflowGraph
|
||||
|
|
@ -270,8 +268,9 @@ def simple_workflow() -> WorkflowGraph:
|
|||
"""
|
||||
dto = ReactFlowDTO(
|
||||
nodes=[
|
||||
StartCallRFNode(
|
||||
RFNodeDTO(
|
||||
id="start",
|
||||
type="startCall",
|
||||
position=Position(x=0, y=0),
|
||||
data=StartCallNodeData(
|
||||
name="Start Call",
|
||||
|
|
@ -290,8 +289,9 @@ def simple_workflow() -> WorkflowGraph:
|
|||
],
|
||||
),
|
||||
),
|
||||
EndCallRFNode(
|
||||
RFNodeDTO(
|
||||
id="end",
|
||||
type="endCall",
|
||||
position=Position(x=0, y=200),
|
||||
data=EndCallNodeData(
|
||||
name="End Call",
|
||||
|
|
@ -333,8 +333,9 @@ def three_node_workflow() -> WorkflowGraph:
|
|||
"""
|
||||
dto = ReactFlowDTO(
|
||||
nodes=[
|
||||
StartCallRFNode(
|
||||
RFNodeDTO(
|
||||
id="start",
|
||||
type="startCall",
|
||||
position=Position(x=0, y=0),
|
||||
data=StartCallNodeData(
|
||||
name="Start Call",
|
||||
|
|
@ -353,8 +354,9 @@ def three_node_workflow() -> WorkflowGraph:
|
|||
],
|
||||
),
|
||||
),
|
||||
AgentRFNode(
|
||||
RFNodeDTO(
|
||||
id="agent",
|
||||
type="agentNode",
|
||||
position=Position(x=0, y=200),
|
||||
data=AgentNodeData(
|
||||
name="Collect Info",
|
||||
|
|
@ -372,8 +374,9 @@ def three_node_workflow() -> WorkflowGraph:
|
|||
],
|
||||
),
|
||||
),
|
||||
EndCallRFNode(
|
||||
RFNodeDTO(
|
||||
id="end",
|
||||
type="endCall",
|
||||
position=Position(x=0, y=400),
|
||||
data=EndCallNodeData(
|
||||
name="End Call",
|
||||
|
|
@ -424,8 +427,9 @@ def three_node_workflow_extraction_start_only() -> WorkflowGraph:
|
|||
"""
|
||||
dto = ReactFlowDTO(
|
||||
nodes=[
|
||||
StartCallRFNode(
|
||||
RFNodeDTO(
|
||||
id="start",
|
||||
type="startCall",
|
||||
position=Position(x=0, y=0),
|
||||
data=StartCallNodeData(
|
||||
name="Start Call",
|
||||
|
|
@ -444,8 +448,9 @@ def three_node_workflow_extraction_start_only() -> WorkflowGraph:
|
|||
],
|
||||
),
|
||||
),
|
||||
AgentRFNode(
|
||||
RFNodeDTO(
|
||||
id="agent",
|
||||
type="agentNode",
|
||||
position=Position(x=0, y=200),
|
||||
data=AgentNodeData(
|
||||
name="Collect Info",
|
||||
|
|
@ -455,8 +460,9 @@ def three_node_workflow_extraction_start_only() -> WorkflowGraph:
|
|||
extraction_enabled=False, # Explicitly disabled for testing
|
||||
),
|
||||
),
|
||||
EndCallRFNode(
|
||||
RFNodeDTO(
|
||||
id="end",
|
||||
type="endCall",
|
||||
position=Position(x=0, y=400),
|
||||
data=EndCallNodeData(
|
||||
name="End Call",
|
||||
|
|
@ -503,8 +509,9 @@ def three_node_workflow_no_variable_extraction() -> WorkflowGraph:
|
|||
"""
|
||||
dto = ReactFlowDTO(
|
||||
nodes=[
|
||||
StartCallRFNode(
|
||||
RFNodeDTO(
|
||||
id="start",
|
||||
type="startCall",
|
||||
position=Position(x=0, y=0),
|
||||
data=StartCallNodeData(
|
||||
name="Start Call",
|
||||
|
|
@ -515,8 +522,9 @@ def three_node_workflow_no_variable_extraction() -> WorkflowGraph:
|
|||
extraction_enabled=False,
|
||||
),
|
||||
),
|
||||
AgentRFNode(
|
||||
RFNodeDTO(
|
||||
id="agent",
|
||||
type="agentNode",
|
||||
position=Position(x=0, y=200),
|
||||
data=AgentNodeData(
|
||||
name="Collect Info",
|
||||
|
|
@ -526,8 +534,9 @@ def three_node_workflow_no_variable_extraction() -> WorkflowGraph:
|
|||
extraction_enabled=False, # Explicitly disabled for testing
|
||||
),
|
||||
),
|
||||
EndCallRFNode(
|
||||
RFNodeDTO(
|
||||
id="end",
|
||||
type="endCall",
|
||||
position=Position(x=0, y=400),
|
||||
data=EndCallNodeData(
|
||||
name="End Call",
|
||||
|
|
|
|||
|
|
@ -63,7 +63,6 @@
|
|||
},
|
||||
"data": {
|
||||
"prompt": "Hello, I am Abhishek from Dograh. ",
|
||||
"is_static": true,
|
||||
"name": "Start Call",
|
||||
"is_start": true
|
||||
},
|
||||
|
|
@ -83,7 +82,6 @@
|
|||
},
|
||||
"data": {
|
||||
"prompt": "Thank you for calling Dograh. Have a great day!",
|
||||
"is_static": true,
|
||||
"name": "End Call"
|
||||
},
|
||||
"measured": {
|
||||
|
|
@ -161,4 +159,4 @@
|
|||
"y": 0,
|
||||
"zoom": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from dograh_sdk.typed import (
|
|||
Qa,
|
||||
StartCall,
|
||||
Trigger,
|
||||
Tuner,
|
||||
TypedNode,
|
||||
Webhook,
|
||||
)
|
||||
|
|
@ -50,6 +51,7 @@ def client() -> _StubClient:
|
|||
(Trigger, "trigger"),
|
||||
(Webhook, "webhook"),
|
||||
(Qa, "qa"),
|
||||
(Tuner, "tuner"),
|
||||
],
|
||||
ids=lambda v: v.__name__ if isinstance(v, type) else v,
|
||||
)
|
||||
|
|
@ -68,8 +70,15 @@ def test_typed_class_declares_spec_name(cls: type[TypedNode], expected_type: str
|
|||
inst = cls(name="t")
|
||||
elif cls is Webhook:
|
||||
inst = cls(name="wh")
|
||||
else: # Qa
|
||||
elif cls is Qa:
|
||||
inst = cls(name="qa")
|
||||
else: # Tuner
|
||||
inst = cls(
|
||||
name="tuner",
|
||||
tuner_agent_id="agent",
|
||||
tuner_workspace_id=1,
|
||||
tuner_api_key="secret",
|
||||
)
|
||||
assert inst.type == expected_type
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,37 @@ async def test_dto():
|
|||
assert dto is not None
|
||||
|
||||
|
||||
def test_dto_ignores_legacy_unknown_node_data_fields():
|
||||
dto = ReactFlowDTO.model_validate(
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "n1",
|
||||
"type": "startCall",
|
||||
"position": {"x": 0, "y": 0},
|
||||
"data": {
|
||||
"name": "Start",
|
||||
"prompt": "Hello",
|
||||
"is_static": True,
|
||||
"detect_voicemail": True,
|
||||
"wait_for_user_response": False,
|
||||
"wait_for_user_response_timeout": 2.5,
|
||||
"legacy_field": "ignored",
|
||||
},
|
||||
}
|
||||
],
|
||||
"edges": [],
|
||||
}
|
||||
)
|
||||
|
||||
data = dto.nodes[0].data.model_dump()
|
||||
assert "is_static" not in data
|
||||
assert "detect_voicemail" not in data
|
||||
assert "wait_for_user_response" not in data
|
||||
assert "wait_for_user_response_timeout" not in data
|
||||
assert "legacy_field" not in data
|
||||
|
||||
|
||||
def test_sanitize_strips_ui_runtime_fields():
|
||||
definition = {
|
||||
"viewport": {"x": 0, "y": 0, "zoom": 1},
|
||||
|
|
|
|||
|
|
@ -14,7 +14,12 @@ import re
|
|||
|
||||
import pytest
|
||||
|
||||
from api.services.workflow.dto import NodeType, ReactFlowDTO
|
||||
from api.services.workflow.dto import (
|
||||
ReactFlowDTO,
|
||||
all_node_type_names,
|
||||
get_node_data_model,
|
||||
)
|
||||
from api.services.workflow.node_data import BaseNodeData
|
||||
from api.services.workflow.node_specs import (
|
||||
NodeSpec,
|
||||
PropertySpec,
|
||||
|
|
@ -118,9 +123,9 @@ def test_fixed_collection_has_sub_properties(spec: NodeSpec):
|
|||
|
||||
@pytest.mark.parametrize("spec", all_specs(), ids=lambda s: s.name)
|
||||
def test_spec_name_matches_dto_discriminator(spec: NodeSpec):
|
||||
valid_names = {t.value for t in NodeType}
|
||||
valid_names = all_node_type_names()
|
||||
assert spec.name in valid_names, (
|
||||
f"NodeSpec {spec.name!r} doesn't match any NodeType discriminator. "
|
||||
f"NodeSpec {spec.name!r} doesn't match any registered node type. "
|
||||
f"Valid: {sorted(valid_names)}"
|
||||
)
|
||||
|
||||
|
|
@ -187,10 +192,226 @@ def test_examples_validate_against_dto(spec: NodeSpec):
|
|||
|
||||
|
||||
def test_all_dto_types_have_specs():
|
||||
"""Every NodeType discriminator value must have a registered NodeSpec —
|
||||
catches the case where someone adds a new node type to dto.py but
|
||||
forgets to author a spec."""
|
||||
"""Every registered node type must have a registered NodeSpec."""
|
||||
spec_names = {s.name for s in all_specs()}
|
||||
type_values = {t.value for t in NodeType}
|
||||
type_values = all_node_type_names()
|
||||
missing = type_values - spec_names
|
||||
assert not missing, f"NodeType discriminators without specs: {sorted(missing)}"
|
||||
assert not missing, f"Registered node types without specs: {sorted(missing)}"
|
||||
|
||||
|
||||
def test_all_registered_node_models_inherit_base_node_data():
|
||||
for type_name in sorted(all_node_type_names()):
|
||||
data_model = get_node_data_model(type_name)
|
||||
assert data_model is not None, f"{type_name}: missing node data model"
|
||||
assert issubclass(data_model, BaseNodeData), (
|
||||
f"{type_name}: node data model must inherit BaseNodeData"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("spec_name", "expected_order"),
|
||||
[
|
||||
(
|
||||
"startCall",
|
||||
[
|
||||
"name",
|
||||
"greeting_type",
|
||||
"greeting",
|
||||
"greeting_recording_id",
|
||||
"prompt",
|
||||
"allow_interrupt",
|
||||
"add_global_prompt",
|
||||
"delayed_start",
|
||||
"delayed_start_duration",
|
||||
"extraction_enabled",
|
||||
"extraction_prompt",
|
||||
"extraction_variables",
|
||||
"tool_uuids",
|
||||
"document_uuids",
|
||||
"pre_call_fetch_enabled",
|
||||
"pre_call_fetch_url",
|
||||
"pre_call_fetch_credential_uuid",
|
||||
],
|
||||
),
|
||||
(
|
||||
"agentNode",
|
||||
[
|
||||
"name",
|
||||
"prompt",
|
||||
"allow_interrupt",
|
||||
"add_global_prompt",
|
||||
"extraction_enabled",
|
||||
"extraction_prompt",
|
||||
"extraction_variables",
|
||||
"tool_uuids",
|
||||
"document_uuids",
|
||||
],
|
||||
),
|
||||
(
|
||||
"endCall",
|
||||
[
|
||||
"name",
|
||||
"prompt",
|
||||
"add_global_prompt",
|
||||
"extraction_enabled",
|
||||
"extraction_prompt",
|
||||
"extraction_variables",
|
||||
],
|
||||
),
|
||||
("globalNode", ["name", "prompt"]),
|
||||
("trigger", ["name", "enabled", "trigger_path"]),
|
||||
(
|
||||
"webhook",
|
||||
[
|
||||
"name",
|
||||
"enabled",
|
||||
"http_method",
|
||||
"endpoint_url",
|
||||
"credential_uuid",
|
||||
"custom_headers",
|
||||
"payload_template",
|
||||
],
|
||||
),
|
||||
(
|
||||
"qa",
|
||||
[
|
||||
"name",
|
||||
"qa_enabled",
|
||||
"qa_system_prompt",
|
||||
"qa_min_call_duration",
|
||||
"qa_voicemail_calls",
|
||||
"qa_sample_rate",
|
||||
"qa_use_workflow_llm",
|
||||
"qa_provider",
|
||||
"qa_model",
|
||||
"qa_api_key",
|
||||
"qa_endpoint",
|
||||
],
|
||||
),
|
||||
(
|
||||
"tuner",
|
||||
[
|
||||
"name",
|
||||
"tuner_enabled",
|
||||
"tuner_agent_id",
|
||||
"tuner_workspace_id",
|
||||
"tuner_api_key",
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_node_spec_property_order_stable(spec_name: str, expected_order: list[str]):
|
||||
spec = next(spec for spec in all_specs() if spec.name == spec_name)
|
||||
assert [prop.name for prop in spec.properties] == expected_order
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# `to_mcp_dict` projection — the lean view served by the `get_node_type`
|
||||
# MCP tool. UI-only metadata is dropped so it doesn't poison LLM context;
|
||||
# the full spec stays available to the frontend and SDK via other paths.
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Keys that are UI-rendering concerns and must never reach the LLM view, at
|
||||
# either the node or property level.
|
||||
_UI_ONLY_KEYS = frozenset(
|
||||
{
|
||||
"display_name",
|
||||
"icon",
|
||||
"category",
|
||||
"version",
|
||||
"placeholder",
|
||||
"display_options",
|
||||
"editor",
|
||||
"extra",
|
||||
"label", # PropertyOption display string
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _walk_dicts(node):
|
||||
"""Yield every dict nested anywhere inside a projected structure."""
|
||||
if isinstance(node, dict):
|
||||
yield node
|
||||
for value in node.values():
|
||||
yield from _walk_dicts(value)
|
||||
elif isinstance(node, list):
|
||||
for item in node:
|
||||
yield from _walk_dicts(item)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("spec", all_specs(), ids=lambda s: s.name)
|
||||
def test_to_mcp_dict_drops_ui_only_keys(spec: NodeSpec):
|
||||
projected = spec.to_mcp_dict()
|
||||
for d in _walk_dicts(projected):
|
||||
leaked = _UI_ONLY_KEYS & d.keys()
|
||||
assert not leaked, f"{spec.name}: UI-only keys leaked into LLM view: {leaked}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("spec", all_specs(), ids=lambda s: s.name)
|
||||
def test_to_mcp_dict_omits_null_and_empty(spec: NodeSpec):
|
||||
"""The lean view never emits null values — absent means unset/optional,
|
||||
which is what halves the noise versus the full model dump."""
|
||||
for d in _walk_dicts(spec.to_mcp_dict()):
|
||||
for key, value in d.items():
|
||||
assert value is not None, f"{spec.name}: {key!r} emitted as null"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("spec", all_specs(), ids=lambda s: s.name)
|
||||
def test_to_mcp_dict_keeps_property_essentials(spec: NodeSpec):
|
||||
"""Every property in the LLM view carries the minimum an LLM needs to
|
||||
author a value: machine name, type, and a description."""
|
||||
|
||||
def _check(props: list[dict]):
|
||||
for prop in props:
|
||||
assert prop.get("name"), f"{spec.name}: property missing name"
|
||||
assert prop.get("type"), f"{spec.name}.{prop.get('name')}: missing type"
|
||||
assert prop.get("description"), (
|
||||
f"{spec.name}.{prop.get('name')}: missing description"
|
||||
)
|
||||
if prop.get("properties"):
|
||||
_check(prop["properties"])
|
||||
|
||||
_check(spec.to_mcp_dict()["properties"])
|
||||
|
||||
|
||||
def test_to_mcp_dict_retains_authoring_signal_startcall():
|
||||
"""startCall is the richest core node — lock in that the projection
|
||||
keeps the fields an LLM actually authors against while shedding the rest."""
|
||||
spec = next(s for s in all_specs() if s.name == "startCall")
|
||||
projected = spec.to_mcp_dict()
|
||||
|
||||
assert set(projected) == {
|
||||
"name",
|
||||
"description",
|
||||
"llm_hint",
|
||||
"properties",
|
||||
"examples",
|
||||
"graph_constraints",
|
||||
}
|
||||
|
||||
props = {p["name"]: p for p in projected["properties"]}
|
||||
|
||||
# Required field keeps `required`; optional fields omit it.
|
||||
assert props["prompt"]["required"] is True
|
||||
assert "required" not in props["greeting"]
|
||||
|
||||
# Enum options project to bare values, dropping the UI label.
|
||||
assert props["greeting_type"]["options"] == [{"value": "text"}, {"value": "audio"}]
|
||||
|
||||
# Validation bounds survive (they constrain valid authored values).
|
||||
assert props["delayed_start_duration"]["min_value"] == 0.1
|
||||
assert props["delayed_start_duration"]["max_value"] == 10.0
|
||||
|
||||
# llm_hint survives where present (catalog-tool references).
|
||||
assert "list_recordings" in props["greeting_recording_id"]["llm_hint"]
|
||||
|
||||
# fixed_collection rows recurse through the same projection.
|
||||
var_rows = {p["name"]: p for p in props["extraction_variables"]["properties"]}
|
||||
assert var_rows["type"]["options"] == [
|
||||
{"value": "string"},
|
||||
{"value": "number"},
|
||||
{"value": "boolean"},
|
||||
]
|
||||
|
||||
# graph_constraints drops its null sub-fields.
|
||||
assert projected["graph_constraints"] == {"min_incoming": 0, "max_incoming": 0}
|
||||
|
|
|
|||
|
|
@ -45,12 +45,11 @@ from api.enums import ToolCategory
|
|||
from api.services.workflow.dto import (
|
||||
EdgeDataDTO,
|
||||
EndCallNodeData,
|
||||
EndCallRFNode,
|
||||
Position,
|
||||
ReactFlowDTO,
|
||||
RFEdgeDTO,
|
||||
RFNodeDTO,
|
||||
StartCallNodeData,
|
||||
StartCallRFNode,
|
||||
)
|
||||
from api.services.workflow.pipecat_engine import PipecatEngine
|
||||
from api.services.workflow.pipecat_engine_custom_tools import CustomToolManager
|
||||
|
|
@ -1014,8 +1013,9 @@ class TestEndCallExtractionBehavior:
|
|||
# Create a workflow where start node has NO extraction
|
||||
dto = ReactFlowDTO(
|
||||
nodes=[
|
||||
StartCallRFNode(
|
||||
RFNodeDTO(
|
||||
id="start",
|
||||
type="startCall",
|
||||
position=Position(x=0, y=0),
|
||||
data=StartCallNodeData(
|
||||
name="Start Call",
|
||||
|
|
@ -1026,8 +1026,9 @@ class TestEndCallExtractionBehavior:
|
|||
extraction_enabled=False, # No extraction
|
||||
),
|
||||
),
|
||||
EndCallRFNode(
|
||||
RFNodeDTO(
|
||||
id="end",
|
||||
type="endCall",
|
||||
position=Position(x=0, y=200),
|
||||
data=EndCallNodeData(
|
||||
name="End Call",
|
||||
|
|
|
|||
|
|
@ -34,12 +34,11 @@ from api.services.pipecat.recording_audio_cache import RecordingAudio
|
|||
from api.services.workflow.dto import (
|
||||
EdgeDataDTO,
|
||||
EndCallNodeData,
|
||||
EndCallRFNode,
|
||||
Position,
|
||||
ReactFlowDTO,
|
||||
RFEdgeDTO,
|
||||
RFNodeDTO,
|
||||
StartCallNodeData,
|
||||
StartCallRFNode,
|
||||
)
|
||||
from api.services.workflow.pipecat_engine import PipecatEngine
|
||||
from api.services.workflow.pipecat_engine_custom_tools import CustomToolManager
|
||||
|
|
@ -65,8 +64,9 @@ def text_workflow() -> WorkflowGraph:
|
|||
"""Start->End workflow with text greeting and text transition speech."""
|
||||
dto = ReactFlowDTO(
|
||||
nodes=[
|
||||
StartCallRFNode(
|
||||
RFNodeDTO(
|
||||
id="start",
|
||||
type="startCall",
|
||||
position=Position(x=0, y=0),
|
||||
data=StartCallNodeData(
|
||||
name="Start Call",
|
||||
|
|
@ -79,8 +79,9 @@ def text_workflow() -> WorkflowGraph:
|
|||
extraction_enabled=False,
|
||||
),
|
||||
),
|
||||
EndCallRFNode(
|
||||
RFNodeDTO(
|
||||
id="end",
|
||||
type="endCall",
|
||||
position=Position(x=0, y=200),
|
||||
data=EndCallNodeData(
|
||||
name="End Call",
|
||||
|
|
@ -114,8 +115,9 @@ def audio_workflow() -> WorkflowGraph:
|
|||
"""Start->End workflow with audio greeting and audio transition speech."""
|
||||
dto = ReactFlowDTO(
|
||||
nodes=[
|
||||
StartCallRFNode(
|
||||
RFNodeDTO(
|
||||
id="start",
|
||||
type="startCall",
|
||||
position=Position(x=0, y=0),
|
||||
data=StartCallNodeData(
|
||||
name="Start Call",
|
||||
|
|
@ -128,8 +130,9 @@ def audio_workflow() -> WorkflowGraph:
|
|||
extraction_enabled=False,
|
||||
),
|
||||
),
|
||||
EndCallRFNode(
|
||||
RFNodeDTO(
|
||||
id="end",
|
||||
type="endCall",
|
||||
position=Position(x=0, y=200),
|
||||
data=EndCallNodeData(
|
||||
name="End Call",
|
||||
|
|
@ -290,8 +293,9 @@ class TestStartGreeting:
|
|||
"""No greeting configured should return None."""
|
||||
dto = ReactFlowDTO(
|
||||
nodes=[
|
||||
StartCallRFNode(
|
||||
RFNodeDTO(
|
||||
id="start",
|
||||
type="startCall",
|
||||
position=Position(x=0, y=0),
|
||||
data=StartCallNodeData(
|
||||
name="Start",
|
||||
|
|
@ -301,8 +305,9 @@ class TestStartGreeting:
|
|||
extraction_enabled=False,
|
||||
),
|
||||
),
|
||||
EndCallRFNode(
|
||||
RFNodeDTO(
|
||||
id="end",
|
||||
type="endCall",
|
||||
position=Position(x=0, y=200),
|
||||
data=EndCallNodeData(
|
||||
name="End",
|
||||
|
|
@ -333,8 +338,9 @@ class TestStartGreeting:
|
|||
"""Text greeting with {{variable}} placeholders should be rendered."""
|
||||
dto = ReactFlowDTO(
|
||||
nodes=[
|
||||
StartCallRFNode(
|
||||
RFNodeDTO(
|
||||
id="start",
|
||||
type="startCall",
|
||||
position=Position(x=0, y=0),
|
||||
data=StartCallNodeData(
|
||||
name="Start",
|
||||
|
|
@ -346,8 +352,9 @@ class TestStartGreeting:
|
|||
extraction_enabled=False,
|
||||
),
|
||||
),
|
||||
EndCallRFNode(
|
||||
RFNodeDTO(
|
||||
id="end",
|
||||
type="endCall",
|
||||
position=Position(x=0, y=200),
|
||||
data=EndCallNodeData(
|
||||
name="End",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,25 @@ def _qa_node(node_id="qa-1", api_key="", **extra_data):
|
|||
return {"id": node_id, "type": "qa", "position": {"x": 0, "y": 0}, "data": data}
|
||||
|
||||
|
||||
def _tuner_node(node_id="tuner-1", api_key="", **extra_data):
|
||||
"""Helper to build a Tuner node."""
|
||||
data = {
|
||||
"name": "Tuner",
|
||||
"tuner_enabled": True,
|
||||
"tuner_agent_id": "sales-bot",
|
||||
"tuner_workspace_id": 7,
|
||||
**extra_data,
|
||||
}
|
||||
if api_key:
|
||||
data["tuner_api_key"] = api_key
|
||||
return {
|
||||
"id": node_id,
|
||||
"type": "tuner",
|
||||
"position": {"x": 0, "y": 0},
|
||||
"data": data,
|
||||
}
|
||||
|
||||
|
||||
def _agent_node(node_id="agent-1"):
|
||||
"""Helper to build a non-QA node."""
|
||||
return {
|
||||
|
|
@ -66,6 +85,19 @@ class TestMaskWorkflowDefinition:
|
|||
assert "qa_api_key" not in masked["nodes"][0]["data"]
|
||||
assert masked["nodes"][1]["data"]["qa_api_key"] == mask_key("sk-secret1234")
|
||||
|
||||
def test_masks_tuner_api_key(self):
|
||||
"""Tuner node api_key is masked, showing only last 4 chars."""
|
||||
real_key = "tuner_live_abcdefghijklmnop"
|
||||
wf = _make_workflow_def([_tuner_node(api_key=real_key)])
|
||||
|
||||
masked = mask_workflow_definition(wf)
|
||||
|
||||
masked_key = masked["nodes"][0]["data"]["tuner_api_key"]
|
||||
assert masked_key == mask_key(real_key)
|
||||
assert masked_key.endswith("mnop")
|
||||
assert masked_key.startswith("*")
|
||||
assert real_key not in str(masked)
|
||||
|
||||
def test_qa_node_without_api_key(self):
|
||||
"""QA node with no api_key is left as-is."""
|
||||
wf = _make_workflow_def([_qa_node()])
|
||||
|
|
@ -154,6 +186,16 @@ class TestMergeWorkflowApiKeys:
|
|||
|
||||
assert result["nodes"][0]["data"]["qa_api_key"] == new_key
|
||||
|
||||
def test_masked_tuner_key_is_restored(self):
|
||||
"""Masked Tuner keys round-trip without losing the stored secret."""
|
||||
real_key = "tuner_live_abcdefghijklmnop"
|
||||
existing = _make_workflow_def([_tuner_node(api_key=real_key)])
|
||||
incoming = _make_workflow_def([_tuner_node(api_key=mask_key(real_key))])
|
||||
|
||||
result = merge_workflow_api_keys(incoming, existing)
|
||||
|
||||
assert result["nodes"][0]["data"]["tuner_api_key"] == real_key
|
||||
|
||||
def test_no_incoming_api_key(self):
|
||||
"""QA node without api_key in incoming is left alone."""
|
||||
existing = _make_workflow_def([_qa_node(api_key="sk-existing-key1")])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue