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:
Abhishek 2026-04-21 07:56:16 +05:30 committed by GitHub
parent 0a61ef295f
commit 00a1a22b74
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
162 changed files with 14355 additions and 3554 deletions

View file

@ -1,35 +1,22 @@
"""OpenAI embedding service.
This module provides document processing capabilities using:
- OpenAI's text-embedding-3-small for embeddings (1536 dimensions)
- Docling for document conversion and chunking
- pgvector for vector similarity search
Embeds text and performs vector similarity search via the local database.
Document conversion and chunking now live in the Model Proxy Service (MPS);
this file no longer pulls docling/transformers.
"""
import os
from pathlib import Path
from typing import Any, Dict, List, Optional
from docling.chunking import HybridChunker
from docling.document_converter import DocumentConverter
from docling_core.transforms.chunker.tokenizer.huggingface import HuggingFaceTokenizer
from loguru import logger
from openai import AsyncOpenAI
from transformers import AutoTokenizer
from api.db.db_client import DBClient
from api.db.models import KnowledgeBaseChunkModel
from .base import BaseEmbeddingService
# Model configuration
DEFAULT_MODEL_ID = "text-embedding-3-small"
EMBEDDING_DIMENSION = 1536 # Dimension for text-embedding-3-small
# For chunking, we'll use the same tokenizer as SentenceTransformer
# since OpenAI uses similar tokenization
TOKENIZER_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
class EmbeddingAPIKeyNotConfiguredError(Exception):
"""Raised when OpenAI API key is not configured for embeddings."""
@ -49,24 +36,20 @@ class OpenAIEmbeddingService(BaseEmbeddingService):
db_client: DBClient,
api_key: Optional[str] = None,
model_id: str = DEFAULT_MODEL_ID,
max_tokens: int = 512,
base_url: Optional[str] = None,
):
"""Initialize the OpenAI embedding service.
Args:
db_client: Database client for storing documents and chunks
db_client: Database client for vector similarity search.
api_key: OpenAI API key. If not provided, the client will not be
initialized and operations will fail with a clear error.
model_id: OpenAI embedding model ID (default: text-embedding-3-small)
max_tokens: Maximum number of tokens per chunk (default: 512)
base_url: Optional base URL for the API (e.g. for OpenRouter)
initialized and operations will fail with a clear error.
model_id: OpenAI embedding model ID (default: text-embedding-3-small).
base_url: Optional base URL for the API (e.g. for OpenRouter).
"""
self.db = db_client
self.model_id = model_id
self.max_tokens = max_tokens
# Only initialize OpenAI client if API key is provided
self._api_key_configured = bool(api_key)
if self._api_key_configured:
client_kwargs = {"api_key": api_key}
@ -81,35 +64,6 @@ class OpenAIEmbeddingService(BaseEmbeddingService):
"Operations will fail until API key is configured in Model Configurations."
)
# Initialize tokenizer for chunking
# We use a HuggingFace tokenizer for consistent chunking
logger.info(
f"Loading tokenizer for chunking: {TOKENIZER_MODEL} with max_tokens={max_tokens}"
)
try:
self.tokenizer = HuggingFaceTokenizer(
tokenizer=AutoTokenizer.from_pretrained(
TOKENIZER_MODEL,
local_files_only=True,
),
max_tokens=max_tokens,
)
logger.info("Loaded tokenizer from cache")
except Exception as e:
logger.warning(f"Tokenizer not in cache, downloading: {e}")
self.tokenizer = HuggingFaceTokenizer(
tokenizer=AutoTokenizer.from_pretrained(TOKENIZER_MODEL),
max_tokens=max_tokens,
)
logger.info("Tokenizer downloaded and cached")
# Initialize chunker
logger.info(f"Initializing HybridChunker with max_tokens={max_tokens}")
self.chunker = HybridChunker(tokenizer=self.tokenizer)
# Initialize document converter
self.converter = DocumentConverter()
def get_model_id(self) -> str:
"""Return the model identifier."""
return self.model_id
@ -126,28 +80,17 @@ class OpenAIEmbeddingService(BaseEmbeddingService):
async def embed_texts(self, texts: List[str]) -> List[List[float]]:
"""Embed a batch of texts using OpenAI API.
Args:
texts: List of text strings to embed
Returns:
List of embedding vectors (each vector is a list of floats)
Raises:
EmbeddingAPIKeyNotConfiguredError: If API key is not configured
EmbeddingAPIKeyNotConfiguredError: If API key is not configured.
"""
self._ensure_api_key_configured()
try:
# OpenAI API call
response = await self.client.embeddings.create(
input=texts,
model=self.model_id,
)
# Extract embeddings from response
embeddings = [item.embedding for item in response.data]
return embeddings
return [item.embedding for item in response.data]
except Exception as e:
logger.error(f"Error generating OpenAI embeddings: {e}")
raise
@ -155,14 +98,8 @@ class OpenAIEmbeddingService(BaseEmbeddingService):
async def embed_query(self, query: str) -> List[float]:
"""Embed a single query text using OpenAI API.
Args:
query: Query text to embed
Returns:
Embedding vector as list of floats
Raises:
EmbeddingAPIKeyNotConfiguredError: If API key is not configured
EmbeddingAPIKeyNotConfiguredError: If API key is not configured.
"""
self._ensure_api_key_configured()
embeddings = await self.embed_texts([query])
@ -177,201 +114,17 @@ class OpenAIEmbeddingService(BaseEmbeddingService):
) -> List[Dict[str, Any]]:
"""Search for similar chunks using vector similarity.
Args:
query: Search query text
organization_id: Organization ID for scoping
limit: Maximum number of results to return
document_uuids: Optional list of document UUIDs to filter by
Returns:
List of dictionaries with chunk data and similarity scores
Raises:
EmbeddingAPIKeyNotConfiguredError: If API key is not configured
EmbeddingAPIKeyNotConfiguredError: If API key is not configured.
"""
self._ensure_api_key_configured()
# Generate query embedding
query_embedding = await self.embed_query(query)
# Perform vector similarity search
results = await self.db.search_similar_chunks(
return await self.db.search_similar_chunks(
query_embedding=query_embedding,
organization_id=organization_id,
limit=limit,
document_uuids=document_uuids,
embedding_model=self.model_id,
)
return results
async def process_document(
self,
file_path: str,
organization_id: int,
created_by: int,
custom_metadata: dict = None,
):
"""Process a document: convert, chunk, embed, and store in database.
Args:
file_path: Path to the document file
organization_id: Organization ID for scoping
created_by: User ID who uploaded the document
custom_metadata: Optional custom metadata dictionary
Returns:
The created document record
"""
try:
# Extract file metadata
filename = Path(file_path).name
file_hash = self.db.compute_file_hash(file_path)
file_size = os.path.getsize(file_path)
mime_type = self.db.get_mime_type(file_path)
# Check if document already exists
existing_doc = await self.db.get_document_by_hash(
file_hash, organization_id
)
if existing_doc:
logger.info(f"Document already exists: {filename} (hash: {file_hash})")
return existing_doc
# Create document record
doc_record = await self.db.create_document(
organization_id=organization_id,
created_by=created_by,
filename=filename,
file_size_bytes=file_size,
file_hash=file_hash,
mime_type=mime_type,
custom_metadata=custom_metadata or {},
)
logger.info(f"Processing document with OpenAI embeddings: {filename}")
# Update status to processing
await self.db.update_document_status(doc_record.id, "processing")
# Step 1: Convert document using docling
logger.info("Converting document with docling...")
conversion_result = self.converter.convert(file_path)
doc = conversion_result.document
# Store docling metadata
docling_metadata = {
"num_pages": len(doc.pages) if hasattr(doc, "pages") else None,
"document_type": type(doc).__name__,
}
# Step 2: Chunk the document
logger.info(f"Chunking document with max_tokens={self.max_tokens}...")
chunks = list(self.chunker.chunk(dl_doc=doc))
total_chunks = len(chunks)
logger.info(f"Generated {total_chunks} chunks")
# Step 3: Process each chunk
chunk_texts = []
chunk_records = []
token_counts = []
for i, chunk in enumerate(chunks):
# Get chunk text
chunk_text = chunk.text
# Get contextualized text
contextualized_text = self.chunker.contextualize(chunk=chunk)
# Calculate token count
text_to_tokenize = (
contextualized_text if contextualized_text else chunk_text
)
token_count = len(
self.tokenizer.tokenizer.encode(
text_to_tokenize, add_special_tokens=False
)
)
token_counts.append(token_count)
# Prepare chunk metadata
chunk_metadata = {}
if hasattr(chunk, "meta") and chunk.meta:
chunk_metadata = {
"doc_items": (
[str(item) for item in chunk.meta.doc_items]
if hasattr(chunk.meta, "doc_items")
else []
),
"headings": (
chunk.meta.headings
if hasattr(chunk.meta, "headings")
else []
),
}
# Create chunk record (without embedding yet)
chunk_record = KnowledgeBaseChunkModel(
document_id=doc_record.id,
organization_id=organization_id,
chunk_text=chunk_text,
contextualized_text=contextualized_text,
chunk_index=i,
chunk_metadata=chunk_metadata,
embedding_model=self.model_id,
embedding_dimension=EMBEDDING_DIMENSION,
token_count=token_count,
)
chunk_records.append(chunk_record)
chunk_texts.append(text_to_tokenize)
# Log chunk statistics
if token_counts:
avg_tokens = sum(token_counts) / len(token_counts)
min_tokens = min(token_counts)
max_tokens = max(token_counts)
logger.info("Chunk token statistics:")
logger.info(f" - Average: {avg_tokens:.1f} tokens")
logger.info(f" - Min: {min_tokens} tokens")
logger.info(f" - Max: {max_tokens} tokens")
# Step 4: Generate embeddings using OpenAI API
logger.info(f"Generating embeddings using OpenAI ({self.model_id})...")
embeddings = await self.embed_texts(chunk_texts)
# Step 5: Attach embeddings to chunk records
for chunk_record, embedding in zip(chunk_records, embeddings):
chunk_record.embedding = embedding
# Step 6: Save all chunks in batch
logger.info("Storing chunks in database...")
await self.db.create_chunks_batch(chunk_records)
# Update document status to completed
await self.db.update_document_status(
doc_record.id,
"completed",
total_chunks=total_chunks,
docling_metadata=docling_metadata,
)
logger.info(f"Successfully processed document: {filename}")
logger.info(f" - Total chunks: {total_chunks}")
logger.info(f" - Embedding model: {self.model_id}")
logger.info(f" - Document ID: {doc_record.id}")
logger.info(f" - Document UUID: {doc_record.document_uuid}")
return doc_record
except Exception as e:
logger.error(f"Error processing document with OpenAI: {e}")
# Update document status to failed if it exists
if "doc_record" in locals():
await self.db.update_document_status(
doc_record.id, "failed", error_message=str(e)
)
raise

View file

@ -487,6 +487,71 @@ class MPSServiceKeyClient:
response=response,
)
async def process_document(
self,
file_path: str,
filename: str,
content_type: str,
retrieval_mode: str = "chunked",
max_tokens: int = 128,
chunk_overlap_tokens: int = 0,
merge_peers: bool = True,
tokenizer_model: Optional[str] = None,
correlation_id: Optional[str] = None,
organization_id: Optional[int] = None,
created_by: Optional[str] = None,
) -> dict:
"""Convert + chunk a document via MPS /document/process.
Returns a dict matching DocumentProcessResponse in MPS:
{
"mode": "chunked" | "full_document",
"docling_metadata": {...},
"full_text": str | None, # populated only in full_document mode
"chunks": [...], # populated only in chunked mode
}
Timeout is 300s to match the ALB idle_timeout configured in
infrastructure/mps/main.tf. Raises on non-2xx responses.
"""
data = {
"retrieval_mode": retrieval_mode,
"max_tokens": str(max_tokens),
"chunk_overlap_tokens": str(chunk_overlap_tokens),
"merge_peers": str(merge_peers).lower(),
}
if tokenizer_model is not None:
data["tokenizer_model"] = tokenizer_model
if correlation_id:
data["correlation_id"] = correlation_id
headers = self._get_headers(organization_id, created_by)
# Remove JSON content-type so httpx sets the correct multipart boundary.
headers.pop("Content-Type", None)
async with httpx.AsyncClient(timeout=httpx.Timeout(300.0)) as client:
with open(file_path, "rb") as fh:
files = {"file": (filename, fh.read(), content_type)}
response = await client.post(
f"{self.base_url}/api/v1/document/process",
files=files,
data=data,
headers=headers,
)
if response.status_code == 200:
return response.json()
logger.error(
f"Failed to process document: {response.status_code} - {response.text}"
)
raise httpx.HTTPStatusError(
f"Failed to process document: {response.text}",
request=response.request,
response=response,
)
async def call_workflow_api(
self,
call_type: str,

View file

@ -97,6 +97,7 @@ async def play_audio(
queue_frame: Callable[[Frame], Awaitable[None]],
transcript: Optional[str] = None,
append_to_context: bool = False,
persist_to_logs: bool = False,
) -> None:
"""Play raw PCM-16 audio once.
@ -115,6 +116,8 @@ async def play_audio(
transcript: Optional transcript of the recording.
append_to_context: Whether the transcript should be appended to
the LLM assistant context. Defaults to False.
persist_to_logs: Whether the transcript should be written to the
app-level logs buffer by observers. Defaults to False.
"""
context_id = str(uuid.uuid4())
await queue_frame(TTSStartedFrame(context_id=context_id))
@ -123,6 +126,7 @@ async def play_audio(
text=transcript, aggregated_by="recording", context_id=context_id
)
tts_text.append_to_context = append_to_context
tts_text.persist_to_logs = persist_to_logs
await queue_frame(tts_text)
await queue_frame(
TTSAudioRawFrame(

View file

@ -42,6 +42,7 @@ from pipecat.frames.frames import (
MetricsFrame,
StopFrame,
TranscriptionFrame,
TTSSpeakFrame,
TTSTextFrame,
UserMuteStartedFrame,
UserMuteStoppedFrame,
@ -230,8 +231,22 @@ class RealtimeFeedbackObserver(BaseObserver):
},
}
)
# Handle engine-queued speech (transition/tool messages) marked for
# log persistence. The downstream TTSTextFrame(s) from the TTS service
# still stream to WS as normal; we persist the full utterance once here
# to avoid word-level log entries from word-timestamp providers.
elif isinstance(frame, TTSSpeakFrame):
if getattr(frame, "persist_to_logs", False):
await self._append_to_buffer(
{
"type": RealtimeFeedbackType.BOT_TEXT.value,
"payload": {"text": frame.text},
}
)
# Handle bot TTS text - respect pts timing, WebSocket only
# Complete turn text is persisted via register_turn_handlers
# Complete turn text is persisted via register_turn_handlers,
# except for frames explicitly flagged persist_to_logs (e.g. recording
# transcripts from play_audio) which bypass the aggregator path.
elif isinstance(frame, TTSTextFrame):
message = {
"type": RealtimeFeedbackType.BOT_TEXT.value,
@ -249,6 +264,9 @@ class RealtimeFeedbackObserver(BaseObserver):
await self._ensure_clock_task()
await self._clock_queue.put((frame.pts, frame.id, message))
elif getattr(frame, "persist_to_logs", False):
# No pts + explicit persistence request (recording transcript).
await self._send_message(message)
else:
# No pts, send immediately
await self._send_ws(message)

View file

@ -94,6 +94,14 @@ class _OrgRoutingExporter(SpanExporter):
org_buckets = {}
for span in spans:
# Drop fastmcp's built-in auto-instrumentation spans
# (`tools/call <name>`, etc.) — our `@traced_tool` decorator
# in `api/mcp_server/tracing.py` produces the spans we want. Keeping
# both would just double every trace.
scope = getattr(span, "instrumentation_scope", None)
if scope is not None and scope.name == "fastmcp":
continue
org_id = span.attributes.get("dograh.org_id") if span.attributes else None
if org_id and str(org_id) in self._org_exporters:
org_buckets.setdefault(str(org_id), []).append(span)

View file

@ -1,5 +1,5 @@
from enum import Enum
from typing import List, Optional
from typing import Annotated, List, Literal, Optional, Union
from pydantic import BaseModel, Field, ValidationError, model_validator
@ -42,17 +42,48 @@ class RetryConfigDTO(BaseModel):
retry_delay_seconds: int = 5
class NodeDataDTO(BaseModel):
# ─────────────────────────────────────────────────────────────────────────
# Per-type node data classes.
#
# Shared fields are factored out as Pydantic mixins; per-type classes
# inherit only the mixins they need so mistyped fields raise at validation
# time and downstream consumers get accurate types. `is_start` / `is_end`
# live on every variant so the WorkflowGraph can identify boundary nodes
# without dispatching on type.
# ─────────────────────────────────────────────────────────────────────────
class _NodeDataBase(BaseModel):
name: str = Field(..., min_length=1)
prompt: Optional[str] = Field(default=None)
is_static: bool = False
is_start: bool = False
is_end: bool = False
class _PromptedNodeDataMixin(BaseModel):
prompt: Optional[str] = Field(default=None)
is_static: bool = False
allow_interrupt: bool = False
add_global_prompt: bool = True
class _ExtractionNodeDataMixin(BaseModel):
extraction_enabled: bool = False
extraction_prompt: Optional[str] = None
extraction_variables: Optional[list[ExtractionVariableDTO]] = None
add_global_prompt: bool = True
class _ToolDocumentRefsMixin(BaseModel):
tool_uuids: Optional[List[str]] = None
document_uuids: Optional[List[str]] = None
class StartCallNodeData(
_NodeDataBase,
_PromptedNodeDataMixin,
_ExtractionNodeDataMixin,
_ToolDocumentRefsMixin,
):
is_start: bool = True
greeting: Optional[str] = None
greeting_type: Optional[str] = None # 'text' or 'audio'
greeting_recording_id: Optional[str] = None
@ -61,14 +92,38 @@ class NodeDataDTO(BaseModel):
detect_voicemail: bool = False
delayed_start: bool = False
delayed_start_duration: Optional[float] = None
# Pre-call fetch (start node only)
pre_call_fetch_enabled: bool = False
pre_call_fetch_url: Optional[str] = None
pre_call_fetch_credential_uuid: Optional[str] = None
tool_uuids: Optional[List[str]] = None
document_uuids: Optional[List[str]] = None
class AgentNodeData(
_NodeDataBase,
_PromptedNodeDataMixin,
_ExtractionNodeDataMixin,
_ToolDocumentRefsMixin,
):
pass
class EndCallNodeData(
_NodeDataBase,
_PromptedNodeDataMixin,
_ExtractionNodeDataMixin,
):
is_end: bool = True
class GlobalNodeData(_NodeDataBase, _PromptedNodeDataMixin):
pass
class TriggerNodeData(_NodeDataBase):
trigger_path: Optional[str] = None
# Webhook node specific fields
enabled: bool = True
class WebhookNodeData(_NodeDataBase):
enabled: bool = True
http_method: Optional[str] = None
endpoint_url: Optional[str] = None
@ -76,30 +131,129 @@ class NodeDataDTO(BaseModel):
custom_headers: Optional[list[CustomHeaderDTO]] = None
payload_template: Optional[dict] = None
retry_config: Optional[RetryConfigDTO] = None
# QA node specific fields
class QANodeData(_NodeDataBase):
qa_enabled: bool = True
qa_system_prompt: Optional[str] = None
qa_use_workflow_llm: bool = True
qa_provider: Optional[str] = None
qa_model: Optional[str] = None
qa_api_key: Optional[str] = None
qa_endpoint: Optional[str] = None
qa_system_prompt: Optional[str] = None
qa_min_call_duration: int = 15
qa_voicemail_calls: bool = False
qa_sample_rate: int = 100
class RFNodeDTO(BaseModel):
# Union of every per-type data class — useful as a type annotation on
# consumers that handle any node data without dispatching on type. Cannot
# be called as a constructor; use the per-type class directly.
NodeDataDTO = Union[
StartCallNodeData,
AgentNodeData,
EndCallNodeData,
GlobalNodeData,
TriggerNodeData,
WebhookNodeData,
QANodeData,
]
# ─────────────────────────────────────────────────────────────────────────
# Per-type RF nodes.
#
# RFNodeDTO is a discriminated Union over `type`. Pydantic dispatches to
# the right variant when validating wire JSON. Direct instantiation must
# use the concrete per-type class (StartCallRFNode, AgentRFNode, ...).
# ─────────────────────────────────────────────────────────────────────────
class _RFNodeBase(BaseModel):
id: str
type: NodeType = Field(default=NodeType.agentNode)
position: Position
data: NodeDataDTO
def _require_prompt(data, type_label: str) -> None:
prompt = getattr(data, "prompt", None)
if not prompt or len(prompt.strip()) == 0:
raise ValueError(f"Prompt is required for {type_label} nodes")
class StartCallRFNode(_RFNodeBase):
type: Literal["startCall"] = "startCall"
data: StartCallNodeData
@model_validator(mode="after")
def _validate_prompt_required(self):
"""Require prompt for all node types except trigger, webhook, and qa."""
if self.type not in (NodeType.trigger, NodeType.webhook, NodeType.qa):
if not self.data.prompt or len(self.data.prompt.strip()) == 0:
raise ValueError("Prompt is required for non-trigger nodes")
def _validate(self):
_require_prompt(self.data, "start")
return self
class AgentRFNode(_RFNodeBase):
type: Literal["agentNode"] = "agentNode"
data: AgentNodeData
@model_validator(mode="after")
def _validate(self):
_require_prompt(self.data, "agent")
return self
class EndCallRFNode(_RFNodeBase):
type: Literal["endCall"] = "endCall"
data: EndCallNodeData
@model_validator(mode="after")
def _validate(self):
_require_prompt(self.data, "end")
return self
class GlobalRFNode(_RFNodeBase):
type: Literal["globalNode"] = "globalNode"
data: GlobalNodeData
@model_validator(mode="after")
def _validate(self):
_require_prompt(self.data, "global")
return self
class TriggerRFNode(_RFNodeBase):
type: Literal["trigger"] = "trigger"
data: TriggerNodeData
class WebhookRFNode(_RFNodeBase):
type: Literal["webhook"] = "webhook"
data: WebhookNodeData
class QARFNode(_RFNodeBase):
type: Literal["qa"] = "qa"
data: QANodeData
RFNodeDTO = Annotated[
Union[
StartCallRFNode,
AgentRFNode,
EndCallRFNode,
GlobalRFNode,
TriggerRFNode,
WebhookRFNode,
QARFNode,
],
Field(discriminator="type"),
]
# ─────────────────────────────────────────────────────────────────────────
# Edges
# ─────────────────────────────────────────────────────────────────────────
class EdgeDataDTO(BaseModel):
label: str = Field(..., min_length=1)
condition: str = Field(..., min_length=1)
@ -144,3 +298,60 @@ class ReactFlowDTO(BaseModel):
)
return self
# Node type → per-type data class. Keeps sanitize_workflow_definition in
# step with RFNodeDTO's discriminated union.
_NODE_DATA_CLASSES: dict[str, type[BaseModel]] = {
NodeType.startNode.value: StartCallNodeData,
NodeType.agentNode.value: AgentNodeData,
NodeType.endNode.value: EndCallNodeData,
NodeType.globalNode.value: GlobalNodeData,
NodeType.trigger.value: TriggerNodeData,
NodeType.webhook.value: WebhookNodeData,
NodeType.qa.value: QANodeData,
}
def sanitize_workflow_definition(definition: dict | None) -> dict | None:
"""Strip unknown fields from each node.data and edge.data so UI-only
runtime state (`invalid`, `validationMessage`, etc.) doesn't leak into
persisted workflow JSON.
Only `.data` is filtered top-level keys on nodes/edges/definition
(viewport, ReactFlow-computed width/height, etc.) are preserved as-is.
This is a stripper, not a validator: it doesn't enforce required fields
or run model_validators, so partial drafts save cleanly.
"""
if not definition:
return definition
out = dict(definition)
raw_nodes = out.get("nodes")
if isinstance(raw_nodes, list):
out["nodes"] = [_sanitize_node(n) for n in raw_nodes]
raw_edges = out.get("edges")
if isinstance(raw_edges, list):
out["edges"] = [_sanitize_edge(e) for e in raw_edges]
return out
def _sanitize_node(node):
if not isinstance(node, dict):
return node
data_cls = _NODE_DATA_CLASSES.get(node.get("type"))
raw_data = node.get("data")
if not data_cls or not isinstance(raw_data, dict):
return node
allowed = data_cls.model_fields.keys()
return {**node, "data": {k: v for k, v in raw_data.items() if k in allowed}}
def _sanitize_edge(edge):
if not isinstance(edge, dict):
return edge
raw_data = edge.get("data")
if not isinstance(raw_data, dict):
return edge
allowed = EdgeDataDTO.model_fields.keys()
return {**edge, "data": {k: v for k, v in raw_data.items() if k in allowed}}

View file

@ -0,0 +1,105 @@
"""Position reconciliation for LLM-edited workflows.
`save_workflow` re-parses LLM-authored TypeScript into workflow JSON,
but the parser deliberately ignores positions (LLMs place nodes
poorly, and the authoring surface stays tighter without coordinates).
This module fills them back in by matching the newly-parsed nodes
against the previously-stored workflow:
1. Named match: (type, data.name) most reliable
2. Unnamed match: (type, nth-occurrence-in-order) best effort
3. New nodes: placed adjacent to their first incoming neighbor
(src.x + 400, src.y + 200), or (0, 0) if orphan
The UI has a proper dagre-based re-layout button
(`ui/src/app/workflow/[workflowId]/utils/layoutNodes.ts`) users can
invoke when they want a clean pass. This module only aims to avoid
all-nodes-at-origin after a save.
"""
from __future__ import annotations
from typing import Any
_DEFAULT_POSITION: dict[str, float] = {"x": 0.0, "y": 0.0}
# Horizontal / vertical offset for newly-introduced nodes relative to
# their first incoming neighbor. Chosen to roughly match the UI layout's
# node spacing without overlapping the neighbor's card.
_NEW_NODE_DX: float = 400.0
_NEW_NODE_DY: float = 200.0
def reconcile_positions(
new_wf: dict[str, Any],
previous_wf: dict[str, Any] | None,
) -> dict[str, Any]:
"""Return `new_wf` with positions filled from `previous_wf` where
node identity matches, and approximate positions for genuinely new
nodes. Mutates and returns the same dict (callers typically want
the mutation)."""
if not previous_wf:
_place_new_nodes(new_wf)
return new_wf
prev_nodes = previous_wf.get("nodes") or []
named_positions: dict[tuple[str, str], dict[str, float]] = {}
unnamed_positions: dict[str, list[dict[str, float]]] = {}
for n in prev_nodes:
t = n.get("type") or ""
name = ((n.get("data") or {}).get("name") or "").strip()
pos = n.get("position") or dict(_DEFAULT_POSITION)
if name:
named_positions[(t, name)] = pos
else:
unnamed_positions.setdefault(t, []).append(pos)
unnamed_cursor: dict[str, int] = {}
for node in new_wf.get("nodes") or []:
t = node.get("type") or ""
name = ((node.get("data") or {}).get("name") or "").strip()
pos: dict[str, float] | None = None
if name:
pos = named_positions.get((t, name))
if pos is None:
idx = unnamed_cursor.get(t, 0)
positions = unnamed_positions.get(t, [])
if idx < len(positions):
pos = positions[idx]
unnamed_cursor[t] = idx + 1
if pos is not None:
node["position"] = dict(pos)
_place_new_nodes(new_wf)
return new_wf
def _place_new_nodes(wf: dict[str, Any]) -> None:
"""For nodes still at (0, 0) — i.e. unmatched by any previous
node pick a position adjacent to the first incoming neighbor.
Runs after named/unnamed matching so only genuinely-new nodes are
affected."""
nodes = wf.get("nodes") or []
if not nodes:
return
id_to_node = {n["id"]: n for n in nodes}
edges = wf.get("edges") or []
for node in nodes:
pos = node.get("position") or {}
if pos.get("x") or pos.get("y"):
continue # already has a non-origin position
src_id = next(
(e["source"] for e in edges if e.get("target") == node["id"]),
None,
)
if src_id and src_id in id_to_node:
src_pos = id_to_node[src_id].get("position") or dict(_DEFAULT_POSITION)
node["position"] = {
"x": float(src_pos.get("x", 0.0)) + _NEW_NODE_DX,
"y": float(src_pos.get("y", 0.0)) + _NEW_NODE_DY,
}
# Leaves truly orphan new nodes at (0, 0). The UI's re-layout
# pass will pull them into the graph on next edit.

View 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

View 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()

View 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")

View 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),
)

View 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
}
]
}

View 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,
),
)

View 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,
),
)

View 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,
},
),
],
)

View 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.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,
},
),
],
graph_constraints=GraphConstraints(
min_incoming=0,
max_incoming=0,
min_outgoing=1,
),
)

View 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,
),
)

View 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}}",
},
},
),
],
)

View file

@ -243,6 +243,7 @@ class PipecatEngine:
else 16000,
queue_frame=self._transport_output.queue_frame,
transcript=result.transcript,
persist_to_logs=True,
)
else:
logger.warning(
@ -252,7 +253,11 @@ class PipecatEngine:
logger.info(f"Playing transition speech: {transition_speech}")
self._queued_speech_mute_state = "waiting"
await self.task.queue_frame(
TTSSpeakFrame(transition_speech, append_to_context=False)
TTSSpeakFrame(
transition_speech,
append_to_context=False,
persist_to_logs=True,
)
)
# Set context for the new node, so that when the function call result

View file

@ -100,6 +100,7 @@ class CustomToolManager:
else 16000,
queue_frame=self._engine._transport_output.queue_frame,
transcript=result.transcript,
persist_to_logs=True,
)
return True
else:
@ -110,7 +111,11 @@ class CustomToolManager:
custom_message = config.get("customMessage", "")
if custom_message:
await self._engine.task.queue_frame(
TTSSpeakFrame(custom_message, append_to_context=append_to_context)
TTSSpeakFrame(
custom_message,
append_to_context=append_to_context,
persist_to_logs=True,
)
)
return True
@ -311,6 +316,7 @@ class CustomToolManager:
else 16000,
queue_frame=self._engine._transport_output.queue_frame,
transcript=result.transcript,
persist_to_logs=True,
)
elif custom_message:
logger.info(
@ -318,7 +324,11 @@ class CustomToolManager:
)
self._engine._queued_speech_mute_state = "waiting"
await self._engine.task.queue_frame(
TTSSpeakFrame(custom_message, append_to_context=False)
TTSSpeakFrame(
custom_message,
append_to_context=False,
persist_to_logs=True,
)
)
result = await execute_http_tool(

View file

@ -8,6 +8,7 @@ from loguru import logger
from api.db.models import WorkflowRunModel
from api.services.gen_ai.json_parser import parse_llm_json
from api.services.pipecat.service_factory import create_llm_service_from_provider
from api.services.workflow.dto import QANodeData
from api.services.workflow.qa.conversation import (
build_conversation_structure,
format_transcript,
@ -77,7 +78,7 @@ async def _generate_conversation_summary(
async def run_per_node_qa_analysis(
qa_node_data: dict[str, Any],
qa_data: QANodeData,
workflow_run: WorkflowRunModel,
workflow_run_id: int,
workflow_definition: dict,
@ -106,18 +107,16 @@ async def run_per_node_qa_analysis(
logger.info(
f"Events lack node_id for run {workflow_run_id}, falling back to whole-call QA"
)
return await _run_whole_call_qa_analysis(
qa_node_data, workflow_run, workflow_run_id
)
return await _run_whole_call_qa_analysis(qa_data, workflow_run, workflow_run_id)
system_prompt = qa_node_data.get("qa_system_prompt", "")
system_prompt = qa_data.qa_system_prompt or ""
if not system_prompt:
logger.warning("No system prompt defined for QA Node")
return {"error": "no_system_prompt", "node_results": {}}
# Resolve LLM config
provider, model, api_key, service_kwargs = await resolve_llm_config(
qa_node_data, workflow_run
qa_data, workflow_run
)
if not api_key:
logger.warning(
@ -127,7 +126,7 @@ async def run_per_node_qa_analysis(
# Ensure node summaries
node_summaries = await ensure_node_summaries(
workflow_definition, definition_id, workflow_run, qa_node_data
workflow_definition, definition_id, workflow_run, qa_data
)
# Set up Langfuse tracing
@ -228,7 +227,7 @@ async def run_per_node_qa_analysis(
async def _run_whole_call_qa_analysis(
qa_node_data: dict[str, Any],
qa_data: QANodeData,
workflow_run: WorkflowRunModel,
workflow_run_id: int,
) -> dict[str, Any]:
@ -254,13 +253,13 @@ async def _run_whole_call_qa_analysis(
metrics = compute_call_metrics(rtf_events, call_duration)
# Resolve LLM config
system_prompt = qa_node_data.get("qa_system_prompt", "")
system_prompt = qa_data.qa_system_prompt or ""
if not system_prompt:
logger.warning("No system prompt defined for QA Node")
return {"error": "no_system_prompt", "node_results": {}}
provider, model, api_key, service_kwargs = await resolve_llm_config(
qa_node_data, workflow_run
qa_data, workflow_run
)
if not api_key:

View file

@ -4,10 +4,11 @@ import random
from api.db import db_client
from api.db.models import WorkflowRunModel
from api.services.workflow.dto import QANodeData
async def resolve_llm_config(
qa_node_data: dict, workflow_run: WorkflowRunModel
qa_data: QANodeData, workflow_run: WorkflowRunModel
) -> tuple[str, str, str, dict]:
"""Resolve the LLM provider, model, API key, and extra kwargs for QA analysis.
@ -18,24 +19,23 @@ async def resolve_llm_config(
(provider, model, api_key, service_kwargs) tuple service_kwargs can be
passed directly to create_llm_service_from_provider as keyword arguments.
"""
if not qa_node_data.get("qa_use_workflow_llm", True):
provider = qa_node_data.get("qa_provider", "openai")
if not qa_data.qa_use_workflow_llm:
provider = qa_data.qa_provider or "openai"
kwargs = {}
if provider == "azure":
kwargs["endpoint"] = qa_node_data.get("qa_endpoint", "")
kwargs["endpoint"] = qa_data.qa_endpoint or ""
return (
provider,
qa_node_data.get("qa_model"),
qa_node_data.get("qa_api_key"),
qa_data.qa_model,
qa_data.qa_api_key,
kwargs,
)
# Fall back to user's configured LLM
provider, model, api_key, kwargs = await resolve_user_llm_config(workflow_run)
qa_model = qa_node_data.get("qa_model", "default")
if qa_model and qa_model != "default":
model = qa_model
if qa_data.qa_model and qa_data.qa_model != "default":
model = qa_data.qa_model
return provider, model, api_key, kwargs

View file

@ -7,7 +7,7 @@ from loguru import logger
from api.db import db_client
from api.db.models import WorkflowRunModel
from api.services.pipecat.service_factory import create_llm_service_from_provider
from api.services.workflow.dto import NodeType
from api.services.workflow.dto import NodeType, QANodeData
from api.services.workflow.qa.llm_config import resolve_llm_config
from api.services.workflow.qa.tracing import create_node_summary_trace
from pipecat.processors.aggregators.llm_context import LLMContext
@ -48,7 +48,7 @@ async def ensure_node_summaries(
workflow_definition: dict,
definition_id: int | None,
workflow_run: WorkflowRunModel,
qa_node_data: dict,
qa_data: QANodeData,
) -> dict[str, Any]:
"""Ensure every agentNode/startCall node has a summary in the definition.
@ -69,7 +69,7 @@ async def ensure_node_summaries(
return existing_summaries
provider, model, api_key, service_kwargs = await resolve_llm_config(
qa_node_data, workflow_run
qa_data, workflow_run
)
if not api_key:
logger.warning("No API key for node summary generation, skipping")

View file

@ -242,7 +242,6 @@ async def _perform_retrieval(
embedding_service = OpenAIEmbeddingService(
db_client=db_client,
max_tokens=128,
api_key=embeddings_api_key,
model_id=embeddings_model or "text-embedding-3-small",
base_url=embeddings_base_url,

View file

@ -2,7 +2,7 @@ import re
from collections import Counter
from typing import Dict, List, Set
from api.services.workflow.dto import EdgeDataDTO, NodeDataDTO, NodeType, ReactFlowDTO
from api.services.workflow.dto import EdgeDataDTO, NodeType, ReactFlowDTO
from api.services.workflow.errors import ItemKind, WorkflowError
# Regex for matching {{ variable }} template placeholders.
@ -61,32 +61,38 @@ class Edge:
class Node:
def __init__(self, id: str, node_type: NodeType, data: NodeDataDTO):
def __init__(self, id: str, node_type: NodeType, data):
self.id, self.node_type, self.data = id, node_type, data
self.out: Dict[str, "Node"] = {} # forward nodes
self.out_edges: List[Edge] = [] # forward edges with properties
# name/is_start/is_end live on every per-type data class (base).
self.name = data.name
self.prompt = data.prompt
self.is_static = data.is_static
self.is_start = data.is_start
self.is_end = data.is_end
self.allow_interrupt = data.allow_interrupt
self.extraction_enabled = data.extraction_enabled
self.extraction_prompt = data.extraction_prompt
self.extraction_variables = data.extraction_variables
self.add_global_prompt = data.add_global_prompt
self.greeting = data.greeting
self.greeting_type = data.greeting_type
self.greeting_recording_id = data.greeting_recording_id
self.detect_voicemail = data.detect_voicemail
self.delayed_start = data.delayed_start
self.delayed_start_duration = data.delayed_start_duration
self.tool_uuids = data.tool_uuids
self.document_uuids = data.document_uuids
self.pre_call_fetch_enabled = data.pre_call_fetch_enabled
self.pre_call_fetch_url = data.pre_call_fetch_url
self.pre_call_fetch_credential_uuid = data.pre_call_fetch_credential_uuid
# Type-specific fields — read with getattr so this works for every
# node variant in the discriminated union.
self.prompt = getattr(data, "prompt", None)
self.is_static = getattr(data, "is_static", False)
self.allow_interrupt = getattr(data, "allow_interrupt", False)
self.extraction_enabled = getattr(data, "extraction_enabled", False)
self.extraction_prompt = getattr(data, "extraction_prompt", None)
self.extraction_variables = getattr(data, "extraction_variables", None)
self.add_global_prompt = getattr(data, "add_global_prompt", True)
self.greeting = getattr(data, "greeting", None)
self.greeting_type = getattr(data, "greeting_type", None)
self.greeting_recording_id = getattr(data, "greeting_recording_id", None)
self.detect_voicemail = getattr(data, "detect_voicemail", False)
self.delayed_start = getattr(data, "delayed_start", False)
self.delayed_start_duration = getattr(data, "delayed_start_duration", None)
self.tool_uuids = getattr(data, "tool_uuids", None)
self.document_uuids = getattr(data, "document_uuids", None)
self.pre_call_fetch_enabled = getattr(data, "pre_call_fetch_enabled", False)
self.pre_call_fetch_url = getattr(data, "pre_call_fetch_url", None)
self.pre_call_fetch_credential_uuid = getattr(
data, "pre_call_fetch_credential_uuid", None
)
self.data = data
@ -98,9 +104,11 @@ class WorkflowGraph:
"""
def __init__(self, dto: ReactFlowDTO):
# build adjacency list
# build adjacency list. n.type comes off the discriminated-union
# variant as a literal string; coerce to NodeType for downstream
# comparisons.
self.nodes: Dict[str, Node] = {
n.id: Node(n.id, n.type, n.data) for n in dto.nodes
n.id: Node(n.id, NodeType(n.type), n.data) for n in dto.nodes
}
# Store all edges