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:
Mohamed-Mamdouh 2026-05-20 10:07:33 +01:00 committed by GitHub
parent afa78fe859
commit 5f28c1b2a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
93 changed files with 3388 additions and 3414 deletions

View file

@ -1,10 +1,8 @@
"""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`.
Core node specs are generated from the workflow DTO models. Third-party
integration node specs live under `api.services.integrations/<name>/` and
register through the integration registry so they don't need edits here.
"""
from __future__ import annotations
@ -21,8 +19,10 @@ from api.services.workflow.node_specs._base import (
PropertyType,
evaluate_display_options,
)
from api.services.workflow.node_specs.model_spec import build_spec
REGISTRY: dict[str, NodeSpec] = {}
_CORE_SPECS_LOADED = False
def register(spec: NodeSpec) -> NodeSpec:
@ -38,12 +38,23 @@ def register(spec: NodeSpec) -> NodeSpec:
def get_spec(name: str) -> NodeSpec | None:
return REGISTRY.get(name)
_ensure_core_registered()
if name in REGISTRY:
return REGISTRY[name]
from api.services.integrations import get_node_spec
return get_node_spec(name)
def all_specs() -> list[NodeSpec]:
"""All registered specs, sorted by name for stable output."""
return [REGISTRY[name] for name in sorted(REGISTRY)]
_ensure_core_registered()
from api.services.integrations import all_node_specs
specs = {spec.name: spec for spec in REGISTRY.values()}
specs.update({spec.name: spec for spec in all_node_specs()})
return [specs[name] for name in sorted(specs)]
__all__ = [
@ -64,19 +75,15 @@ __all__ = [
]
# 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,
)
def _ensure_core_registered() -> None:
global _CORE_SPECS_LOADED
if _CORE_SPECS_LOADED:
return
# 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
from api.services.workflow.dto import _CORE_NODE_DATA_CLASSES
for model_cls in _CORE_NODE_DATA_CLASSES.values():
if model_cls.__node_spec_metadata__.name in REGISTRY:
continue
register(build_spec(model_cls))
_CORE_SPECS_LOADED = True

View file

@ -1,9 +1,9 @@
"""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)
`NodeSpec` is the serialized contract exposed to the frontend, MCP tools, and
SDKs. Core workflow node specs are generated from the DTO models plus
model-attached metadata; integration packages may generate them the same way or
register a prebuilt spec object.
Every property's `description` is LLM-readable copy — treat it as production
documentation, not internal notes. Spec lint enforces non-empty descriptions
@ -122,6 +122,16 @@ class PropertyOption(BaseModel):
model_config = ConfigDict(extra="forbid")
def to_mcp_dict(self) -> dict[str, Any]:
"""Lean projection for `get_node_type`: the `value` an LLM writes in
code, plus a `description` when one carries real meaning. The UI
`label` is dropped it's the option's display string, never used
when authoring."""
out: dict[str, Any] = {"value": self.value}
if self.description:
out["description"] = self.description
return out
class PropertySpec(BaseModel):
"""Single field on a node.
@ -175,6 +185,43 @@ class PropertySpec(BaseModel):
model_config = ConfigDict(extra="forbid")
def to_mcp_dict(self) -> dict[str, Any]:
"""Lean projection of this property for the `get_node_type` MCP tool.
Keeps only what an LLM needs to author a valid value: name, type,
description, llm_hint, requiredness, default, enum options, nested
row properties, and validation bounds. UI-rendering concerns
(`display_name`, `placeholder`, `display_options`, `editor`,
`extra`) and null/empty fields are omitted they're noise in the
model's context and never appear in authored SDK code.
"""
out: dict[str, Any] = {
"name": self.name,
"type": self.type.value,
"description": self.description,
}
if self.llm_hint:
out["llm_hint"] = self.llm_hint
if self.required:
out["required"] = True
if self.default is not None:
out["default"] = self.default
if self.options:
out["options"] = [opt.to_mcp_dict() for opt in self.options]
if self.properties:
out["properties"] = [prop.to_mcp_dict() for prop in self.properties]
for constraint in (
"min_value",
"max_value",
"min_length",
"max_length",
"pattern",
):
value = getattr(self, constraint)
if value is not None:
out[constraint] = value
return out
PropertySpec.model_rebuild()
@ -222,3 +269,33 @@ class NodeSpec(BaseModel):
graph_constraints: Optional[GraphConstraints] = None
model_config = ConfigDict(extra="forbid")
def to_mcp_dict(self) -> dict[str, Any]:
"""Lean projection of this spec for the `get_node_type` MCP tool.
Drops node-level UI metadata (`display_name`, `category`, `icon`,
`version`) and the per-property rendering concerns trimmed by
`PropertySpec.to_mcp_dict`, leaving just the authoring-relevant
schema the LLM consumes when composing a workflow. The full spec is
still served verbatim to the frontend renderer (REST `node-types`
route) and the SDK codegen / TS validator (`ts_bridge`), which need
the dropped fields.
"""
out: dict[str, Any] = {
"name": self.name,
"description": self.description,
}
if self.llm_hint:
out["llm_hint"] = self.llm_hint
out["properties"] = [prop.to_mcp_dict() for prop in self.properties]
if self.examples:
out["examples"] = [
ex.model_dump(mode="json", exclude_none=True) for ex in self.examples
]
if self.graph_constraints:
constraints = self.graph_constraints.model_dump(
mode="json", exclude_none=True
)
if constraints:
out["graph_constraints"] = constraints
return out

View file

@ -1,168 +0,0 @@
"""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),
)

View file

@ -0,0 +1,44 @@
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."""

View file

@ -1,141 +0,0 @@
"""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,
),
)

View file

@ -1,77 +0,0 @@
"""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,
),
)

View file

@ -0,0 +1,404 @@
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import field as dataclass_field
from enum import Enum
from types import NoneType
from typing import Any, Callable, Literal, get_args, get_origin
from pydantic import BaseModel, Field
from pydantic.fields import FieldInfo, PydanticUndefined
from api.services.workflow.node_specs._base import (
DisplayOptions,
GraphConstraints,
NodeCategory,
NodeExample,
NodeSpec,
PropertyOption,
PropertySpec,
PropertyType,
)
_SPEC_FIELD_META_KEY = "__dograh_spec_field__"
_UNSET = object()
@dataclass(frozen=True)
class NodeSpecMetadata:
name: str
display_name: str
description: str
category: NodeCategory
icon: str
llm_hint: str | None = None
version: str = "1.0.0"
examples: tuple[NodeExample, ...] = ()
graph_constraints: GraphConstraints | None = None
property_order: tuple[str, ...] = ()
field_overrides: dict[str, dict[str, Any]] = dataclass_field(default_factory=dict)
def spec_field(
*field_args: Any,
ui_type: PropertyType | str | None = None,
display_name: str | None = None,
llm_hint: str | None = None,
required: bool | None = None,
spec_default: Any = _UNSET,
placeholder: str | None = None,
display_options: DisplayOptions | None = None,
options: list[PropertyOption] | None = None,
editor: str | None = None,
extra: dict[str, Any] | None = None,
spec_exclude: bool = False,
min_value: float | None = None,
max_value: float | None = None,
min_length: int | None = None,
max_length: int | None = None,
pattern: str | None = None,
**field_kwargs: Any,
):
json_schema_extra = dict(field_kwargs.pop("json_schema_extra", {}) or {})
json_schema_extra[_SPEC_FIELD_META_KEY] = {
"ui_type": ui_type.value if isinstance(ui_type, PropertyType) else ui_type,
"display_name": display_name,
"llm_hint": llm_hint,
"required": required,
"placeholder": placeholder,
"display_options": display_options,
"options": options,
"editor": editor,
"extra": extra or {},
"spec_exclude": spec_exclude,
"min_value": min_value,
"max_value": max_value,
"min_length": min_length,
"max_length": max_length,
"pattern": pattern,
}
if spec_default is not _UNSET:
json_schema_extra[_SPEC_FIELD_META_KEY]["spec_default"] = spec_default
return Field(*field_args, json_schema_extra=json_schema_extra, **field_kwargs)
def node_spec(
*,
name: str,
display_name: str,
description: str,
category: NodeCategory,
icon: str,
llm_hint: str | None = None,
version: str = "1.0.0",
examples: list[NodeExample] | tuple[NodeExample, ...] = (),
graph_constraints: GraphConstraints | None = None,
property_order: list[str] | tuple[str, ...] = (),
field_overrides: dict[str, dict[str, Any]] | None = None,
) -> Callable[[type[BaseModel]], type[BaseModel]]:
metadata = NodeSpecMetadata(
name=name,
display_name=display_name,
description=description,
category=category,
icon=icon,
llm_hint=llm_hint,
version=version,
examples=tuple(examples),
graph_constraints=graph_constraints,
property_order=tuple(property_order),
field_overrides=field_overrides or {},
)
def decorator(model_cls: type[BaseModel]) -> type[BaseModel]:
setattr(model_cls, "__node_spec_metadata__", metadata)
return model_cls
return decorator
def build_spec(model_cls: type[BaseModel]) -> NodeSpec:
metadata: NodeSpecMetadata | None = getattr(
model_cls, "__node_spec_metadata__", None
)
if metadata is None:
raise ValueError(f"{model_cls.__name__} is missing __node_spec_metadata__")
properties: list[PropertySpec] = []
for name, field in model_cls.model_fields.items():
prop = _build_property_spec(model_cls, name, field)
if prop is not None:
properties.append(prop)
properties = _sort_properties(metadata.name, properties, metadata.property_order)
return NodeSpec(
name=metadata.name,
display_name=metadata.display_name,
description=metadata.description,
llm_hint=metadata.llm_hint,
category=metadata.category,
icon=metadata.icon,
version=metadata.version,
properties=properties,
examples=list(metadata.examples),
graph_constraints=metadata.graph_constraints,
)
def _sort_properties(
spec_name: str,
properties: list[PropertySpec],
property_order: tuple[str, ...],
) -> list[PropertySpec]:
if not property_order:
return properties
property_names = {prop.name for prop in properties}
missing = [name for name in property_order if name not in property_names]
if missing:
raise ValueError(
f"{spec_name}: property_order references unknown properties: {missing}"
)
order_map = {name: idx for idx, name in enumerate(property_order)}
ordered = sorted(
enumerate(properties),
key=lambda item: (order_map.get(item[1].name, len(order_map)), item[0]),
)
return [prop for _, prop in ordered]
def _build_property_spec(
owner_cls: type[BaseModel],
field_name: str,
field: FieldInfo,
) -> PropertySpec | None:
meta = _merged_field_meta(owner_cls, field_name, field)
if meta.get("spec_exclude"):
return None
prop_type = _resolve_property_type(field.annotation, meta)
nested_properties = _resolve_nested_properties(field.annotation, prop_type)
options = _resolve_options(field.annotation, meta, prop_type)
min_value, max_value, min_length, max_length, pattern = _resolve_constraints(
field, meta
)
description = meta.get("description") or field.description
if not description:
raise ValueError(f"{owner_cls.__name__}.{field_name} is missing a description")
return PropertySpec(
name=field_name,
type=prop_type,
display_name=meta.get("display_name") or _humanize_identifier(field_name),
description=description,
llm_hint=meta.get("llm_hint"),
default=_resolve_default(field, meta),
required=_resolve_required(field, meta),
placeholder=meta.get("placeholder"),
display_options=meta.get("display_options"),
options=options,
properties=nested_properties,
min_value=min_value,
max_value=max_value,
min_length=min_length,
max_length=max_length,
pattern=pattern,
editor=meta.get("editor"),
extra=meta.get("extra") or {},
)
def _merged_field_meta(
owner_cls: type[BaseModel],
field_name: str,
field: FieldInfo,
) -> dict[str, Any]:
field_meta = {}
if isinstance(field.json_schema_extra, dict):
field_meta = dict(field.json_schema_extra.get(_SPEC_FIELD_META_KEY, {}) or {})
metadata: NodeSpecMetadata | None = getattr(
owner_cls, "__node_spec_metadata__", None
)
override = (
dict(metadata.field_overrides.get(field_name, {}) or {})
if metadata is not None
else {}
)
merged = dict(field_meta)
merged.update(override)
return merged
def _resolve_property_type(annotation: Any, meta: dict[str, Any]) -> PropertyType:
ui_type = meta.get("ui_type")
if ui_type:
return PropertyType(ui_type)
inner = _strip_optional(annotation)
origin = get_origin(inner)
args = get_args(inner)
if origin is list:
item_type = _strip_optional(args[0]) if args else Any
if isinstance(item_type, type) and issubclass(item_type, BaseModel):
return PropertyType.fixed_collection
raise ValueError(
"List-valued fields must declare an explicit ui_type unless they wrap a "
f"BaseModel row type (field annotation: {annotation!r})."
)
if _is_enum(inner) or _is_literal(inner):
return PropertyType.options
if inner in (str,):
return PropertyType.string
if inner in (int, float):
return PropertyType.number
if inner is bool:
return PropertyType.boolean
if inner in (dict, Any) or origin is dict:
return PropertyType.json
raise ValueError(f"Unable to derive PropertyType for annotation {annotation!r}")
def _resolve_nested_properties(
annotation: Any,
prop_type: PropertyType,
) -> list[PropertySpec] | None:
if prop_type != PropertyType.fixed_collection:
return None
inner = _strip_optional(annotation)
args = get_args(inner)
if not args:
raise ValueError(
f"fixed_collection field annotation is missing row type: {annotation!r}"
)
row_type = _strip_optional(args[0])
if not isinstance(row_type, type) or not issubclass(row_type, BaseModel):
raise ValueError(
f"fixed_collection rows must be BaseModel subclasses: {annotation!r}"
)
properties: list[PropertySpec] = []
for field_name, field in row_type.model_fields.items():
prop = _build_property_spec(row_type, field_name, field)
if prop is not None:
properties.append(prop)
return properties
def _resolve_options(
annotation: Any,
meta: dict[str, Any],
prop_type: PropertyType,
) -> list[PropertyOption] | None:
if prop_type not in (PropertyType.options, PropertyType.multi_options):
return meta.get("options")
if meta.get("options"):
return meta["options"]
inner = _strip_optional(annotation)
if prop_type == PropertyType.multi_options:
inner = _strip_optional(get_args(inner)[0])
if _is_enum(inner):
return [
PropertyOption(
value=member.value, label=_humanize_option_label(member.value)
)
for member in inner
]
if _is_literal(inner):
return [
PropertyOption(value=value, label=_humanize_option_label(value))
for value in get_args(inner)
if value is not None
]
return None
def _resolve_constraints(
field: FieldInfo,
meta: dict[str, Any],
) -> tuple[float | None, float | None, int | None, int | None, str | None]:
min_value = meta.get("min_value")
max_value = meta.get("max_value")
min_length = meta.get("min_length")
max_length = meta.get("max_length")
pattern = meta.get("pattern")
for item in field.metadata:
if min_value is None:
if hasattr(item, "ge") and item.ge is not None:
min_value = item.ge
elif hasattr(item, "gt") and item.gt is not None:
min_value = item.gt
if max_value is None:
if hasattr(item, "le") and item.le is not None:
max_value = item.le
elif hasattr(item, "lt") and item.lt is not None:
max_value = item.lt
if (
min_length is None
and hasattr(item, "min_length")
and item.min_length is not None
):
min_length = item.min_length
if (
max_length is None
and hasattr(item, "max_length")
and item.max_length is not None
):
max_length = item.max_length
if pattern is None and hasattr(item, "pattern") and item.pattern is not None:
pattern = item.pattern
return min_value, max_value, min_length, max_length, pattern
def _resolve_default(field: FieldInfo, meta: dict[str, Any]) -> Any:
if "spec_default" in meta:
return meta["spec_default"]
if field.default is not PydanticUndefined:
return field.default
return None
def _resolve_required(field: FieldInfo, meta: dict[str, Any]) -> bool:
if meta.get("required") is not None:
return bool(meta["required"])
return bool(field.is_required())
def _strip_optional(annotation: Any) -> Any:
origin = get_origin(annotation)
if origin is None:
return annotation
args = [arg for arg in get_args(annotation) if arg is not NoneType]
if len(args) == 1 and len(args) != len(get_args(annotation)):
return args[0]
return annotation
def _is_enum(annotation: Any) -> bool:
return isinstance(annotation, type) and issubclass(annotation, Enum)
def _is_literal(annotation: Any) -> bool:
return get_origin(annotation) is Literal
def _humanize_identifier(name: str) -> str:
return name.replace("_", " ").strip().title()
def _humanize_option_label(value: Any) -> str:
if isinstance(value, str):
return value.replace("_", " ").replace("-", " ").strip().title()
return str(value)

View file

@ -1,203 +0,0 @@
"""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,
GraphConstraints,
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,
},
),
],
# QA runs post-call against the saved transcript (run_integrations
# scans by type), never as a graph step. Reject any edge into or out
# of a QA node.
graph_constraints=GraphConstraints(
min_incoming=0, max_incoming=0, min_outgoing=0, max_outgoing=0
),
)

View file

@ -1,250 +0,0 @@
"""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.110.",
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,
},
),
],
# `min_outgoing` is intentionally unset: a startCall is allowed to
# sit on the canvas without an outgoing edge (e.g. a workflow with
# just a greeting). Only constraint: nothing flows INTO the start.
graph_constraints=GraphConstraints(
min_incoming=0,
max_incoming=0,
),
)

View file

@ -1,79 +0,0 @@
"""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 endpoints that launch the workflow."),
llm_hint=(
"Exposes two public HTTP POST endpoints derived from the auto-generated "
"`trigger_path`:\n"
" • Production: `<backend>/api/v1/public/agent/<trigger_path>` — runs "
"the published agent. Use this from production systems.\n"
" • Test: `<backend>/api/v1/public/agent/test/<trigger_path>` — runs "
"the latest draft, useful for verifying changes before publishing. "
"Falls back to the published agent when no draft exists.\n"
"Both require an API key in the `X-API-Key` header.\n"
"Request body fields:\n"
" • `phone_number` (string, required) — destination to dial.\n"
" • `initial_context` (object, optional) — merged into the run's "
"initial context.\n"
" • `telephony_configuration_id` (int, optional) — pick a specific "
"telephony configuration for the call. Must belong to the same "
"organization as the trigger. When omitted, the org's default "
"outbound configuration is used."
),
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. Used in both URLs:\n"
" • Production: `/api/v1/public/agent/<trigger_path>` — "
"executes the published agent.\n"
" • Test: `/api/v1/public/agent/test/<trigger_path>` — "
"executes the latest draft.\n"
"Do not edit manually."
),
),
],
examples=[
NodeExample(
name="default",
data={"name": "Inbound Trigger", "enabled": True},
),
],
graph_constraints=GraphConstraints(
min_incoming=0,
max_incoming=0,
),
)

View file

@ -1,133 +0,0 @@
"""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 (
GraphConstraints,
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}}",
},
),
],
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}}",
},
},
),
],
# Webhooks fire post-call (run_integrations scans nodes by type),
# never as a graph step. Reject any edge into or out of a webhook so
# the editor can't wire one into the conversation flow.
graph_constraints=GraphConstraints(
min_incoming=0, max_incoming=0, min_outgoing=0, max_outgoing=0
),
)