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