mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +02:00
feat: refactor node spec and add mcp tools (#244)
* refactor: carve out extraction panel * refactor: create spec versions for node types * refactor: create a GenericNode and remove custom nodes * feat: add python and typescript sdk * add dograh sdk * fix: fetch draft workflow definition over published one * fix: fix routes of SDKs to use code gen * chore: remove doclink dependency to reduce image size * chore: format files * chore: bump pipecat * feat: let mcp fetch archived workflows on demand * chore: fix tests * feat: add sdk documentation * chore: change banner and add badge
This commit is contained in:
parent
0a61ef295f
commit
00a1a22b74
162 changed files with 14355 additions and 3554 deletions
82
api/services/workflow/node_specs/__init__.py
Normal file
82
api/services/workflow/node_specs/__init__.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""Node specification registry.
|
||||
|
||||
Adding a new node type:
|
||||
1. Create a new module under this package, define a `SPEC: NodeSpec`.
|
||||
2. Add it to the imports + REGISTRY below.
|
||||
3. The Pydantic discriminated-union variant in dto.py must use the same
|
||||
`name` value as `SPEC.name`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from api.services.workflow.node_specs._base import (
|
||||
SPEC_VERSION,
|
||||
DisplayOptions,
|
||||
GraphConstraints,
|
||||
NodeCategory,
|
||||
NodeExample,
|
||||
NodeSpec,
|
||||
PropertyOption,
|
||||
PropertySpec,
|
||||
PropertyType,
|
||||
evaluate_display_options,
|
||||
)
|
||||
|
||||
REGISTRY: dict[str, NodeSpec] = {}
|
||||
|
||||
|
||||
def register(spec: NodeSpec) -> NodeSpec:
|
||||
"""Register a NodeSpec in the global registry. Returns the spec for
|
||||
chaining at module top-level: `SPEC = register(NodeSpec(...))`."""
|
||||
if spec.name in REGISTRY:
|
||||
raise ValueError(
|
||||
f"Duplicate NodeSpec registration for {spec.name!r}. "
|
||||
f"Each node type must have exactly one spec."
|
||||
)
|
||||
REGISTRY[spec.name] = spec
|
||||
return spec
|
||||
|
||||
|
||||
def get_spec(name: str) -> NodeSpec | None:
|
||||
return REGISTRY.get(name)
|
||||
|
||||
|
||||
def all_specs() -> list[NodeSpec]:
|
||||
"""All registered specs, sorted by name for stable output."""
|
||||
return [REGISTRY[name] for name in sorted(REGISTRY)]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SPEC_VERSION",
|
||||
"REGISTRY",
|
||||
"DisplayOptions",
|
||||
"GraphConstraints",
|
||||
"NodeCategory",
|
||||
"NodeExample",
|
||||
"NodeSpec",
|
||||
"PropertyOption",
|
||||
"PropertySpec",
|
||||
"PropertyType",
|
||||
"all_specs",
|
||||
"evaluate_display_options",
|
||||
"get_spec",
|
||||
"register",
|
||||
]
|
||||
|
||||
|
||||
# Side-effect imports — each module's `register(SPEC)` call populates REGISTRY.
|
||||
# Keep at module bottom so the registry helpers are defined first.
|
||||
from api.services.workflow.node_specs import ( # noqa: E402, F401
|
||||
agent,
|
||||
end_call,
|
||||
global_node,
|
||||
qa,
|
||||
start_call,
|
||||
trigger,
|
||||
webhook,
|
||||
)
|
||||
|
||||
# Wire up registrations from the SPEC constants in each module.
|
||||
for _module in (start_call, agent, end_call, global_node, trigger, webhook, qa):
|
||||
register(_module.SPEC)
|
||||
del _module
|
||||
28
api/services/workflow/node_specs/__main__.py
Normal file
28
api/services/workflow/node_specs/__main__.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"""Dump the registered NodeSpecs to stdout as JSON.
|
||||
|
||||
Used by `scripts/generate_sdk.sh` to feed both SDK codegens without
|
||||
requiring a running backend. Shape matches the `/api/v1/node-types`
|
||||
HTTP response so either source is interchangeable.
|
||||
|
||||
python -m api.services.workflow.node_specs > specs.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
from api.services.workflow.node_specs import SPEC_VERSION, all_specs
|
||||
|
||||
|
||||
def main() -> None:
|
||||
payload = {
|
||||
"spec_version": SPEC_VERSION,
|
||||
"node_types": [s.model_dump(mode="json") for s in all_specs()],
|
||||
}
|
||||
json.dump(payload, sys.stdout, indent=2, ensure_ascii=False)
|
||||
sys.stdout.write("\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
224
api/services/workflow/node_specs/_base.py
Normal file
224
api/services/workflow/node_specs/_base.py
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
"""Spec schema for node definitions.
|
||||
|
||||
A `NodeSpec` is the single source of truth for a node type. It drives:
|
||||
- Pydantic validation (the per-type DTOs in dto.py mirror these property types)
|
||||
- The generic UI renderer (frontend reads specs via /api/v1/node-types)
|
||||
- The LLM SDK (constructors and JSON-Schema derived from these specs)
|
||||
|
||||
Every property's `description` is LLM-readable copy — treat it as production
|
||||
documentation, not internal notes. Spec lint enforces non-empty descriptions
|
||||
and example coverage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
# Spec contract version. Bump when adding new PropertyType values or making
|
||||
# breaking changes to the NodeSpec wire shape. SDK clients warn on mismatch.
|
||||
SPEC_VERSION = "1.0.0"
|
||||
|
||||
|
||||
class PropertyType(str, Enum):
|
||||
"""Bounded vocabulary of property types the renderer dispatches on.
|
||||
|
||||
Adding a value here requires a matching arm in the frontend
|
||||
`<PropertyInput>` switch and (where relevant) the SDK codegen template.
|
||||
"""
|
||||
|
||||
string = "string"
|
||||
number = "number"
|
||||
boolean = "boolean"
|
||||
options = "options" # single-select dropdown
|
||||
multi_options = "multi_options" # multi-select
|
||||
fixed_collection = "fixed_collection" # repeating rows of sub-properties
|
||||
json = "json" # arbitrary JSON object editor
|
||||
|
||||
# Domain-specific reference types — values are UUIDs/keys looked up against
|
||||
# a reference catalog (list_tools, list_documents, list_recordings,
|
||||
# list_credentials).
|
||||
tool_refs = "tool_refs"
|
||||
document_refs = "document_refs"
|
||||
recording_ref = "recording_ref"
|
||||
credential_ref = "credential_ref"
|
||||
|
||||
# Domain-specific input widgets
|
||||
mention_textarea = "mention_textarea" # textarea with {{var}} mentions
|
||||
url = "url"
|
||||
|
||||
|
||||
class NodeCategory(str, Enum):
|
||||
"""Drives grouping in the AddNodePanel UI."""
|
||||
|
||||
call_node = "call_node"
|
||||
global_node = "global_node"
|
||||
trigger = "trigger"
|
||||
integration = "integration"
|
||||
|
||||
|
||||
class DisplayOptions(BaseModel):
|
||||
"""Conditional visibility rules.
|
||||
|
||||
`show` keys are AND-combined: this property is visible only when EVERY
|
||||
referenced field's value matches one of the listed values.
|
||||
|
||||
`hide` keys are OR-combined: this property is hidden when ANY referenced
|
||||
field's value matches one of the listed values.
|
||||
|
||||
Example:
|
||||
DisplayOptions(show={"extraction_enabled": [True]})
|
||||
DisplayOptions(show={"greeting_type": ["audio"]})
|
||||
"""
|
||||
|
||||
show: Optional[dict[str, list[Any]]] = None
|
||||
hide: Optional[dict[str, list[Any]]] = None
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
def evaluate_display_options(
|
||||
rules: Optional[DisplayOptions | dict[str, Any]],
|
||||
values: dict[str, Any],
|
||||
) -> bool:
|
||||
"""Reference implementation of the display_options visibility check.
|
||||
|
||||
Mirrored 1:1 in the TypeScript renderer
|
||||
(`ui/src/components/flow/renderer/displayOptions.ts`). The golden
|
||||
fixtures in `display_options_fixtures.json` lock the two
|
||||
implementations together — update both whenever the semantics change.
|
||||
"""
|
||||
if rules is None:
|
||||
return True
|
||||
|
||||
if isinstance(rules, DisplayOptions):
|
||||
show = rules.show
|
||||
hide = rules.hide
|
||||
else:
|
||||
show = rules.get("show")
|
||||
hide = rules.get("hide")
|
||||
|
||||
if show:
|
||||
for field, allowed in show.items():
|
||||
if values.get(field) not in allowed:
|
||||
return False
|
||||
|
||||
if hide:
|
||||
for field, hidden in hide.items():
|
||||
if values.get(field) in hidden:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class PropertyOption(BaseModel):
|
||||
"""An option in an `options` or `multi_options` dropdown."""
|
||||
|
||||
value: str | int | bool | float
|
||||
label: str
|
||||
description: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class PropertySpec(BaseModel):
|
||||
"""Single field on a node.
|
||||
|
||||
`description` is HUMAN-FACING — shown under the field in the edit
|
||||
dialog. Keep it concise and explain what the field does.
|
||||
|
||||
`llm_hint` is LLM-FACING — appears only in the `get_node_type` MCP
|
||||
response and in SDK schema output. Use it for catalog tool references
|
||||
(e.g., "Use `list_recordings`"), array shape, expected value idioms,
|
||||
or anything that would be noise in the UI. Optional; omit when the
|
||||
`description` already suffices for both audiences.
|
||||
"""
|
||||
|
||||
name: str
|
||||
type: PropertyType
|
||||
display_name: str
|
||||
description: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="Human-facing explanation shown in the UI.",
|
||||
)
|
||||
llm_hint: Optional[str] = Field(
|
||||
default=None,
|
||||
description="LLM-only guidance; omitted from the UI.",
|
||||
)
|
||||
default: Any = None
|
||||
required: bool = False
|
||||
placeholder: Optional[str] = None
|
||||
|
||||
display_options: Optional[DisplayOptions] = None
|
||||
|
||||
# For `options` / `multi_options`
|
||||
options: Optional[list[PropertyOption]] = None
|
||||
|
||||
# For `fixed_collection` — sub-properties of each row
|
||||
properties: Optional[list["PropertySpec"]] = None
|
||||
|
||||
# Validation hints. Enforced by Pydantic where possible.
|
||||
min_value: Optional[float] = None
|
||||
max_value: Optional[float] = None
|
||||
min_length: Optional[int] = None
|
||||
max_length: Optional[int] = None
|
||||
pattern: Optional[str] = None
|
||||
|
||||
# Renderer hint, e.g. "textarea" vs single-line for `string`.
|
||||
editor: Optional[str] = None
|
||||
|
||||
# Free-form metadata for renderer-specific behavior. Use sparingly.
|
||||
extra: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
PropertySpec.model_rebuild()
|
||||
|
||||
|
||||
class NodeExample(BaseModel):
|
||||
"""A worked example LLMs can pattern-match. Keep small and realistic."""
|
||||
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
data: dict[str, Any]
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class GraphConstraints(BaseModel):
|
||||
"""Per-node-type graph rules. WorkflowGraph enforces these at validation."""
|
||||
|
||||
min_incoming: Optional[int] = None
|
||||
max_incoming: Optional[int] = None
|
||||
min_outgoing: Optional[int] = None
|
||||
max_outgoing: Optional[int] = None
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class NodeSpec(BaseModel):
|
||||
"""Single source of truth for a node type."""
|
||||
|
||||
name: str # machine name; matches the Pydantic discriminator value
|
||||
display_name: str
|
||||
description: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="Human-facing explanation shown in AddNodePanel.",
|
||||
)
|
||||
llm_hint: Optional[str] = Field(
|
||||
default=None,
|
||||
description="LLM-only guidance; omitted from the UI.",
|
||||
)
|
||||
category: NodeCategory
|
||||
icon: str # lucide-react icon name (e.g., "Play")
|
||||
version: str = "1.0.0"
|
||||
properties: list[PropertySpec]
|
||||
examples: list[NodeExample] = Field(default_factory=list)
|
||||
graph_constraints: Optional[GraphConstraints] = None
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
168
api/services/workflow/node_specs/agent.py
Normal file
168
api/services/workflow/node_specs/agent.py
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
"""Spec for the Agent node — the workhorse mid-call node where the LLM
|
||||
executes a focused conversational step with optional tools and documents."""
|
||||
|
||||
from api.services.workflow.node_specs._base import (
|
||||
DisplayOptions,
|
||||
GraphConstraints,
|
||||
NodeCategory,
|
||||
NodeExample,
|
||||
NodeSpec,
|
||||
PropertyOption,
|
||||
PropertySpec,
|
||||
PropertyType,
|
||||
)
|
||||
|
||||
SPEC = NodeSpec(
|
||||
name="agentNode",
|
||||
display_name="Agent Node",
|
||||
description="Conversational step — the LLM runs one focused exchange.",
|
||||
llm_hint=(
|
||||
"Mid-call step executed by the LLM. Most workflows are a chain of "
|
||||
"agent nodes connected by edges that describe transition conditions. "
|
||||
"Each agent node can invoke tools and reference documents."
|
||||
),
|
||||
category=NodeCategory.call_node,
|
||||
icon="Headset",
|
||||
properties=[
|
||||
PropertySpec(
|
||||
name="name",
|
||||
type=PropertyType.string,
|
||||
display_name="Name",
|
||||
description=(
|
||||
"Short identifier for this step (e.g., 'Qualify Budget'). "
|
||||
"Appears in call logs and edge transition tools."
|
||||
),
|
||||
required=True,
|
||||
min_length=1,
|
||||
default="Agent",
|
||||
),
|
||||
PropertySpec(
|
||||
name="prompt",
|
||||
type=PropertyType.mention_textarea,
|
||||
display_name="Prompt",
|
||||
description=(
|
||||
"Agent system prompt for this step. Supports "
|
||||
"{{template_variables}} from extraction or pre-call fetch."
|
||||
),
|
||||
required=True,
|
||||
min_length=1,
|
||||
placeholder="Ask the caller about their budget and timeline.",
|
||||
),
|
||||
PropertySpec(
|
||||
name="allow_interrupt",
|
||||
type=PropertyType.boolean,
|
||||
display_name="Allow Interruption",
|
||||
description=(
|
||||
"When true, the user can interrupt the agent mid-utterance. "
|
||||
"Set false for non-interruptible disclosures."
|
||||
),
|
||||
default=True,
|
||||
),
|
||||
PropertySpec(
|
||||
name="add_global_prompt",
|
||||
type=PropertyType.boolean,
|
||||
display_name="Add Global Prompt",
|
||||
description=(
|
||||
"When true and a Global node exists, prepends the global "
|
||||
"prompt to this node's prompt at runtime."
|
||||
),
|
||||
default=True,
|
||||
),
|
||||
PropertySpec(
|
||||
name="extraction_enabled",
|
||||
type=PropertyType.boolean,
|
||||
display_name="Enable Variable Extraction",
|
||||
description=(
|
||||
"When true, runs an LLM extraction pass on transition out of "
|
||||
"this node to capture variables from the conversation."
|
||||
),
|
||||
default=False,
|
||||
),
|
||||
PropertySpec(
|
||||
name="extraction_prompt",
|
||||
type=PropertyType.string,
|
||||
display_name="Extraction Prompt",
|
||||
description="Overall instructions guiding variable extraction.",
|
||||
display_options=DisplayOptions(show={"extraction_enabled": [True]}),
|
||||
editor="textarea",
|
||||
),
|
||||
PropertySpec(
|
||||
name="extraction_variables",
|
||||
type=PropertyType.fixed_collection,
|
||||
display_name="Variables to Extract",
|
||||
description=(
|
||||
"Each entry declares one variable to capture from the "
|
||||
"conversation, with its name, type, and per-variable hint."
|
||||
),
|
||||
display_options=DisplayOptions(show={"extraction_enabled": [True]}),
|
||||
properties=[
|
||||
PropertySpec(
|
||||
name="name",
|
||||
type=PropertyType.string,
|
||||
display_name="Variable Name",
|
||||
description="snake_case identifier used downstream.",
|
||||
required=True,
|
||||
),
|
||||
PropertySpec(
|
||||
name="type",
|
||||
type=PropertyType.options,
|
||||
display_name="Type",
|
||||
description="Data type of the extracted value.",
|
||||
required=True,
|
||||
default="string",
|
||||
options=[
|
||||
PropertyOption(value="string", label="String"),
|
||||
PropertyOption(value="number", label="Number"),
|
||||
PropertyOption(value="boolean", label="Boolean"),
|
||||
],
|
||||
),
|
||||
PropertySpec(
|
||||
name="prompt",
|
||||
type=PropertyType.string,
|
||||
display_name="Extraction Hint",
|
||||
description="Per-variable hint describing what to look for.",
|
||||
editor="textarea",
|
||||
),
|
||||
],
|
||||
),
|
||||
PropertySpec(
|
||||
name="tool_uuids",
|
||||
type=PropertyType.tool_refs,
|
||||
display_name="Tools",
|
||||
description="Tools the agent can invoke during this step.",
|
||||
llm_hint="List of tool UUIDs from `list_tools`.",
|
||||
),
|
||||
PropertySpec(
|
||||
name="document_uuids",
|
||||
type=PropertyType.document_refs,
|
||||
display_name="Knowledge Base Documents",
|
||||
description="Documents the agent can reference during this step.",
|
||||
llm_hint="List of document UUIDs from `list_documents`.",
|
||||
),
|
||||
],
|
||||
examples=[
|
||||
NodeExample(
|
||||
name="qualify_lead",
|
||||
data={
|
||||
"name": "Qualify Budget",
|
||||
"prompt": "Ask about budget and timeline. Capture both before transitioning.",
|
||||
"allow_interrupt": True,
|
||||
"extraction_enabled": True,
|
||||
"extraction_prompt": "Extract budget amount and rough timeline.",
|
||||
"extraction_variables": [
|
||||
{
|
||||
"name": "budget_usd",
|
||||
"type": "number",
|
||||
"prompt": "Stated budget in USD",
|
||||
},
|
||||
{
|
||||
"name": "timeline",
|
||||
"type": "string",
|
||||
"prompt": "When they want to start",
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
],
|
||||
graph_constraints=GraphConstraints(min_incoming=1),
|
||||
)
|
||||
123
api/services/workflow/node_specs/display_options_fixtures.json
Normal file
123
api/services/workflow/node_specs/display_options_fixtures.json
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
{
|
||||
"_doc": "Golden fixtures for the display_options evaluator. Both the Python evaluator (api/services/workflow/node_specs/_base.py:evaluate_display_options) and the TypeScript evaluator (ui/src/components/flow/renderer/displayOptions.ts:evaluateDisplayOptions) must agree on every case here. Fixtures double as documentation for the show/hide semantics.",
|
||||
"cases": [
|
||||
{
|
||||
"name": "no_rules_visible",
|
||||
"rules": null,
|
||||
"values": {"a": 1},
|
||||
"expected": true
|
||||
},
|
||||
{
|
||||
"name": "empty_rules_visible",
|
||||
"rules": {"show": null, "hide": null},
|
||||
"values": {},
|
||||
"expected": true
|
||||
},
|
||||
{
|
||||
"name": "show_match_visible",
|
||||
"rules": {"show": {"extraction_enabled": [true]}},
|
||||
"values": {"extraction_enabled": true},
|
||||
"expected": true
|
||||
},
|
||||
{
|
||||
"name": "show_mismatch_hidden",
|
||||
"rules": {"show": {"extraction_enabled": [true]}},
|
||||
"values": {"extraction_enabled": false},
|
||||
"expected": false
|
||||
},
|
||||
{
|
||||
"name": "show_missing_field_hidden",
|
||||
"rules": {"show": {"extraction_enabled": [true]}},
|
||||
"values": {},
|
||||
"expected": false
|
||||
},
|
||||
{
|
||||
"name": "show_multiple_allowed_values",
|
||||
"rules": {"show": {"greeting_type": ["text", "audio"]}},
|
||||
"values": {"greeting_type": "audio"},
|
||||
"expected": true
|
||||
},
|
||||
{
|
||||
"name": "show_multiple_keys_all_match",
|
||||
"rules": {
|
||||
"show": {
|
||||
"qa_use_workflow_llm": [false],
|
||||
"qa_provider": ["azure"]
|
||||
}
|
||||
},
|
||||
"values": {"qa_use_workflow_llm": false, "qa_provider": "azure"},
|
||||
"expected": true
|
||||
},
|
||||
{
|
||||
"name": "show_multiple_keys_one_mismatch_hides",
|
||||
"rules": {
|
||||
"show": {
|
||||
"qa_use_workflow_llm": [false],
|
||||
"qa_provider": ["azure"]
|
||||
}
|
||||
},
|
||||
"values": {"qa_use_workflow_llm": false, "qa_provider": "openai"},
|
||||
"expected": false
|
||||
},
|
||||
{
|
||||
"name": "hide_match_hides",
|
||||
"rules": {"hide": {"locked": [true]}},
|
||||
"values": {"locked": true},
|
||||
"expected": false
|
||||
},
|
||||
{
|
||||
"name": "hide_mismatch_visible",
|
||||
"rules": {"hide": {"locked": [true]}},
|
||||
"values": {"locked": false},
|
||||
"expected": true
|
||||
},
|
||||
{
|
||||
"name": "hide_missing_field_visible",
|
||||
"rules": {"hide": {"locked": [true]}},
|
||||
"values": {},
|
||||
"expected": true
|
||||
},
|
||||
{
|
||||
"name": "hide_or_combined_either_hides",
|
||||
"rules": {"hide": {"a": [1], "b": [2]}},
|
||||
"values": {"a": 0, "b": 2},
|
||||
"expected": false
|
||||
},
|
||||
{
|
||||
"name": "show_and_hide_both_required",
|
||||
"rules": {"show": {"enabled": [true]}, "hide": {"locked": [true]}},
|
||||
"values": {"enabled": true, "locked": false},
|
||||
"expected": true
|
||||
},
|
||||
{
|
||||
"name": "show_and_hide_show_passes_hide_blocks",
|
||||
"rules": {"show": {"enabled": [true]}, "hide": {"locked": [true]}},
|
||||
"values": {"enabled": true, "locked": true},
|
||||
"expected": false
|
||||
},
|
||||
{
|
||||
"name": "show_and_hide_show_fails_hide_irrelevant",
|
||||
"rules": {"show": {"enabled": [true]}, "hide": {"locked": [true]}},
|
||||
"values": {"enabled": false, "locked": false},
|
||||
"expected": false
|
||||
},
|
||||
{
|
||||
"name": "scalar_int_strict",
|
||||
"rules": {"show": {"sample_rate": [100]}},
|
||||
"values": {"sample_rate": 100},
|
||||
"expected": true
|
||||
},
|
||||
{
|
||||
"name": "scalar_int_mismatch",
|
||||
"rules": {"show": {"sample_rate": [100]}},
|
||||
"values": {"sample_rate": 99},
|
||||
"expected": false
|
||||
},
|
||||
{
|
||||
"name": "scalar_string_strict",
|
||||
"rules": {"show": {"http_method": ["POST", "PUT"]}},
|
||||
"values": {"http_method": "GET"},
|
||||
"expected": false
|
||||
}
|
||||
]
|
||||
}
|
||||
141
api/services/workflow/node_specs/end_call.py
Normal file
141
api/services/workflow/node_specs/end_call.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
"""Spec for the End Call node — terminal node that wraps up a conversation
|
||||
and optionally extracts variables before hangup."""
|
||||
|
||||
from api.services.workflow.node_specs._base import (
|
||||
DisplayOptions,
|
||||
GraphConstraints,
|
||||
NodeCategory,
|
||||
NodeExample,
|
||||
NodeSpec,
|
||||
PropertyOption,
|
||||
PropertySpec,
|
||||
PropertyType,
|
||||
)
|
||||
|
||||
SPEC = NodeSpec(
|
||||
name="endCall",
|
||||
display_name="End Call",
|
||||
description="Closes the conversation and hangs up.",
|
||||
llm_hint=(
|
||||
"Terminal node that politely closes the conversation. Variable "
|
||||
"extraction can run before hangup. A workflow can have multiple "
|
||||
"endCall nodes reached via different edge conditions."
|
||||
),
|
||||
category=NodeCategory.call_node,
|
||||
icon="OctagonX",
|
||||
properties=[
|
||||
PropertySpec(
|
||||
name="name",
|
||||
type=PropertyType.string,
|
||||
display_name="Name",
|
||||
description=(
|
||||
"Short identifier shown in call logs. Should describe the "
|
||||
"ending context (e.g., 'Successful close', 'Polite decline')."
|
||||
),
|
||||
required=True,
|
||||
min_length=1,
|
||||
default="End Call",
|
||||
),
|
||||
PropertySpec(
|
||||
name="prompt",
|
||||
type=PropertyType.mention_textarea,
|
||||
display_name="Prompt",
|
||||
description=(
|
||||
"Agent system prompt for the closing exchange. Supports "
|
||||
"{{template_variables}} from extraction or pre-call fetch."
|
||||
),
|
||||
required=True,
|
||||
min_length=1,
|
||||
placeholder="Thank the caller and confirm next steps before ending the call.",
|
||||
),
|
||||
PropertySpec(
|
||||
name="add_global_prompt",
|
||||
type=PropertyType.boolean,
|
||||
display_name="Add Global Prompt",
|
||||
description=(
|
||||
"When true and a Global node exists, prepends the global "
|
||||
"prompt to this node's prompt at runtime."
|
||||
),
|
||||
default=False,
|
||||
),
|
||||
PropertySpec(
|
||||
name="extraction_enabled",
|
||||
type=PropertyType.boolean,
|
||||
display_name="Enable Variable Extraction",
|
||||
description=(
|
||||
"When true, runs an LLM extraction pass before hangup to "
|
||||
"capture variables from the conversation."
|
||||
),
|
||||
default=False,
|
||||
),
|
||||
PropertySpec(
|
||||
name="extraction_prompt",
|
||||
type=PropertyType.string,
|
||||
display_name="Extraction Prompt",
|
||||
description=(
|
||||
"Overall instructions guiding how variables should be "
|
||||
"extracted from the conversation."
|
||||
),
|
||||
display_options=DisplayOptions(show={"extraction_enabled": [True]}),
|
||||
editor="textarea",
|
||||
),
|
||||
PropertySpec(
|
||||
name="extraction_variables",
|
||||
type=PropertyType.fixed_collection,
|
||||
display_name="Variables to Extract",
|
||||
description=(
|
||||
"Each entry declares one variable to capture from the "
|
||||
"conversation, with its name, data type, and a per-variable "
|
||||
"extraction hint."
|
||||
),
|
||||
display_options=DisplayOptions(show={"extraction_enabled": [True]}),
|
||||
properties=[
|
||||
PropertySpec(
|
||||
name="name",
|
||||
type=PropertyType.string,
|
||||
display_name="Variable Name",
|
||||
description="snake_case identifier used downstream.",
|
||||
required=True,
|
||||
),
|
||||
PropertySpec(
|
||||
name="type",
|
||||
type=PropertyType.options,
|
||||
display_name="Type",
|
||||
description="The data type of the extracted value.",
|
||||
required=True,
|
||||
default="string",
|
||||
options=[
|
||||
PropertyOption(value="string", label="String"),
|
||||
PropertyOption(value="number", label="Number"),
|
||||
PropertyOption(value="boolean", label="Boolean"),
|
||||
],
|
||||
),
|
||||
PropertySpec(
|
||||
name="prompt",
|
||||
type=PropertyType.string,
|
||||
display_name="Extraction Hint",
|
||||
description=(
|
||||
"Per-variable hint describing what to look for in "
|
||||
"the conversation."
|
||||
),
|
||||
editor="textarea",
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
examples=[
|
||||
NodeExample(
|
||||
name="successful_close",
|
||||
data={
|
||||
"name": "Successful Close",
|
||||
"prompt": "Confirm the appointment time, thank the caller, and end the call.",
|
||||
"add_global_prompt": False,
|
||||
},
|
||||
),
|
||||
],
|
||||
graph_constraints=GraphConstraints(
|
||||
min_incoming=1,
|
||||
min_outgoing=0,
|
||||
max_outgoing=0,
|
||||
),
|
||||
)
|
||||
77
api/services/workflow/node_specs/global_node.py
Normal file
77
api/services/workflow/node_specs/global_node.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""Spec for the Global node — system-level instructions appended to every
|
||||
agent node that opts in via `add_global_prompt`."""
|
||||
|
||||
from api.services.workflow.node_specs._base import (
|
||||
GraphConstraints,
|
||||
NodeCategory,
|
||||
NodeExample,
|
||||
NodeSpec,
|
||||
PropertySpec,
|
||||
PropertyType,
|
||||
)
|
||||
|
||||
SPEC = NodeSpec(
|
||||
name="globalNode",
|
||||
display_name="Global Node",
|
||||
description="Persona/tone appended to every agent node's prompt.",
|
||||
llm_hint=(
|
||||
"System-level prompt appended to every prompted node whose "
|
||||
"`add_global_prompt` is true. Use it for persona, tone, and shared "
|
||||
"rules that apply across the entire conversation. At most one "
|
||||
"global node per workflow."
|
||||
),
|
||||
category=NodeCategory.global_node,
|
||||
icon="Globe",
|
||||
properties=[
|
||||
PropertySpec(
|
||||
name="name",
|
||||
type=PropertyType.string,
|
||||
display_name="Name",
|
||||
description=(
|
||||
"Short identifier shown in the canvas and call logs. Has no "
|
||||
"runtime effect."
|
||||
),
|
||||
required=True,
|
||||
min_length=1,
|
||||
default="Global Node",
|
||||
),
|
||||
PropertySpec(
|
||||
name="prompt",
|
||||
type=PropertyType.mention_textarea,
|
||||
display_name="Global Prompt",
|
||||
description=(
|
||||
"Text appended to every prompted node's system prompt when "
|
||||
"that node has `add_global_prompt=true`. Supports "
|
||||
"{{template_variables}}."
|
||||
),
|
||||
required=True,
|
||||
min_length=1,
|
||||
placeholder="You are a friendly assistant calling on behalf of {{company_name}}.",
|
||||
default=(
|
||||
"You are a helpful assistant whose mode of interaction with "
|
||||
"the user is voice. So don't use any special characters which "
|
||||
"can not be pronounced. Use short sentences and simple language."
|
||||
),
|
||||
),
|
||||
],
|
||||
examples=[
|
||||
NodeExample(
|
||||
name="basic_persona",
|
||||
description="Establishes a consistent persona across the call.",
|
||||
data={
|
||||
"name": "Persona",
|
||||
"prompt": (
|
||||
"You are Sarah, a polite and warm representative from "
|
||||
"Acme Corp. Always thank the caller for their time and "
|
||||
"speak in short conversational sentences."
|
||||
),
|
||||
},
|
||||
),
|
||||
],
|
||||
graph_constraints=GraphConstraints(
|
||||
min_incoming=0,
|
||||
max_incoming=0,
|
||||
min_outgoing=0,
|
||||
max_outgoing=0,
|
||||
),
|
||||
)
|
||||
196
api/services/workflow/node_specs/qa.py
Normal file
196
api/services/workflow/node_specs/qa.py
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
"""Spec for the QA Analysis node — runs an LLM quality review on the call
|
||||
transcript after completion."""
|
||||
|
||||
from api.services.workflow.node_specs._base import (
|
||||
DisplayOptions,
|
||||
NodeCategory,
|
||||
NodeExample,
|
||||
NodeSpec,
|
||||
PropertyOption,
|
||||
PropertySpec,
|
||||
PropertyType,
|
||||
)
|
||||
|
||||
DEFAULT_QA_SYSTEM_PROMPT = """You are a QA analyst evaluating a specific segment of a voice AI conversation.
|
||||
|
||||
## Node Purpose
|
||||
{{node_summary}}
|
||||
|
||||
## Previous Conversation Context (For start of conversation, previous conversation summary can be empty.)
|
||||
{{previous_conversation_summary}}
|
||||
|
||||
## Tags to evaluate
|
||||
|
||||
Examine the conversation carefully and identify which of the following tags apply:
|
||||
|
||||
- UNCLEAR_CONVERSATION - The conversation is not coherent or clear, messages don't connect logically
|
||||
- ASSISTANT_IN_LOOP - The assistant asks the same question multiple times or gets stuck repeating itself
|
||||
- ASSISTANT_REPLY_IMPROPER - The assistant did not reply properly to the user's question/query or seems confused by what the user said
|
||||
- USER_FRUSTRATED - The user seems angry, frustrated, or is complaining about something in the call
|
||||
- USER_NOT_UNDERSTANDING - The user explicitly says they don't understand or repeatedly asks for clarification
|
||||
- HEARING_ISSUES - Either party can't hear the other ("hello?", "are you there?", "can you hear me?")
|
||||
- DEAD_AIR - Unusually long silences in the conversation (use the timestamps to judge)
|
||||
- USER_REQUESTING_FEATURE - The user asks for something the assistant can't fulfill
|
||||
- ASSISTANT_LACKS_EMPATHY - The assistant ignores the user's personal situation or emotional state and continues pitching or pushing the agenda.
|
||||
- USER_DETECTS_AI - The user suspects or identifies that they are talking to an AI/robot/bot rather than a real human.
|
||||
|
||||
## Call metrics (pre-computed)
|
||||
|
||||
Use these alongside the transcript for your analysis:
|
||||
{{metrics}}
|
||||
|
||||
## Output format
|
||||
|
||||
Return ONLY a valid JSON object (no markdown):
|
||||
{
|
||||
"tags": [
|
||||
{
|
||||
"tag": "TAG_NAME",
|
||||
"reason": "Short reason with evidence from the transcript"
|
||||
}
|
||||
],
|
||||
"overall_sentiment": "positive|neutral|negative",
|
||||
"call_quality_score": <1-10>,
|
||||
"summary": "1-2 sentence summary of this segment"
|
||||
}
|
||||
|
||||
If no tags apply, return an empty tags list. Always provide sentiment, score, and summary."""
|
||||
|
||||
|
||||
SPEC = NodeSpec(
|
||||
name="qa",
|
||||
display_name="QA Analysis",
|
||||
description="Run LLM quality analysis on the call transcript.",
|
||||
llm_hint=(
|
||||
"Runs an LLM quality review on the call transcript after completion. "
|
||||
"Per-node analysis splits the conversation by node and evaluates each "
|
||||
"segment against the configured system prompt. Sampling, minimum "
|
||||
"duration, and voicemail filters are supported."
|
||||
),
|
||||
category=NodeCategory.integration,
|
||||
icon="ClipboardCheck",
|
||||
properties=[
|
||||
PropertySpec(
|
||||
name="name",
|
||||
type=PropertyType.string,
|
||||
display_name="Name",
|
||||
description="Short identifier for this QA configuration.",
|
||||
required=True,
|
||||
min_length=1,
|
||||
default="QA Analysis",
|
||||
),
|
||||
PropertySpec(
|
||||
name="qa_enabled",
|
||||
type=PropertyType.boolean,
|
||||
display_name="Enabled",
|
||||
description="When false, the QA run is skipped.",
|
||||
default=True,
|
||||
),
|
||||
PropertySpec(
|
||||
name="qa_system_prompt",
|
||||
type=PropertyType.string,
|
||||
display_name="System Prompt",
|
||||
description=(
|
||||
"Instructions to the QA reviewer LLM. Supports placeholders: "
|
||||
"`{node_summary}`, `{previous_conversation_summary}`, "
|
||||
"`{transcript}`, `{metrics}`."
|
||||
),
|
||||
editor="textarea",
|
||||
default=DEFAULT_QA_SYSTEM_PROMPT,
|
||||
),
|
||||
PropertySpec(
|
||||
name="qa_min_call_duration",
|
||||
type=PropertyType.number,
|
||||
display_name="Minimum Call Duration (seconds)",
|
||||
description="Calls shorter than this are skipped.",
|
||||
default=15,
|
||||
min_value=0,
|
||||
),
|
||||
PropertySpec(
|
||||
name="qa_voicemail_calls",
|
||||
type=PropertyType.boolean,
|
||||
display_name="Include Voicemail Calls",
|
||||
description="When false, calls flagged as voicemail are skipped.",
|
||||
default=False,
|
||||
),
|
||||
PropertySpec(
|
||||
name="qa_sample_rate",
|
||||
type=PropertyType.number,
|
||||
display_name="Sample Rate (%)",
|
||||
description=(
|
||||
"Percent of eligible calls QA'd. 100 means every call; lower "
|
||||
"values use random sampling."
|
||||
),
|
||||
default=100,
|
||||
min_value=1,
|
||||
max_value=100,
|
||||
),
|
||||
# ---- LLM configuration ----
|
||||
PropertySpec(
|
||||
name="qa_use_workflow_llm",
|
||||
type=PropertyType.boolean,
|
||||
display_name="Use Workflow's LLM",
|
||||
description=(
|
||||
"When true, the QA pass uses the same LLM the workflow runs "
|
||||
"with. Set false to specify a separate provider/model."
|
||||
),
|
||||
default=True,
|
||||
),
|
||||
PropertySpec(
|
||||
name="qa_provider",
|
||||
type=PropertyType.options,
|
||||
display_name="QA LLM Provider",
|
||||
description="LLM provider used for the QA pass.",
|
||||
display_options=DisplayOptions(show={"qa_use_workflow_llm": [False]}),
|
||||
options=[
|
||||
PropertyOption(value="openai", label="OpenAI"),
|
||||
PropertyOption(value="azure", label="Azure OpenAI"),
|
||||
PropertyOption(value="openrouter", label="OpenRouter"),
|
||||
PropertyOption(value="anthropic", label="Anthropic"),
|
||||
],
|
||||
),
|
||||
PropertySpec(
|
||||
name="qa_model",
|
||||
type=PropertyType.string,
|
||||
display_name="QA Model",
|
||||
description=(
|
||||
"Model identifier (e.g., 'gpt-4o', 'claude-sonnet-4-6'). "
|
||||
"Provider-specific."
|
||||
),
|
||||
display_options=DisplayOptions(show={"qa_use_workflow_llm": [False]}),
|
||||
default="default",
|
||||
),
|
||||
PropertySpec(
|
||||
name="qa_api_key",
|
||||
type=PropertyType.string,
|
||||
display_name="API Key",
|
||||
description="API key for the chosen provider.",
|
||||
display_options=DisplayOptions(show={"qa_use_workflow_llm": [False]}),
|
||||
),
|
||||
PropertySpec(
|
||||
name="qa_endpoint",
|
||||
type=PropertyType.url,
|
||||
display_name="Azure Endpoint",
|
||||
description="Required for the Azure provider.",
|
||||
display_options=DisplayOptions(
|
||||
show={"qa_use_workflow_llm": [False], "qa_provider": ["azure"]}
|
||||
),
|
||||
),
|
||||
],
|
||||
examples=[
|
||||
NodeExample(
|
||||
name="basic_qa",
|
||||
data={
|
||||
"name": "Compliance Check",
|
||||
"qa_enabled": True,
|
||||
"qa_system_prompt": (
|
||||
"You are a compliance reviewer. Review the transcript and "
|
||||
"produce a JSON object with `tags`, `summary`, "
|
||||
"`call_quality_score`, and `overall_sentiment`."
|
||||
),
|
||||
"qa_min_call_duration": 30,
|
||||
"qa_sample_rate": 100,
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
248
api/services/workflow/node_specs/start_call.py
Normal file
248
api/services/workflow/node_specs/start_call.py
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
"""Spec for the Start Call node — the single entry point of every workflow.
|
||||
Carries greeting, pre-call data fetch, and the same prompt/extraction/tools
|
||||
fields as agent nodes."""
|
||||
|
||||
from api.services.workflow.node_specs._base import (
|
||||
DisplayOptions,
|
||||
GraphConstraints,
|
||||
NodeCategory,
|
||||
NodeExample,
|
||||
NodeSpec,
|
||||
PropertyOption,
|
||||
PropertySpec,
|
||||
PropertyType,
|
||||
)
|
||||
|
||||
SPEC = NodeSpec(
|
||||
name="startCall",
|
||||
display_name="Start Call",
|
||||
description="Entry point of the workflow — plays a greeting and opens the conversation.",
|
||||
llm_hint=(
|
||||
"The entry point of every workflow (exactly one required). Plays an "
|
||||
"optional greeting, can fetch context from an external API before "
|
||||
"the call begins, and executes the first conversational turn."
|
||||
),
|
||||
category=NodeCategory.call_node,
|
||||
icon="Play",
|
||||
properties=[
|
||||
PropertySpec(
|
||||
name="name",
|
||||
type=PropertyType.string,
|
||||
display_name="Name",
|
||||
description="Short identifier shown in the canvas and call logs.",
|
||||
required=True,
|
||||
min_length=1,
|
||||
default="Start Call",
|
||||
),
|
||||
# ---- Greeting (variant via greeting_type) ----
|
||||
PropertySpec(
|
||||
name="greeting_type",
|
||||
type=PropertyType.options,
|
||||
display_name="Greeting Type",
|
||||
description=(
|
||||
"Whether the optional greeting is spoken via TTS from text "
|
||||
"or played from a pre-recorded audio file."
|
||||
),
|
||||
default="text",
|
||||
options=[
|
||||
PropertyOption(value="text", label="Text (TTS)"),
|
||||
PropertyOption(value="audio", label="Pre-recorded Audio"),
|
||||
],
|
||||
),
|
||||
PropertySpec(
|
||||
name="greeting",
|
||||
type=PropertyType.string,
|
||||
display_name="Greeting Text",
|
||||
description=(
|
||||
"Text spoken via TTS at the start of the call. Supports "
|
||||
"{{template_variables}}. Leave empty to skip the greeting."
|
||||
),
|
||||
display_options=DisplayOptions(show={"greeting_type": ["text"]}),
|
||||
editor="textarea",
|
||||
placeholder="Hi {{first_name}}, this is Sarah from Acme.",
|
||||
),
|
||||
PropertySpec(
|
||||
name="greeting_recording_id",
|
||||
type=PropertyType.recording_ref,
|
||||
display_name="Greeting Recording",
|
||||
description="Pre-recorded audio file played at the start of the call.",
|
||||
llm_hint=(
|
||||
"Value is the `recording_id` string. Use the `list_recordings` "
|
||||
"MCP tool to discover available recordings."
|
||||
),
|
||||
display_options=DisplayOptions(show={"greeting_type": ["audio"]}),
|
||||
),
|
||||
PropertySpec(
|
||||
name="prompt",
|
||||
type=PropertyType.mention_textarea,
|
||||
display_name="Prompt",
|
||||
description=(
|
||||
"Agent system prompt for the opening turn. Supports "
|
||||
"{{template_variables}} from pre-call fetch and the initial context."
|
||||
),
|
||||
required=True,
|
||||
min_length=1,
|
||||
placeholder="Greet the caller warmly and ask how you can help today.",
|
||||
),
|
||||
# ---- Behavior toggles ----
|
||||
PropertySpec(
|
||||
name="allow_interrupt",
|
||||
type=PropertyType.boolean,
|
||||
display_name="Allow Interruption",
|
||||
description=("When true, the user can interrupt the agent mid-utterance."),
|
||||
default=False,
|
||||
),
|
||||
PropertySpec(
|
||||
name="add_global_prompt",
|
||||
type=PropertyType.boolean,
|
||||
display_name="Add Global Prompt",
|
||||
description=(
|
||||
"When true and a Global node exists, prepends the global "
|
||||
"prompt to this node's prompt at runtime."
|
||||
),
|
||||
default=True,
|
||||
),
|
||||
PropertySpec(
|
||||
name="delayed_start",
|
||||
type=PropertyType.boolean,
|
||||
display_name="Delayed Start",
|
||||
description=(
|
||||
"When true, the agent waits before speaking after pickup. "
|
||||
"Useful for outbound calls where the called party needs a "
|
||||
"moment to settle."
|
||||
),
|
||||
default=False,
|
||||
),
|
||||
PropertySpec(
|
||||
name="delayed_start_duration",
|
||||
type=PropertyType.number,
|
||||
display_name="Delay Duration (seconds)",
|
||||
description="Seconds to wait before the agent speaks. 0.1–10.",
|
||||
default=2.0,
|
||||
min_value=0.1,
|
||||
max_value=10.0,
|
||||
display_options=DisplayOptions(show={"delayed_start": [True]}),
|
||||
),
|
||||
# ---- Variable extraction ----
|
||||
PropertySpec(
|
||||
name="extraction_enabled",
|
||||
type=PropertyType.boolean,
|
||||
display_name="Enable Variable Extraction",
|
||||
description=(
|
||||
"When true, runs an LLM extraction pass on transition out of "
|
||||
"this node to capture variables from the opening turn."
|
||||
),
|
||||
default=False,
|
||||
),
|
||||
PropertySpec(
|
||||
name="extraction_prompt",
|
||||
type=PropertyType.string,
|
||||
display_name="Extraction Prompt",
|
||||
description="Overall instructions guiding variable extraction.",
|
||||
display_options=DisplayOptions(show={"extraction_enabled": [True]}),
|
||||
editor="textarea",
|
||||
),
|
||||
PropertySpec(
|
||||
name="extraction_variables",
|
||||
type=PropertyType.fixed_collection,
|
||||
display_name="Variables to Extract",
|
||||
description=(
|
||||
"Each entry declares one variable to capture, with its name, "
|
||||
"data type, and per-variable extraction hint."
|
||||
),
|
||||
display_options=DisplayOptions(show={"extraction_enabled": [True]}),
|
||||
properties=[
|
||||
PropertySpec(
|
||||
name="name",
|
||||
type=PropertyType.string,
|
||||
display_name="Variable Name",
|
||||
description="snake_case identifier used downstream.",
|
||||
required=True,
|
||||
),
|
||||
PropertySpec(
|
||||
name="type",
|
||||
type=PropertyType.options,
|
||||
display_name="Type",
|
||||
description="Data type of the extracted value.",
|
||||
required=True,
|
||||
default="string",
|
||||
options=[
|
||||
PropertyOption(value="string", label="String"),
|
||||
PropertyOption(value="number", label="Number"),
|
||||
PropertyOption(value="boolean", label="Boolean"),
|
||||
],
|
||||
),
|
||||
PropertySpec(
|
||||
name="prompt",
|
||||
type=PropertyType.string,
|
||||
display_name="Extraction Hint",
|
||||
description="Per-variable hint describing what to look for.",
|
||||
editor="textarea",
|
||||
),
|
||||
],
|
||||
),
|
||||
# ---- Tools / documents ----
|
||||
PropertySpec(
|
||||
name="tool_uuids",
|
||||
type=PropertyType.tool_refs,
|
||||
display_name="Tools",
|
||||
description="Tools the agent can invoke during the opening turn.",
|
||||
llm_hint="List of tool UUIDs from `list_tools`.",
|
||||
),
|
||||
PropertySpec(
|
||||
name="document_uuids",
|
||||
type=PropertyType.document_refs,
|
||||
display_name="Knowledge Base Documents",
|
||||
description="Documents the agent can reference.",
|
||||
llm_hint="List of document UUIDs from `list_documents`.",
|
||||
),
|
||||
# ---- Pre-call data fetch (advanced) ----
|
||||
PropertySpec(
|
||||
name="pre_call_fetch_enabled",
|
||||
type=PropertyType.boolean,
|
||||
display_name="Pre-Call Data Fetch",
|
||||
description=(
|
||||
"When true, makes a POST request to an external API before "
|
||||
"the call starts and merges the JSON response into the call "
|
||||
"context as template variables."
|
||||
),
|
||||
default=False,
|
||||
),
|
||||
PropertySpec(
|
||||
name="pre_call_fetch_url",
|
||||
type=PropertyType.url,
|
||||
display_name="Endpoint URL",
|
||||
description=(
|
||||
"URL the pre-call POST request is sent to. The request body "
|
||||
"includes caller and called numbers."
|
||||
),
|
||||
display_options=DisplayOptions(show={"pre_call_fetch_enabled": [True]}),
|
||||
placeholder="https://api.example.com/customer-lookup",
|
||||
),
|
||||
PropertySpec(
|
||||
name="pre_call_fetch_credential_uuid",
|
||||
type=PropertyType.credential_ref,
|
||||
display_name="Authentication",
|
||||
description="Optional credential attached to the pre-call request.",
|
||||
llm_hint="Credential UUID from `list_credentials`.",
|
||||
display_options=DisplayOptions(show={"pre_call_fetch_enabled": [True]}),
|
||||
),
|
||||
],
|
||||
examples=[
|
||||
NodeExample(
|
||||
name="warm_greeting",
|
||||
data={
|
||||
"name": "Greeting",
|
||||
"prompt": "Greet warmly and ask the caller's reason for calling.",
|
||||
"greeting_type": "text",
|
||||
"greeting": "Hi {{first_name}}, this is Sarah from Acme.",
|
||||
"allow_interrupt": True,
|
||||
},
|
||||
),
|
||||
],
|
||||
graph_constraints=GraphConstraints(
|
||||
min_incoming=0,
|
||||
max_incoming=0,
|
||||
min_outgoing=1,
|
||||
),
|
||||
)
|
||||
61
api/services/workflow/node_specs/trigger.py
Normal file
61
api/services/workflow/node_specs/trigger.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"""Spec for the API Trigger node — exposes a public webhook URL that
|
||||
external systems can hit to launch the workflow."""
|
||||
|
||||
from api.services.workflow.node_specs._base import (
|
||||
GraphConstraints,
|
||||
NodeCategory,
|
||||
NodeExample,
|
||||
NodeSpec,
|
||||
PropertySpec,
|
||||
PropertyType,
|
||||
)
|
||||
|
||||
SPEC = NodeSpec(
|
||||
name="trigger",
|
||||
display_name="API Trigger",
|
||||
description="Public HTTP endpoint that launches the workflow.",
|
||||
llm_hint=(
|
||||
"Exposes a public HTTP POST endpoint. External systems call the URL "
|
||||
"(derived from the auto-generated `trigger_path`) to launch this "
|
||||
"workflow. Requires an API key in the `X-API-Key` header."
|
||||
),
|
||||
category=NodeCategory.trigger,
|
||||
icon="Webhook",
|
||||
properties=[
|
||||
PropertySpec(
|
||||
name="name",
|
||||
type=PropertyType.string,
|
||||
display_name="Name",
|
||||
description="Short identifier shown in the canvas. No runtime effect.",
|
||||
required=True,
|
||||
min_length=1,
|
||||
default="API Trigger",
|
||||
),
|
||||
PropertySpec(
|
||||
name="enabled",
|
||||
type=PropertyType.boolean,
|
||||
display_name="Enabled",
|
||||
description="When false, the trigger URL returns 404.",
|
||||
default=True,
|
||||
),
|
||||
PropertySpec(
|
||||
name="trigger_path",
|
||||
type=PropertyType.string,
|
||||
display_name="Trigger Path",
|
||||
description=(
|
||||
"Auto-generated UUID-style path segment that uniquely "
|
||||
"identifies this trigger. Do not edit manually."
|
||||
),
|
||||
),
|
||||
],
|
||||
examples=[
|
||||
NodeExample(
|
||||
name="default",
|
||||
data={"name": "Inbound Trigger", "enabled": True},
|
||||
),
|
||||
],
|
||||
graph_constraints=GraphConstraints(
|
||||
min_incoming=0,
|
||||
max_incoming=0,
|
||||
),
|
||||
)
|
||||
135
api/services/workflow/node_specs/webhook.py
Normal file
135
api/services/workflow/node_specs/webhook.py
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
"""Spec for the Webhook node — sends an HTTP request to an external system
|
||||
after the workflow completes."""
|
||||
|
||||
from api.services.workflow.node_specs._base import (
|
||||
NodeCategory,
|
||||
NodeExample,
|
||||
NodeSpec,
|
||||
PropertyOption,
|
||||
PropertySpec,
|
||||
PropertyType,
|
||||
)
|
||||
|
||||
SPEC = NodeSpec(
|
||||
name="webhook",
|
||||
display_name="Webhook",
|
||||
description="Send HTTP request after the workflow completes.",
|
||||
llm_hint=(
|
||||
"Sends an HTTP request to an external system after the workflow "
|
||||
"completes. The payload is a Jinja-templated JSON body with access "
|
||||
"to `workflow_run_id`, `initial_context`, `gathered_context`, "
|
||||
"`annotations`, and call metadata."
|
||||
),
|
||||
category=NodeCategory.integration,
|
||||
icon="Link2",
|
||||
properties=[
|
||||
PropertySpec(
|
||||
name="name",
|
||||
type=PropertyType.string,
|
||||
display_name="Name",
|
||||
description="Short identifier shown in the canvas and run logs.",
|
||||
required=True,
|
||||
min_length=1,
|
||||
default="Webhook",
|
||||
),
|
||||
PropertySpec(
|
||||
name="enabled",
|
||||
type=PropertyType.boolean,
|
||||
display_name="Enabled",
|
||||
description="When false, the webhook is skipped at run time.",
|
||||
default=True,
|
||||
),
|
||||
PropertySpec(
|
||||
name="http_method",
|
||||
type=PropertyType.options,
|
||||
display_name="HTTP Method",
|
||||
description="HTTP verb used for the outbound request.",
|
||||
default="POST",
|
||||
options=[
|
||||
PropertyOption(value="GET", label="GET"),
|
||||
PropertyOption(value="POST", label="POST"),
|
||||
PropertyOption(value="PUT", label="PUT"),
|
||||
PropertyOption(value="PATCH", label="PATCH"),
|
||||
PropertyOption(value="DELETE", label="DELETE"),
|
||||
],
|
||||
),
|
||||
PropertySpec(
|
||||
name="endpoint_url",
|
||||
type=PropertyType.url,
|
||||
display_name="Endpoint URL",
|
||||
description="URL the request is sent to.",
|
||||
placeholder="https://api.example.com/webhook",
|
||||
),
|
||||
PropertySpec(
|
||||
name="credential_uuid",
|
||||
type=PropertyType.credential_ref,
|
||||
display_name="Authentication",
|
||||
description="Optional credential applied as the Authorization header.",
|
||||
llm_hint="Credential UUID from `list_credentials`.",
|
||||
),
|
||||
PropertySpec(
|
||||
name="custom_headers",
|
||||
type=PropertyType.fixed_collection,
|
||||
display_name="Custom Headers",
|
||||
description="Additional HTTP headers to include with the request.",
|
||||
properties=[
|
||||
PropertySpec(
|
||||
name="key",
|
||||
type=PropertyType.string,
|
||||
display_name="Header Name",
|
||||
description="HTTP header name (e.g., 'X-Source').",
|
||||
required=True,
|
||||
),
|
||||
PropertySpec(
|
||||
name="value",
|
||||
type=PropertyType.string,
|
||||
display_name="Header Value",
|
||||
description="Header value (supports {{template_variables}}).",
|
||||
required=True,
|
||||
),
|
||||
],
|
||||
),
|
||||
PropertySpec(
|
||||
name="payload_template",
|
||||
type=PropertyType.json,
|
||||
display_name="Payload Template",
|
||||
description=(
|
||||
"JSON body of the request. Values are Jinja-rendered against "
|
||||
"the run context — `{{workflow_run_id}}`, "
|
||||
"`{{gathered_context.foo}}`, `{{annotations.qa_xxx}}`, etc."
|
||||
),
|
||||
default={
|
||||
"call_id": "{{workflow_run_id}}",
|
||||
"first_name": "{{initial_context.first_name}}",
|
||||
"rsvp": "{{gathered_context.rsvp}}",
|
||||
"duration": "{{cost_info.call_duration_seconds}}",
|
||||
"recording_url": "{{recording_url}}",
|
||||
"transcript_url": "{{transcript_url}}",
|
||||
},
|
||||
),
|
||||
PropertySpec(
|
||||
name="retry_config",
|
||||
type=PropertyType.json,
|
||||
display_name="Retry Configuration",
|
||||
description=(
|
||||
"Optional retry settings: `enabled` (bool), `max_retries` "
|
||||
"(int), `retry_delay_seconds` (int)."
|
||||
),
|
||||
),
|
||||
],
|
||||
examples=[
|
||||
NodeExample(
|
||||
name="post_to_crm",
|
||||
data={
|
||||
"name": "Notify CRM",
|
||||
"enabled": True,
|
||||
"http_method": "POST",
|
||||
"endpoint_url": "https://crm.example.com/calls",
|
||||
"payload_template": {
|
||||
"run_id": "{{workflow_run_id}}",
|
||||
"outcome": "{{gathered_context.call_disposition}}",
|
||||
},
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue