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,2 +1,4 @@
|
||||||
api/.env
|
api/.env
|
||||||
evals/
|
evals/
|
||||||
|
api/mcp_server/ts_validator/node_modules/
|
||||||
|
sdk/
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -16,4 +16,5 @@ venv/
|
||||||
.playwright-mcp
|
.playwright-mcp
|
||||||
coturn/
|
coturn/
|
||||||
*.wav
|
*.wav
|
||||||
dograh_pcm_cache/
|
dograh_pcm_cache/
|
||||||
|
node_modules/
|
||||||
|
|
@ -13,11 +13,6 @@ RUN apt-get update && apt-get install -y \
|
||||||
# Copy and install requirements
|
# Copy and install requirements
|
||||||
COPY api/requirements.txt .
|
COPY api/requirements.txt .
|
||||||
|
|
||||||
# Install CPU-only PyTorch FIRST to prevent CUDA/NVIDIA dependencies
|
|
||||||
# This satisfies torch dependency before other packages try to pull GPU version
|
|
||||||
RUN pip install --user --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu && \
|
|
||||||
rm -rf /root/.cache/pip
|
|
||||||
|
|
||||||
# Install dependencies to user directory for easy copying
|
# Install dependencies to user directory for easy copying
|
||||||
RUN pip install --user --no-cache-dir -r requirements.txt && \
|
RUN pip install --user --no-cache-dir -r requirements.txt && \
|
||||||
# Clean up pip cache after installation
|
# Clean up pip cache after installation
|
||||||
|
|
@ -25,27 +20,54 @@ RUN pip install --user --no-cache-dir -r requirements.txt && \
|
||||||
|
|
||||||
# Copy and install pipecat from local submodule
|
# Copy and install pipecat from local submodule
|
||||||
COPY pipecat /tmp/pipecat
|
COPY pipecat /tmp/pipecat
|
||||||
RUN pip install --user --no-cache-dir '/tmp/pipecat[cartesia,deepgram,openai,elevenlabs,groq,google,azure,sarvam,soundfile,silero,webrtc,local-smart-turn-v3,speechmatics,openrouter,camb]' && \
|
RUN pip install --user --no-cache-dir '/tmp/pipecat[cartesia,deepgram,openai,elevenlabs,groq,google,azure,sarvam,soundfile,silero,webrtc,speechmatics,openrouter,camb]' && \
|
||||||
|
# Swap opencv-python (pulled by pipecat[webrtc]) for opencv-python-headless
|
||||||
|
# to drop X11/Qt dependencies that otherwise require libxcb etc. in runner.
|
||||||
|
pip uninstall -y opencv-python && \
|
||||||
|
pip install --user --no-cache-dir opencv-python-headless && \
|
||||||
# Pre-download NLTK punkt_tab tokenizer data (required by pipecat at runtime)
|
# Pre-download NLTK punkt_tab tokenizer data (required by pipecat at runtime)
|
||||||
python -c "import nltk; nltk.download('punkt_tab', quiet=True)" && \
|
python -c "import nltk; nltk.download('punkt_tab', quiet=True)" && \
|
||||||
# Clean up pip cache and temporary pipecat directory
|
# Clean up pip cache and temporary pipecat directory
|
||||||
rm -rf /root/.cache/pip /tmp/pipecat
|
rm -rf /root/.cache/pip /tmp/pipecat
|
||||||
|
|
||||||
# Remove unnecessary Python cache files from installed packages
|
# Strip cache files, test/example dirs, and type stubs from installed packages
|
||||||
RUN find /root/.local -type f -name '*.pyc' -delete && \
|
RUN find /root/.local -type f -name '*.pyc' -delete && \
|
||||||
find /root/.local -type d -name '__pycache__' -delete && \
|
find /root/.local -type d -name '__pycache__' -prune -exec rm -rf {} + && \
|
||||||
find /root/.local -type f -name '*.pyo' -delete
|
find /root/.local -type f -name '*.pyo' -delete && \
|
||||||
|
find /root/.local -type d \( -name tests -o -name test -o -name examples \) -prune -exec rm -rf {} + && \
|
||||||
|
find /root/.local -name '*.pyi' -delete
|
||||||
|
|
||||||
# Stage 2: Runtime - Minimal image with only runtime dependencies
|
# Stage 2: Node deps for ts_validator (built with full node:22-slim, only
|
||||||
|
# node_modules is copied into the runner).
|
||||||
|
FROM node:22-slim AS ts-deps
|
||||||
|
WORKDIR /ts_validator
|
||||||
|
COPY api/mcp_server/ts_validator/package*.json ./
|
||||||
|
RUN npm ci --omit=dev && npm cache clean --force
|
||||||
|
|
||||||
|
# Stage 3: Static ffmpeg binary (avoids apt ffmpeg pulling mesa/libllvm for
|
||||||
|
# hardware acceleration we don't use server-side).
|
||||||
|
FROM debian:trixie-slim AS ffmpeg-static
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
curl ca-certificates xz-utils \
|
||||||
|
&& curl -fsSL -o /tmp/ffmpeg.tar.xz https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz \
|
||||||
|
&& mkdir -p /tmp/ffmpeg \
|
||||||
|
&& tar -xJf /tmp/ffmpeg.tar.xz -C /tmp/ffmpeg --strip-components=1 \
|
||||||
|
&& mv /tmp/ffmpeg/ffmpeg /tmp/ffmpeg/ffprobe /usr/local/bin/ \
|
||||||
|
&& chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe
|
||||||
|
|
||||||
|
# Stage 4: Runtime - Minimal image with only runtime dependencies
|
||||||
FROM python:3.12-slim AS runner
|
FROM python:3.12-slim AS runner
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Only install ffmpeg (runtime dependency)
|
# Static ffmpeg + ffprobe (used by audio_converter, audio_file_cache, etc.)
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
COPY --from=ffmpeg-static /usr/local/bin/ffmpeg /usr/local/bin/ffmpeg
|
||||||
ffmpeg \
|
COPY --from=ffmpeg-static /usr/local/bin/ffprobe /usr/local/bin/ffprobe
|
||||||
&& apt-get clean \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
# Node.js 22 binary only (ts_validator subprocess needs node >=22.6 for
|
||||||
|
# native TypeScript stripping; see api/mcp_server/ts_bridge.py). python:3.12-slim
|
||||||
|
# already provides libstdc++6, libgcc-s1, and ca-certificates that node needs.
|
||||||
|
COPY --from=node:22-slim /usr/local/bin/node /usr/local/bin/node
|
||||||
|
|
||||||
# Copy Python packages from builder stage
|
# Copy Python packages from builder stage
|
||||||
COPY --from=builder /root/.local /root/.local
|
COPY --from=builder /root/.local /root/.local
|
||||||
|
|
@ -65,6 +87,10 @@ ENV PYTHONUNBUFFERED=1
|
||||||
COPY ./api ./api
|
COPY ./api ./api
|
||||||
COPY ./scripts/start_services_dev.sh ./scripts/start_services_dev.sh
|
COPY ./scripts/start_services_dev.sh ./scripts/start_services_dev.sh
|
||||||
|
|
||||||
|
# ts_validator Node deps (built in ts-deps stage with full node:22-slim image).
|
||||||
|
# The validator runs as a short-lived subprocess from api/mcp_server/ts_bridge.py.
|
||||||
|
COPY --from=ts-deps /ts_validator/node_modules ./api/mcp_server/ts_validator/node_modules
|
||||||
|
|
||||||
# Product documentation — read at runtime by the MCP docs tools
|
# Product documentation — read at runtime by the MCP docs tools
|
||||||
# (search_dograh_docs / fetch_dograh_doc) so agents can learn Dograh.
|
# (search_dograh_docs / fetch_dograh_doc) so agents can learn Dograh.
|
||||||
COPY ./docs ./docs
|
COPY ./docs ./docs
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from api.constants import REDIS_URL
|
from api.constants import REDIS_URL
|
||||||
from api.mcp import mcp
|
from api.mcp_server import mcp
|
||||||
from api.routes.main import router as main_router
|
from api.routes.main import router as main_router
|
||||||
from api.services.pipecat.tracing_config import (
|
from api.services.pipecat.tracing_config import (
|
||||||
handle_langfuse_sync,
|
handle_langfuse_sync,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
from api.mcp.server import mcp
|
|
||||||
|
|
||||||
__all__ = ["mcp"]
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
from fastapi import HTTPException
|
|
||||||
from fastmcp.server.dependencies import get_http_headers
|
|
||||||
|
|
||||||
from api.db.models import UserModel
|
|
||||||
from api.services.auth.depends import _handle_api_key_auth
|
|
||||||
|
|
||||||
|
|
||||||
async def authenticate_mcp_request() -> UserModel:
|
|
||||||
"""Resolve the authenticated Dograh user for an MCP tool invocation.
|
|
||||||
|
|
||||||
Accepts either `X-API-Key: <key>` or `Authorization: Bearer <key>`,
|
|
||||||
reusing the API-key flow from `api.services.auth.depends`.
|
|
||||||
"""
|
|
||||||
headers = get_http_headers()
|
|
||||||
api_key = headers.get("x-api-key")
|
|
||||||
if not api_key:
|
|
||||||
auth = headers.get("authorization", "")
|
|
||||||
if auth.lower().startswith("bearer "):
|
|
||||||
api_key = auth.split(" ", 1)[1].strip()
|
|
||||||
if not api_key:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=401,
|
|
||||||
detail="Missing API key — send X-API-Key or Authorization: Bearer <key>",
|
|
||||||
)
|
|
||||||
return await _handle_api_key_auth(api_key)
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
from fastmcp import FastMCP
|
|
||||||
|
|
||||||
mcp = FastMCP("dograh")
|
|
||||||
|
|
||||||
from api.mcp.tools import docs as _docs # noqa: E402, F401
|
|
||||||
from api.mcp.tools import workflows as _workflows # noqa: E402, F401
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
import re
|
|
||||||
from functools import lru_cache
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from fastapi import HTTPException
|
|
||||||
from rank_bm25 import BM25Okapi
|
|
||||||
|
|
||||||
from api.mcp.server import mcp
|
|
||||||
|
|
||||||
DOCS_ROOT = Path(__file__).resolve().parents[3] / "docs"
|
|
||||||
|
|
||||||
_TOKEN_RE = re.compile(r"[A-Za-z0-9_]+")
|
|
||||||
_FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n", re.DOTALL)
|
|
||||||
_TITLE_RE = re.compile(r"^title:\s*['\"]?(.+?)['\"]?\s*$", re.MULTILINE)
|
|
||||||
_H1_RE = re.compile(r"^#\s+(.+?)\s*$", re.MULTILINE)
|
|
||||||
|
|
||||||
|
|
||||||
def _tokenize(text: str) -> list[str]:
|
|
||||||
return [t.lower() for t in _TOKEN_RE.findall(text)]
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_title(path: Path, body: str) -> str:
|
|
||||||
fm_match = _FRONTMATTER_RE.match(body)
|
|
||||||
if fm_match:
|
|
||||||
title_match = _TITLE_RE.search(fm_match.group(1))
|
|
||||||
if title_match:
|
|
||||||
return title_match.group(1).strip()
|
|
||||||
h1_match = _H1_RE.search(body)
|
|
||||||
if h1_match:
|
|
||||||
return h1_match.group(1).strip()
|
|
||||||
return path.stem.replace("-", " ").title()
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_frontmatter(body: str) -> str:
|
|
||||||
return _FRONTMATTER_RE.sub("", body, count=1)
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
|
||||||
def _load_index() -> tuple[list[dict], BM25Okapi]:
|
|
||||||
"""Read every docs/**/*.mdx file once and build a BM25 index.
|
|
||||||
|
|
||||||
Cached for the process lifetime — docs rarely change between restarts.
|
|
||||||
"""
|
|
||||||
docs: list[dict] = []
|
|
||||||
corpus: list[list[str]] = []
|
|
||||||
|
|
||||||
for path in sorted(DOCS_ROOT.rglob("*.mdx")):
|
|
||||||
body = path.read_text(encoding="utf-8")
|
|
||||||
rel = path.relative_to(DOCS_ROOT).as_posix()
|
|
||||||
title = _extract_title(path, body)
|
|
||||||
content = _strip_frontmatter(body)
|
|
||||||
docs.append({"path": rel, "title": title, "content": content})
|
|
||||||
corpus.append(_tokenize(f"{title} {content}"))
|
|
||||||
|
|
||||||
return docs, BM25Okapi(corpus)
|
|
||||||
|
|
||||||
|
|
||||||
def _snippet(content: str, query_tokens: list[str], width: int = 240) -> str:
|
|
||||||
lowered = content.lower()
|
|
||||||
for tok in query_tokens:
|
|
||||||
idx = lowered.find(tok)
|
|
||||||
if idx >= 0:
|
|
||||||
start = max(0, idx - width // 2)
|
|
||||||
end = min(len(content), start + width)
|
|
||||||
return (
|
|
||||||
("…" if start > 0 else "")
|
|
||||||
+ content[start:end].strip()
|
|
||||||
+ ("…" if end < len(content) else "")
|
|
||||||
)
|
|
||||||
return content[:width].strip() + ("…" if len(content) > width else "")
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool
|
|
||||||
async def search_dograh_docs(query: str, limit: int = 5) -> list[dict]:
|
|
||||||
"""Search Dograh's product documentation.
|
|
||||||
|
|
||||||
Returns the top matches as {path, title, snippet}. Pass the returned
|
|
||||||
`path` to `fetch_dograh_doc` to read the full page. Use this first
|
|
||||||
when you need to learn how a Dograh feature works before building
|
|
||||||
against it.
|
|
||||||
"""
|
|
||||||
docs, bm25 = _load_index()
|
|
||||||
tokens = _tokenize(query)
|
|
||||||
if not tokens:
|
|
||||||
return []
|
|
||||||
|
|
||||||
scores = bm25.get_scores(tokens)
|
|
||||||
ranked = sorted(zip(scores, docs), key=lambda pair: pair[0], reverse=True)[:limit]
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"path": doc["path"],
|
|
||||||
"title": doc["title"],
|
|
||||||
"snippet": _snippet(doc["content"], tokens),
|
|
||||||
"score": round(float(score), 3),
|
|
||||||
}
|
|
||||||
for score, doc in ranked
|
|
||||||
if score > 0
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool
|
|
||||||
async def fetch_dograh_doc(path: str) -> dict:
|
|
||||||
"""Fetch the full content of a Dograh docs page by its path
|
|
||||||
(e.g. `core-concepts/workflows.mdx`), as returned by `search_dograh_docs`.
|
|
||||||
"""
|
|
||||||
docs, _ = _load_index()
|
|
||||||
for doc in docs:
|
|
||||||
if doc["path"] == path:
|
|
||||||
return {
|
|
||||||
"path": doc["path"],
|
|
||||||
"title": doc["title"],
|
|
||||||
"content": doc["content"],
|
|
||||||
}
|
|
||||||
raise HTTPException(status_code=404, detail=f"Doc not found: {path}")
|
|
||||||
3
api/mcp_server/__init__.py
Normal file
3
api/mcp_server/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from api.mcp_server.server import mcp
|
||||||
|
|
||||||
|
__all__ = ["mcp"]
|
||||||
46
api/mcp_server/auth.py
Normal file
46
api/mcp_server/auth.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from fastmcp.server.dependencies import get_http_headers
|
||||||
|
from opentelemetry import trace
|
||||||
|
|
||||||
|
from api.db.models import UserModel
|
||||||
|
from api.services.auth.depends import _handle_api_key_auth
|
||||||
|
|
||||||
|
|
||||||
|
async def authenticate_mcp_request() -> UserModel:
|
||||||
|
"""Resolve the authenticated Dograh user for an MCP tool invocation.
|
||||||
|
|
||||||
|
Accepts either `X-API-Key: <key>` or `Authorization: Bearer <key>`,
|
||||||
|
reusing the API-key flow from `api.services.auth.depends`.
|
||||||
|
|
||||||
|
Tags the currently-active OTel span with the resolved organization
|
||||||
|
and user identifiers. `_OrgRoutingExporter` reads `dograh.org_id`
|
||||||
|
at export time to dispatch the span to the right Langfuse project;
|
||||||
|
the `langfuse.user.id` / `langfuse.session.id` attributes make the
|
||||||
|
span filterable in the Langfuse UI.
|
||||||
|
"""
|
||||||
|
headers = get_http_headers()
|
||||||
|
api_key = headers.get("x-api-key")
|
||||||
|
if not api_key:
|
||||||
|
auth = headers.get("authorization", "")
|
||||||
|
if auth.lower().startswith("bearer "):
|
||||||
|
api_key = auth.split(" ", 1)[1].strip()
|
||||||
|
if not api_key:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Missing API key — send X-API-Key or Authorization: Bearer <key>",
|
||||||
|
)
|
||||||
|
user = await _handle_api_key_auth(api_key)
|
||||||
|
|
||||||
|
span = trace.get_current_span()
|
||||||
|
if span.is_recording():
|
||||||
|
org_id = user.selected_organization_id
|
||||||
|
# Intentionally NOT `dograh.org_id` — that attribute triggers the
|
||||||
|
# per-org Langfuse routing for pipeline spans, and MCP traffic
|
||||||
|
# should land in the default (developer-facing) project only.
|
||||||
|
# Exposed under `mcp.org_id` for Langfuse UI filtering without
|
||||||
|
# affecting the router.
|
||||||
|
span.set_attribute("mcp.org_id", str(org_id))
|
||||||
|
span.set_attribute("mcp.user_id", str(user.id))
|
||||||
|
span.set_attribute("langfuse.user.id", str(user.id))
|
||||||
|
|
||||||
|
return user
|
||||||
124
api/mcp_server/instructions.py
Normal file
124
api/mcp_server/instructions.py
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
"""Top-level orchestration guide surfaced to every MCP session.
|
||||||
|
|
||||||
|
Sent to the client via `FastMCP(instructions=...)` — the client bakes
|
||||||
|
this into its system prompt, so every LLM session sees it before the
|
||||||
|
first tool call. Prefer procedural orchestration here (call order, error
|
||||||
|
handling, hard constraints). Design-level per-field guidance belongs in
|
||||||
|
each `PropertySpec.llm_hint`; it flows out through `get_node_type` and
|
||||||
|
doesn't need to be repeated here.
|
||||||
|
|
||||||
|
Extend based on real LLM failures — every bullet below ideally maps to a
|
||||||
|
mistake the system has seen at least once.
|
||||||
|
"""
|
||||||
|
|
||||||
|
DOGRAH_MCP_INSTRUCTIONS = """\
|
||||||
|
You build and edit Dograh voice-AI workflows by emitting TypeScript that
|
||||||
|
uses the `@dograh/sdk` package. Workflows are stored as JSON; this server
|
||||||
|
projects them to TypeScript for editing and parses them back on save.
|
||||||
|
|
||||||
|
## Call order
|
||||||
|
|
||||||
|
1. `list_workflows` — locate the target workflow.
|
||||||
|
2. `get_workflow_code(workflow_id)` — fetch the current source (draft if
|
||||||
|
one exists, otherwise published).
|
||||||
|
3. (optional) `list_node_types` / `get_node_type(name)` — consult before
|
||||||
|
adding or editing a node type whose fields aren't already visible in
|
||||||
|
the current code.
|
||||||
|
4. Mutate the code in place. Preserve existing nodes, edges, and variable
|
||||||
|
names unless the task requires removing or renaming them.
|
||||||
|
5. `save_workflow(workflow_id, code)` — persist as a new draft. The
|
||||||
|
published version is untouched.
|
||||||
|
|
||||||
|
## Allowed source shape
|
||||||
|
|
||||||
|
The parser is AST-only and rejects anything outside this grammar. At the
|
||||||
|
top level, only three statement forms are accepted:
|
||||||
|
|
||||||
|
import ... from "..."; // any import
|
||||||
|
const <var> = <initializer>; // bindings (see below)
|
||||||
|
wf.edge(<src>, <tgt>, { label, condition }); // bare edge calls
|
||||||
|
|
||||||
|
`<initializer>` is one of:
|
||||||
|
new Workflow({ name: "..." })
|
||||||
|
wf.addTyped(<factory>({ ...fields }) [, { position: [x, y] }])
|
||||||
|
wf.add({ type: "<nodeType>", ...fields [, position: [x, y]] })
|
||||||
|
|
||||||
|
No functions, arrow fns, loops, conditionals, ternaries, spreads,
|
||||||
|
destructuring, template interpolation, `export`, or `.map`/`.forEach`.
|
||||||
|
Data-position values must be plain literals (strings, numbers, booleans,
|
||||||
|
null, arrays/objects of same). A single `new Workflow(...)` per file —
|
||||||
|
the `name` you pass there is the workflow's display name and is applied
|
||||||
|
on save (renames propagate immediately; definition changes go to draft).
|
||||||
|
|
||||||
|
## Adding edges — explicit syntax
|
||||||
|
|
||||||
|
wf.edge(source, target, { label: "...", condition: "..." });
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- `source` and `target` are the **bare variable identifiers** bound by
|
||||||
|
`wf.addTyped(...)` / `wf.add(...)` — not strings, not `.id`, not inline
|
||||||
|
factories. Both must be declared earlier in the file.
|
||||||
|
- `label` is a short tag (≤4 words) shown in call logs to identify the
|
||||||
|
branch: `"qualified"`, `"wrap up"`, `"retry"`.
|
||||||
|
- `condition` is a full natural-language predicate the runtime evaluates
|
||||||
|
against the live conversation: `"caller confirmed interest in a demo"`,
|
||||||
|
not `"interested"`. Condition clarity determines routing accuracy.
|
||||||
|
- Both fields are required and must be non-empty strings.
|
||||||
|
- Edges are directional; emit one `wf.edge(...)` per outgoing branch.
|
||||||
|
- Place all edges after all node bindings; group by source node.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
const greet = wf.addTyped(startCall({ name: "Greet", prompt: "Hi!" }));
|
||||||
|
const done = wf.addTyped(endCall({ name: "Done", prompt: "Bye." }));
|
||||||
|
wf.edge(greet, done, {
|
||||||
|
label: "wrap up",
|
||||||
|
condition: "user acknowledged the greeting and is ready to end"
|
||||||
|
});
|
||||||
|
|
||||||
|
## Hard graph constraints
|
||||||
|
|
||||||
|
- Exactly one `startCall` node per workflow; no incoming edges.
|
||||||
|
- `endCall` nodes have no outgoing edges.
|
||||||
|
- `globalNode` has no incoming or outgoing edges; its prompt is prepended
|
||||||
|
to every other node's prompt at runtime when that node sets
|
||||||
|
`add_global_prompt=true`.
|
||||||
|
- Every non-global node must be reachable from `startCall`.
|
||||||
|
|
||||||
|
## Iterating on errors
|
||||||
|
|
||||||
|
`save_workflow` returns one of:
|
||||||
|
- `parse_error` Disallowed construct (see grammar above) or
|
||||||
|
malformed TypeScript.
|
||||||
|
- `validation_error` Node data failed spec validation (unknown field,
|
||||||
|
missing required, wrong type, bad `options` value).
|
||||||
|
- `graph_validation` Structural rule broken (missing startCall,
|
||||||
|
unreachable node, edge to/from wrong node type).
|
||||||
|
- `bridge_error` Internal — retry once, then surface to the user.
|
||||||
|
|
||||||
|
Every error carries `line` and `column`. Fix at that location and
|
||||||
|
resubmit the **complete source** — this tool does not accept patches.
|
||||||
|
|
||||||
|
## Field conventions
|
||||||
|
|
||||||
|
- `data.name` is the canonical identifier. Pick a descriptive name
|
||||||
|
(`"Qualify Budget"`, not `"Node1"`) — the generated code uses it as
|
||||||
|
the variable name and call logs reference it.
|
||||||
|
- Reference fields take UUIDs, not human names:
|
||||||
|
`tool_refs`, `document_refs` → from `list_tools`, `list_documents`
|
||||||
|
`credential_ref` → from `list_credentials`
|
||||||
|
`recording_ref` → from `list_recordings`
|
||||||
|
- `mention_textarea` fields (prompts, greetings, etc.) accept
|
||||||
|
`{{template_variables}}` — values resolved at runtime from
|
||||||
|
`pre_call_fetch`, caller context, or earlier extraction passes.
|
||||||
|
|
||||||
|
## Style
|
||||||
|
|
||||||
|
- Prefer `wf.addTyped(factory({ ... }))` over `wf.add({ type, ... })`.
|
||||||
|
- Only include fields whose values differ from the spec default — the
|
||||||
|
parser re-applies defaults on save, so extras are noise.
|
||||||
|
- Omit `position`; the server reconciles positions against the previous
|
||||||
|
saved workflow and lays out new nodes automatically.
|
||||||
|
- Add nodes in call-flow order (start → intermediate → end) so the
|
||||||
|
generated code reads top-to-bottom, with all edges after all nodes.
|
||||||
|
"""
|
||||||
11
api/mcp_server/server.py
Normal file
11
api/mcp_server/server.py
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
from fastmcp import FastMCP
|
||||||
|
|
||||||
|
from api.mcp_server.instructions import DOGRAH_MCP_INSTRUCTIONS
|
||||||
|
|
||||||
|
mcp = FastMCP("dograh", instructions=DOGRAH_MCP_INSTRUCTIONS)
|
||||||
|
|
||||||
|
from api.mcp_server.tools import catalog as _catalog # noqa: E402, F401
|
||||||
|
from api.mcp_server.tools import get_workflow_code as _get_workflow_code # noqa: E402, F401
|
||||||
|
from api.mcp_server.tools import node_types as _node_types # noqa: E402, F401
|
||||||
|
from api.mcp_server.tools import save_workflow as _save_workflow # noqa: E402, F401
|
||||||
|
from api.mcp_server.tools import workflows as _workflows # noqa: E402, F401
|
||||||
113
api/mcp_server/tools/catalog.py
Normal file
113
api/mcp_server/tools/catalog.py
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
"""MCP discovery tools for the reference catalogs.
|
||||||
|
|
||||||
|
Node properties of type `tool_refs`, `document_refs`, `recording_ref`, and
|
||||||
|
`credential_ref` carry UUIDs that resolve against these catalogs. LLMs must
|
||||||
|
list the catalog before populating those fields with real UUIDs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from api.db import db_client
|
||||||
|
from api.mcp_server.auth import authenticate_mcp_request
|
||||||
|
from api.mcp_server.server import mcp
|
||||||
|
from api.mcp_server.tracing import traced_tool
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool
|
||||||
|
@traced_tool
|
||||||
|
async def list_tools(status: str | None = "active") -> list[dict]:
|
||||||
|
"""List tools the agent can invoke during a call.
|
||||||
|
|
||||||
|
Returns each tool's `tool_uuid` (use this in node `tool_uuids` properties),
|
||||||
|
`name`, `description`, and `category`. Pass `status=None` to include
|
||||||
|
archived tools.
|
||||||
|
"""
|
||||||
|
user = await authenticate_mcp_request()
|
||||||
|
tools = await db_client.get_tools_for_organization(
|
||||||
|
organization_id=user.selected_organization_id,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"tool_uuid": t.tool_uuid,
|
||||||
|
"name": t.name,
|
||||||
|
"description": t.description or "",
|
||||||
|
"category": t.category,
|
||||||
|
}
|
||||||
|
for t in tools
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool
|
||||||
|
@traced_tool
|
||||||
|
async def list_documents() -> list[dict]:
|
||||||
|
"""List knowledge-base documents the agent can reference during a call.
|
||||||
|
|
||||||
|
Returns each document's `document_uuid` (use this in node
|
||||||
|
`document_uuids` properties), `filename`, and `processing_status`.
|
||||||
|
"""
|
||||||
|
user = await authenticate_mcp_request()
|
||||||
|
documents = await db_client.get_documents_for_organization(
|
||||||
|
organization_id=user.selected_organization_id,
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"document_uuid": d.document_uuid,
|
||||||
|
"filename": d.filename,
|
||||||
|
"processing_status": d.processing_status,
|
||||||
|
"total_chunks": d.total_chunks,
|
||||||
|
}
|
||||||
|
for d in documents
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool
|
||||||
|
@traced_tool
|
||||||
|
async def list_credentials() -> list[dict]:
|
||||||
|
"""List external credentials available for webhook auth and pre-call fetch.
|
||||||
|
|
||||||
|
Returns each credential's `credential_uuid` (use this in node
|
||||||
|
`credential_uuid` / `pre_call_fetch_credential_uuid` properties), `name`,
|
||||||
|
`description`, and `credential_type`.
|
||||||
|
"""
|
||||||
|
user = await authenticate_mcp_request()
|
||||||
|
credentials = await db_client.get_credentials_for_organization(
|
||||||
|
organization_id=user.selected_organization_id,
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"credential_uuid": c.credential_uuid,
|
||||||
|
"name": c.name,
|
||||||
|
"description": c.description or "",
|
||||||
|
"credential_type": c.credential_type,
|
||||||
|
}
|
||||||
|
for c in credentials
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool
|
||||||
|
@traced_tool
|
||||||
|
async def list_recordings(workflow_id: int | None = None) -> list[dict]:
|
||||||
|
"""List pre-recorded audio files available for greetings and edge
|
||||||
|
transition speech.
|
||||||
|
|
||||||
|
Returns each recording's `recording_id` (use this in
|
||||||
|
`greeting_recording_id` / `transition_speech_recording_id` properties),
|
||||||
|
`transcript`, and TTS metadata. Pass `workflow_id` to filter to one
|
||||||
|
workflow's recordings.
|
||||||
|
"""
|
||||||
|
user = await authenticate_mcp_request()
|
||||||
|
recordings = await db_client.get_recordings(
|
||||||
|
organization_id=user.selected_organization_id,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r.id,
|
||||||
|
"recording_id": r.recording_id,
|
||||||
|
"workflow_id": r.workflow_id,
|
||||||
|
"transcript": r.transcript,
|
||||||
|
"tts_provider": r.tts_provider,
|
||||||
|
"tts_model": r.tts_model,
|
||||||
|
"tts_voice_id": r.tts_voice_id,
|
||||||
|
}
|
||||||
|
for r in recordings
|
||||||
|
]
|
||||||
71
api/mcp_server/tools/get_workflow_code.py
Normal file
71
api/mcp_server/tools/get_workflow_code.py
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
"""MCP tool that returns a workflow as SDK TypeScript code.
|
||||||
|
|
||||||
|
Companion to `save_workflow`: the LLM calls `get_workflow_code` to see
|
||||||
|
the current state of a workflow as editable code, mutates it, and calls
|
||||||
|
`save_workflow` with the new code. Storage stays JSON; the TS form is
|
||||||
|
an ephemeral projection for the LLM edit loop.
|
||||||
|
|
||||||
|
Selection priority: latest draft → latest published → legacy
|
||||||
|
`workflow.workflow_definition`. That matches the UI's "whichever is the
|
||||||
|
working copy" behavior so the LLM sees what a human editor would see.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from api.db import db_client
|
||||||
|
from api.mcp_server.auth import authenticate_mcp_request
|
||||||
|
from api.mcp_server.server import mcp
|
||||||
|
from api.mcp_server.tracing import traced_tool
|
||||||
|
from api.mcp_server.ts_bridge import TsBridgeError, generate_code
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool
|
||||||
|
@traced_tool
|
||||||
|
async def get_workflow_code(workflow_id: int) -> dict[str, Any]:
|
||||||
|
"""Return the workflow as SDK TypeScript code the LLM can edit.
|
||||||
|
|
||||||
|
Output shape:
|
||||||
|
{"code": "<TS source>", "workflow_id": int, "version": "draft" | "published" | "legacy"}
|
||||||
|
|
||||||
|
The LLM edits `code`, then calls `save_workflow(workflow_id, code)`.
|
||||||
|
"""
|
||||||
|
user = await authenticate_mcp_request()
|
||||||
|
|
||||||
|
workflow = await db_client.get_workflow(
|
||||||
|
workflow_id, organization_id=user.selected_organization_id
|
||||||
|
)
|
||||||
|
if not workflow:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Workflow {workflow_id} not found")
|
||||||
|
|
||||||
|
# Draft wins over published — editing a draft is the normal flow.
|
||||||
|
# `current_definition` (is_current=True) is the published row, so we
|
||||||
|
# fetch the draft explicitly. If the latest draft was just published,
|
||||||
|
# no draft row exists and we fall through to `released_definition`.
|
||||||
|
draft = await db_client.get_draft_version(workflow_id)
|
||||||
|
released = workflow.released_definition
|
||||||
|
|
||||||
|
if draft is not None and draft.workflow_json:
|
||||||
|
payload = draft.workflow_json
|
||||||
|
source = "draft"
|
||||||
|
elif released is not None and released.workflow_json:
|
||||||
|
payload = released.workflow_json
|
||||||
|
source = "published"
|
||||||
|
else:
|
||||||
|
payload = workflow.workflow_definition or {}
|
||||||
|
source = "legacy"
|
||||||
|
|
||||||
|
try:
|
||||||
|
code = await generate_code(payload, workflow_name=workflow.name or "")
|
||||||
|
except TsBridgeError as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to generate code: {e}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"workflow_id": workflow_id,
|
||||||
|
"name": workflow.name or "",
|
||||||
|
"version": source,
|
||||||
|
"code": code,
|
||||||
|
}
|
||||||
57
api/mcp_server/tools/node_types.py
Normal file
57
api/mcp_server/tools/node_types.py
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
"""MCP discovery tools for node specifications.
|
||||||
|
|
||||||
|
LLMs call these tools first to learn the available node-type catalog and
|
||||||
|
each node's property schema before composing or modifying a workflow.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from api.mcp_server.auth import authenticate_mcp_request
|
||||||
|
from api.mcp_server.server import mcp
|
||||||
|
from api.mcp_server.tracing import traced_tool
|
||||||
|
from api.services.workflow.node_specs import SPEC_VERSION, all_specs, get_spec
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool
|
||||||
|
@traced_tool
|
||||||
|
async def list_node_types() -> dict:
|
||||||
|
"""List every available node type with a brief summary.
|
||||||
|
|
||||||
|
Use this first to discover what nodes exist, then call `get_node_type`
|
||||||
|
for the full schema of any node you intend to use.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dict with `spec_version` (pin against this in any generated workflow
|
||||||
|
code) and `node_types` (list of {name, display_name, description,
|
||||||
|
category}).
|
||||||
|
"""
|
||||||
|
await authenticate_mcp_request()
|
||||||
|
return {
|
||||||
|
"spec_version": SPEC_VERSION,
|
||||||
|
"node_types": [
|
||||||
|
{
|
||||||
|
"name": spec.name,
|
||||||
|
"display_name": spec.display_name,
|
||||||
|
"description": spec.description,
|
||||||
|
"category": spec.category.value,
|
||||||
|
}
|
||||||
|
for spec in all_specs()
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool
|
||||||
|
@traced_tool
|
||||||
|
async def get_node_type(name: str) -> dict:
|
||||||
|
"""Fetch the full schema for a node type, including every property's
|
||||||
|
type, default, conditional visibility rules, and LLM-readable
|
||||||
|
description, plus worked examples.
|
||||||
|
|
||||||
|
Use the property `description` and the `examples` list to understand
|
||||||
|
semantics — types alone are not enough.
|
||||||
|
"""
|
||||||
|
await authenticate_mcp_request()
|
||||||
|
spec = get_spec(name)
|
||||||
|
if spec is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Unknown node type: {name!r}")
|
||||||
|
return spec.model_dump(mode="json")
|
||||||
168
api/mcp_server/tools/save_workflow.py
Normal file
168
api/mcp_server/tools/save_workflow.py
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
"""MCP tool that accepts LLM-authored SDK TypeScript and saves it as a draft.
|
||||||
|
|
||||||
|
Execution flow:
|
||||||
|
1. Parse via the Node TS validator — AST-only, never executes the code.
|
||||||
|
Returns either a workflow JSON or per-location parse/validate errors.
|
||||||
|
2. Pydantic validation via `ReactFlowDTO.model_validate` (defence in
|
||||||
|
depth; the parser is already spec-driven, but the DTO layer is the
|
||||||
|
authoritative wire-format gate).
|
||||||
|
3. Graph validation via `WorkflowGraph`.
|
||||||
|
4. Save as a new draft via `db_client.save_workflow_draft` — the
|
||||||
|
published version stays intact, so edits are rollback-safe.
|
||||||
|
|
||||||
|
Error codes surfaced to the LLM:
|
||||||
|
parse_error — TS parse failed or a disallowed construct was used
|
||||||
|
validation_error — node data failed spec validation (unknown field,
|
||||||
|
missing required, wrong type, option out of range)
|
||||||
|
schema_validation — ReactFlowDTO Pydantic rejection (rare; parser bug)
|
||||||
|
graph_validation — semantic graph rule broken (e.g. no start node)
|
||||||
|
bridge_error — Node subprocess failed before returning JSON
|
||||||
|
|
||||||
|
All LLM-facing errors include file:line:column where available so the
|
||||||
|
LLM can correct its code directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from loguru import logger
|
||||||
|
from pydantic import ValidationError as PydanticValidationError
|
||||||
|
|
||||||
|
from api.db import db_client
|
||||||
|
from api.mcp_server.auth import authenticate_mcp_request
|
||||||
|
from api.mcp_server.server import mcp
|
||||||
|
from api.mcp_server.tracing import traced_tool
|
||||||
|
from api.mcp_server.ts_bridge import TsBridgeError, parse_code
|
||||||
|
from api.services.workflow.dto import ReactFlowDTO
|
||||||
|
from api.services.workflow.layout import reconcile_positions
|
||||||
|
from api.services.workflow.workflow import WorkflowGraph
|
||||||
|
|
||||||
|
|
||||||
|
async def _previous_workflow_json(workflow: Any) -> dict[str, Any] | None:
|
||||||
|
"""Same selection priority as `get_workflow_code` — the version the
|
||||||
|
LLM saw is the version we reconcile against.
|
||||||
|
|
||||||
|
`current_definition` (is_current=True) is the published row, so the
|
||||||
|
draft must be fetched explicitly. If no draft exists (e.g. the last
|
||||||
|
draft was just published), fall through to `released_definition`.
|
||||||
|
"""
|
||||||
|
draft = await db_client.get_draft_version(workflow.id)
|
||||||
|
if draft is not None and draft.workflow_json:
|
||||||
|
return draft.workflow_json
|
||||||
|
released = workflow.released_definition
|
||||||
|
if released is not None and released.workflow_json:
|
||||||
|
return released.workflow_json
|
||||||
|
return workflow.workflow_definition or None
|
||||||
|
|
||||||
|
|
||||||
|
def _error_result(code: str, message: str, **extra: Any) -> dict[str, Any]:
|
||||||
|
return {"saved": False, "error_code": code, "error": message, **extra}
|
||||||
|
|
||||||
|
|
||||||
|
def _format_errors(errors: list[dict[str, Any]]) -> str:
|
||||||
|
parts: list[str] = []
|
||||||
|
for e in errors:
|
||||||
|
loc = ""
|
||||||
|
line = e.get("line")
|
||||||
|
col = e.get("column")
|
||||||
|
if line is not None:
|
||||||
|
loc = f" (line {line}" + (f", col {col}" if col is not None else "") + ")"
|
||||||
|
parts.append(f"{e.get('message', '')}{loc}")
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool
|
||||||
|
@traced_tool
|
||||||
|
async def save_workflow(workflow_id: int, code: str) -> dict[str, Any]:
|
||||||
|
"""Parse SDK TypeScript and save the resulting workflow as a draft.
|
||||||
|
|
||||||
|
`code` is TypeScript source using `@dograh/sdk`. Fetch the current
|
||||||
|
code first via `get_workflow_code(workflow_id)`, edit it, then pass
|
||||||
|
the full updated source here.
|
||||||
|
|
||||||
|
Example code:
|
||||||
|
import { Workflow } from "@dograh/sdk";
|
||||||
|
import { startCall, endCall } from "@dograh/sdk/typed";
|
||||||
|
|
||||||
|
const wf = new Workflow({ name: "lead_qualification" });
|
||||||
|
const greeting = wf.addTyped(startCall({ name: "Greeting", prompt: "Hi!" }));
|
||||||
|
const done = wf.addTyped(endCall({ name: "Done", prompt: "Bye." }));
|
||||||
|
wf.edge(greeting, done, { label: "done", condition: "conversation complete" });
|
||||||
|
|
||||||
|
On success the draft version is saved; the published version is
|
||||||
|
untouched.
|
||||||
|
"""
|
||||||
|
user = await authenticate_mcp_request()
|
||||||
|
|
||||||
|
workflow = await db_client.get_workflow(
|
||||||
|
workflow_id, organization_id=user.selected_organization_id
|
||||||
|
)
|
||||||
|
if not workflow:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Workflow {workflow_id} not found")
|
||||||
|
|
||||||
|
# 1. Parse + spec-validate via the Node TS validator.
|
||||||
|
try:
|
||||||
|
parsed = await parse_code(code)
|
||||||
|
except TsBridgeError as e:
|
||||||
|
logger.warning(f"ts_bridge failure: {e}")
|
||||||
|
return _error_result("bridge_error", str(e))
|
||||||
|
|
||||||
|
if not parsed.get("ok"):
|
||||||
|
stage = parsed.get("stage", "parse")
|
||||||
|
errs = parsed.get("errors") or []
|
||||||
|
code_key = "parse_error" if stage == "parse" else "validation_error"
|
||||||
|
return _error_result(code_key, _format_errors(errs), errors=errs)
|
||||||
|
|
||||||
|
payload = parsed["workflow"]
|
||||||
|
new_name = (parsed.get("workflowName") or "").strip()
|
||||||
|
|
||||||
|
# 1b. Reconcile node positions against the previously-stored workflow.
|
||||||
|
# The parser drops positions by design (LLMs don't place nodes well);
|
||||||
|
# here we fill them back in from what was there before, and pick
|
||||||
|
# approximate placements for newly-introduced nodes.
|
||||||
|
payload = reconcile_positions(payload, await _previous_workflow_json(workflow))
|
||||||
|
|
||||||
|
# 2. Pydantic shape check (defence in depth — parser is spec-driven).
|
||||||
|
try:
|
||||||
|
dto = ReactFlowDTO.model_validate(payload)
|
||||||
|
except PydanticValidationError as e:
|
||||||
|
return _error_result("schema_validation", str(e))
|
||||||
|
|
||||||
|
# 3. Graph-level semantic validation (start-node count, edge shape).
|
||||||
|
try:
|
||||||
|
WorkflowGraph(dto)
|
||||||
|
except (ValueError, Exception) as e: # WorkflowGraph raises ValueError
|
||||||
|
return _error_result("graph_validation", str(e))
|
||||||
|
|
||||||
|
# 4a. If the `new Workflow({ name })` in the edited source differs from
|
||||||
|
# the stored name, rename the workflow. Name is a workflow-level field
|
||||||
|
# (not versioned), so this takes effect immediately.
|
||||||
|
name_changed = bool(new_name) and new_name != workflow.name
|
||||||
|
if name_changed:
|
||||||
|
await db_client.update_workflow(
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
name=new_name,
|
||||||
|
workflow_definition=None,
|
||||||
|
template_context_variables=None,
|
||||||
|
workflow_configurations=None,
|
||||||
|
organization_id=user.selected_organization_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4b. Save as a new draft (existing published version stays intact).
|
||||||
|
draft = await db_client.save_workflow_draft(
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
workflow_definition=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"saved": True,
|
||||||
|
"workflow_id": workflow_id,
|
||||||
|
"version_number": draft.version_number,
|
||||||
|
"status": draft.status,
|
||||||
|
"node_count": len(payload["nodes"]),
|
||||||
|
"edge_count": len(payload["edges"]),
|
||||||
|
"name": new_name or workflow.name,
|
||||||
|
"renamed": name_changed,
|
||||||
|
}
|
||||||
|
|
@ -1,17 +1,20 @@
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from api.db import db_client
|
from api.db import db_client
|
||||||
from api.mcp.auth import authenticate_mcp_request
|
from api.mcp_server.auth import authenticate_mcp_request
|
||||||
from api.mcp.server import mcp
|
from api.mcp_server.server import mcp
|
||||||
|
from api.mcp_server.tracing import traced_tool
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool
|
@mcp.tool
|
||||||
async def list_workflows(status: str | None = None) -> list[dict]:
|
@traced_tool
|
||||||
|
async def list_workflows(status: str | None = "active") -> list[dict]:
|
||||||
"""List agents (workflows) in the caller's organization.
|
"""List agents (workflows) in the caller's organization.
|
||||||
|
|
||||||
Returns id, name, status, and created_at for each agent. Use
|
Returns id, name, status, and created_at for each agent. Use
|
||||||
`get_workflow` to fetch a single agent's full definition. Pass
|
`get_workflow` to fetch a single agent's full definition. Defaults
|
||||||
`status="active"` or `status="archived"` to filter.
|
to active agents; pass `status="archived"` to list archived agents,
|
||||||
|
or `status=None` to list all.
|
||||||
"""
|
"""
|
||||||
user = await authenticate_mcp_request()
|
user = await authenticate_mcp_request()
|
||||||
workflows = await db_client.get_all_workflows_for_listing(
|
workflows = await db_client.get_all_workflows_for_listing(
|
||||||
|
|
@ -30,6 +33,7 @@ async def list_workflows(status: str | None = None) -> list[dict]:
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool
|
@mcp.tool
|
||||||
|
@traced_tool
|
||||||
async def get_workflow(workflow_id: int) -> dict:
|
async def get_workflow(workflow_id: int) -> dict:
|
||||||
"""Fetch a single agent by id, including its current published definition."""
|
"""Fetch a single agent by id, including its current published definition."""
|
||||||
user = await authenticate_mcp_request()
|
user = await authenticate_mcp_request()
|
||||||
87
api/mcp_server/tracing.py
Normal file
87
api/mcp_server/tracing.py
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
"""OTel tracing for MCP tool invocations.
|
||||||
|
|
||||||
|
The project-wide tracing setup in
|
||||||
|
`api/services/pipecat/tracing_config.py` already routes spans to
|
||||||
|
per-organization Langfuse projects based on the `dograh.org_id` span
|
||||||
|
attribute. This module plugs MCP tool calls into that pipeline:
|
||||||
|
|
||||||
|
@mcp.tool
|
||||||
|
@traced_tool
|
||||||
|
async def my_tool(...): ...
|
||||||
|
|
||||||
|
Each decorated invocation produces one span named `mcp.<tool_name>` with
|
||||||
|
Langfuse-rendered input/output. Organization and user attributes are
|
||||||
|
stamped separately by `authenticate_mcp_request` when it runs inside
|
||||||
|
the tool body — the decorator's span is the `current_span` at that
|
||||||
|
point, so the attributes land on the right span and the router export
|
||||||
|
dispatches to the correct Langfuse project.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from functools import wraps
|
||||||
|
from typing import Any, Awaitable, Callable, TypeVar
|
||||||
|
|
||||||
|
from opentelemetry import trace
|
||||||
|
from opentelemetry.context import Context
|
||||||
|
from opentelemetry.trace import Status, StatusCode
|
||||||
|
|
||||||
|
R = TypeVar("R")
|
||||||
|
|
||||||
|
_TRACER = trace.get_tracer("dograh.mcp")
|
||||||
|
# Langfuse truncates long payloads anyway; cap here to keep span size
|
||||||
|
# bounded. Tune up if you find tool outputs consistently clipped.
|
||||||
|
_MAX_ATTR_LEN = 8000
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_json(value: Any) -> str:
|
||||||
|
try:
|
||||||
|
return json.dumps(value, default=str, ensure_ascii=False)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def traced_tool(fn: Callable[..., Awaitable[R]]) -> Callable[..., Awaitable[R]]:
|
||||||
|
"""Wrap an MCP tool so each invocation produces a span.
|
||||||
|
|
||||||
|
Captures tool name, input kwargs, output, and exceptions. Stacks
|
||||||
|
below `@mcp.tool` so FastMCP sees the wrapped function when
|
||||||
|
introspecting the tool schema (`functools.wraps` preserves the
|
||||||
|
signature the framework reads).
|
||||||
|
"""
|
||||||
|
|
||||||
|
@wraps(fn)
|
||||||
|
async def wrapper(*args: Any, **kwargs: Any) -> R:
|
||||||
|
# Each MCP tool call is its own root trace. Passing an empty
|
||||||
|
# `Context()` severs the inherited parent so the span doesn't
|
||||||
|
# graft onto whatever other trace happens to be active (e.g.
|
||||||
|
# the FastAPI request span, or a client-propagated context).
|
||||||
|
# One trace per tool invocation makes Langfuse diffing and
|
||||||
|
# per-org filtering clean.
|
||||||
|
with _TRACER.start_as_current_span(
|
||||||
|
f"mcp.{fn.__name__}",
|
||||||
|
context=Context(),
|
||||||
|
) as span:
|
||||||
|
span.set_attribute("mcp.tool.name", fn.__name__)
|
||||||
|
# Explicit trace-name override so the Langfuse UI shows
|
||||||
|
# `mcp.<tool>` at the top of the trace instead of whatever
|
||||||
|
# the framework happens to name the root span.
|
||||||
|
span.set_attribute("langfuse.trace.name", f"mcp.{fn.__name__}")
|
||||||
|
span.set_attribute(
|
||||||
|
"langfuse.observation.input",
|
||||||
|
_safe_json(kwargs)[:_MAX_ATTR_LEN],
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = await fn(*args, **kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
span.record_exception(e)
|
||||||
|
span.set_status(Status(StatusCode.ERROR, str(e)))
|
||||||
|
raise
|
||||||
|
span.set_attribute(
|
||||||
|
"langfuse.observation.output",
|
||||||
|
_safe_json(result)[:_MAX_ATTR_LEN],
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
return wrapper
|
||||||
93
api/mcp_server/ts_bridge.py
Normal file
93
api/mcp_server/ts_bridge.py
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
"""Python-side bridge to the Node TS validator.
|
||||||
|
|
||||||
|
Spawns `node api/mcp_server/ts_validator/src/index.ts` as a short-lived
|
||||||
|
subprocess per call, streams a JSON request on stdin, reads a JSON
|
||||||
|
response from stdout. The validator never executes LLM code — it either
|
||||||
|
emits TypeScript from a workflow JSON (`generate`) or parses LLM-authored
|
||||||
|
TS back into a workflow JSON via AST walking (`parse`).
|
||||||
|
|
||||||
|
The subprocess startup cost is ~100-200ms per call. Fine for MCP tool
|
||||||
|
rates; if it ever matters, the validator can be promoted to a long-lived
|
||||||
|
worker over a unix socket without changing this interface.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from api.services.workflow.node_specs import all_specs
|
||||||
|
|
||||||
|
_VALIDATOR_ENTRY = Path(__file__).resolve().parent / "ts_validator" / "src" / "index.ts"
|
||||||
|
|
||||||
|
|
||||||
|
class TsBridgeError(Exception):
|
||||||
|
"""The Node subprocess failed before producing a JSON response."""
|
||||||
|
|
||||||
|
|
||||||
|
def _specs_payload() -> list[dict[str, Any]]:
|
||||||
|
return [s.model_dump(mode="json") for s in all_specs()]
|
||||||
|
|
||||||
|
|
||||||
|
async def _invoke(request: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"node",
|
||||||
|
str(_VALIDATOR_ENTRY),
|
||||||
|
stdin=asyncio.subprocess.PIPE,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
stdout, stderr = await proc.communicate(json.dumps(request).encode("utf-8"))
|
||||||
|
if proc.returncode != 0 and not stdout:
|
||||||
|
raise TsBridgeError(
|
||||||
|
f"ts_validator exited {proc.returncode}: "
|
||||||
|
f"{stderr.decode('utf-8', errors='replace')}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return json.loads(stdout.decode("utf-8"))
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise TsBridgeError(
|
||||||
|
f"ts_validator emitted non-JSON: {stdout!r} (stderr: {stderr!r})"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_code(workflow: dict[str, Any], *, workflow_name: str = "") -> str:
|
||||||
|
"""Emit SDK TypeScript source from a workflow JSON payload.
|
||||||
|
|
||||||
|
Raises `TsBridgeError` if the validator can't produce code (unknown
|
||||||
|
node type, dangling edge reference, etc.) — these are bugs at the
|
||||||
|
caller layer, not user input, so we fail loudly.
|
||||||
|
"""
|
||||||
|
result = await _invoke(
|
||||||
|
{
|
||||||
|
"command": "generate",
|
||||||
|
"workflow": workflow,
|
||||||
|
"specs": _specs_payload(),
|
||||||
|
"workflowName": workflow_name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if not result.get("ok"):
|
||||||
|
errs = result.get("errors") or [{"message": "unknown failure"}]
|
||||||
|
raise TsBridgeError(
|
||||||
|
"generate_code failed: " + "; ".join(e.get("message", "") for e in errs)
|
||||||
|
)
|
||||||
|
return result["code"]
|
||||||
|
|
||||||
|
|
||||||
|
async def parse_code(code: str) -> dict[str, Any]:
|
||||||
|
"""Parse LLM-authored TS back into a workflow JSON.
|
||||||
|
|
||||||
|
Returns the raw validator response — `{"ok": True, "workflow": {...}}`
|
||||||
|
on success, `{"ok": False, "stage": "parse" | "validate", "errors": [...]}`
|
||||||
|
on author-side failure. Author-side failures are surfaced to the LLM
|
||||||
|
verbatim so it can iterate; callers should not re-wrap them.
|
||||||
|
"""
|
||||||
|
return await _invoke(
|
||||||
|
{
|
||||||
|
"command": "parse",
|
||||||
|
"code": code,
|
||||||
|
"specs": _specs_payload(),
|
||||||
|
}
|
||||||
|
)
|
||||||
1
api/mcp_server/ts_validator/.gitignore
vendored
Normal file
1
api/mcp_server/ts_validator/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
node_modules/
|
||||||
31
api/mcp_server/ts_validator/package-lock.json
generated
Normal file
31
api/mcp_server/ts_validator/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
{
|
||||||
|
"name": "dograh-ts-validator",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "dograh-ts-validator",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"typescript": "^5.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
api/mcp_server/ts_validator/package.json
Normal file
13
api/mcp_server/ts_validator/package.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"name": "dograh-ts-validator",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Node helper invoked by the Python MCP server. Converts workflow JSON to SDK TypeScript code (generate) and parses LLM-authored TS back into a validated workflow JSON (parse). Runs as a short-lived subprocess over stdin/stdout.",
|
||||||
|
"dependencies": {
|
||||||
|
"typescript": "^5.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
304
api/mcp_server/ts_validator/src/generate.ts
Normal file
304
api/mcp_server/ts_validator/src/generate.ts
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
// JSON → TypeScript source. Emits flat code the LLM can read and edit:
|
||||||
|
// imports, a `Workflow` construction, one `addTyped` per node, one `edge`
|
||||||
|
// per edge. Variable names are derived from `data.name` (falling back to
|
||||||
|
// the node id) and deduplicated so the AST round-trips back through
|
||||||
|
// `parse.ts` into the same workflow JSON.
|
||||||
|
|
||||||
|
import type {
|
||||||
|
GenerateResult,
|
||||||
|
NodeSpec,
|
||||||
|
PropertySpec,
|
||||||
|
WireWorkflow,
|
||||||
|
} from "./types.ts";
|
||||||
|
|
||||||
|
export function generateCode(
|
||||||
|
workflow: WireWorkflow,
|
||||||
|
specs: NodeSpec[],
|
||||||
|
opts: { workflowName?: string } = {},
|
||||||
|
): GenerateResult {
|
||||||
|
const specByName = new Map(specs.map((s) => [s.name, s]));
|
||||||
|
|
||||||
|
// Catch unknown node types up-front — otherwise we'd emit an import
|
||||||
|
// line for a factory that doesn't exist.
|
||||||
|
for (const n of workflow.nodes) {
|
||||||
|
if (!specByName.has(n.type)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
message: `Unknown node type in workflow: "${n.type}"`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const factoryNames = [
|
||||||
|
...new Set(workflow.nodes.map((n) => n.type)),
|
||||||
|
].sort();
|
||||||
|
const nodeVarById = new Map<string, string>();
|
||||||
|
const usedNames = new Set<string>();
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(`import { Workflow } from "@dograh/sdk";`);
|
||||||
|
if (factoryNames.length > 0) {
|
||||||
|
lines.push(
|
||||||
|
`import { ${factoryNames.join(", ")} } from "@dograh/sdk/typed";`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
const wfName = opts.workflowName ?? "";
|
||||||
|
lines.push(
|
||||||
|
`const wf = new Workflow(${renderObject({ name: wfName }, 0)});`,
|
||||||
|
);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
for (const node of workflow.nodes) {
|
||||||
|
const varName = pickVarName(node, usedNames);
|
||||||
|
nodeVarById.set(node.id, varName);
|
||||||
|
|
||||||
|
const spec = specByName.get(node.type)!;
|
||||||
|
// Strip legacy/UI-state fields the spec doesn't know about
|
||||||
|
// (e.g. `invalid`, `selected`, `dragging`, `is_start`,
|
||||||
|
// `validationMessage`). They accumulated in stored workflow
|
||||||
|
// data before the parser enforced spec validation, and are
|
||||||
|
// pure noise from the LLM's perspective — dropping them keeps
|
||||||
|
// the editing surface clean and avoids a pointless save-time
|
||||||
|
// rejection round-trip.
|
||||||
|
const knownOnly = stripUnknown(node.data, spec);
|
||||||
|
const data = stripDefaults(knownOnly, spec);
|
||||||
|
const factoryArg = renderObject(data, 0);
|
||||||
|
|
||||||
|
// Positions are intentionally omitted — LLMs don't place nodes
|
||||||
|
// sensibly, so we let a downstream auto-layout pass (future
|
||||||
|
// enhancement) assign coordinates on save. Existing positions
|
||||||
|
// in the DB are preserved by `parse.ts` defaulting to {0,0}
|
||||||
|
// and the save path leaving pre-existing node positions alone.
|
||||||
|
lines.push(
|
||||||
|
`const ${varName} = wf.addTyped(${node.type}(${factoryArg}));`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workflow.edges.length > 0) {
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
for (const edge of workflow.edges) {
|
||||||
|
const src = nodeVarById.get(edge.source);
|
||||||
|
const tgt = nodeVarById.get(edge.target);
|
||||||
|
if (!src || !tgt) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
`Edge ${edge.id} references unknown node ` +
|
||||||
|
`(source=${edge.source}, target=${edge.target}).`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const cleanedEdge = pickEdgeFields(edge.data);
|
||||||
|
const edgeOpts = renderObject(cleanedEdge, 0);
|
||||||
|
lines.push(`wf.edge(${src}, ${tgt}, ${edgeOpts});`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, code: lines.join("\n") + "\n" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function pickVarName(
|
||||||
|
node: { id: string; data: Record<string, unknown> },
|
||||||
|
used: Set<string>,
|
||||||
|
): string {
|
||||||
|
const seed =
|
||||||
|
typeof node.data["name"] === "string" && node.data["name"].trim()
|
||||||
|
? (node.data["name"] as string)
|
||||||
|
: `node_${node.id}`;
|
||||||
|
const base = sanitizeIdentifier(seed);
|
||||||
|
let candidate = base;
|
||||||
|
let i = 2;
|
||||||
|
while (used.has(candidate) || RESERVED.has(candidate)) {
|
||||||
|
candidate = `${base}_${i++}`;
|
||||||
|
}
|
||||||
|
used.add(candidate);
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeIdentifier(raw: string): string {
|
||||||
|
const cleaned = raw
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-zA-Z0-9_]+/g, "_")
|
||||||
|
.replace(/^_+|_+$/g, "")
|
||||||
|
.toLowerCase();
|
||||||
|
if (!cleaned) return "node";
|
||||||
|
if (/^[0-9]/.test(cleaned)) return `n_${cleaned}`;
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RESERVED = new Set([
|
||||||
|
"wf",
|
||||||
|
"const",
|
||||||
|
"let",
|
||||||
|
"var",
|
||||||
|
"new",
|
||||||
|
"function",
|
||||||
|
"class",
|
||||||
|
"import",
|
||||||
|
"export",
|
||||||
|
"return",
|
||||||
|
"if",
|
||||||
|
"else",
|
||||||
|
"for",
|
||||||
|
"while",
|
||||||
|
"do",
|
||||||
|
"switch",
|
||||||
|
"case",
|
||||||
|
"break",
|
||||||
|
"continue",
|
||||||
|
"default",
|
||||||
|
"throw",
|
||||||
|
"try",
|
||||||
|
"catch",
|
||||||
|
"finally",
|
||||||
|
"await",
|
||||||
|
"async",
|
||||||
|
"true",
|
||||||
|
"false",
|
||||||
|
"null",
|
||||||
|
"undefined",
|
||||||
|
"this",
|
||||||
|
"super",
|
||||||
|
"in",
|
||||||
|
"of",
|
||||||
|
"typeof",
|
||||||
|
"instanceof",
|
||||||
|
"delete",
|
||||||
|
"void",
|
||||||
|
"yield",
|
||||||
|
"Workflow",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Drop keys not declared in the spec. Handles nested `fixed_collection`
|
||||||
|
// rows by recursing through sub-property specs. Anything that isn't in
|
||||||
|
// the spec is legacy/UI state and should never reach the LLM.
|
||||||
|
function stripUnknown(
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
spec: NodeSpec,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const known = new Map<string, PropertySpec>();
|
||||||
|
for (const p of spec.properties ?? []) known.set(p.name, p);
|
||||||
|
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(data)) {
|
||||||
|
const prop = known.get(k);
|
||||||
|
if (!prop) continue; // drop unknown
|
||||||
|
if (prop.type === "fixed_collection" && Array.isArray(v)) {
|
||||||
|
const rowSpec: NodeSpec = {
|
||||||
|
name: prop.name,
|
||||||
|
properties: prop.properties ?? [],
|
||||||
|
};
|
||||||
|
out[k] = v.map((row) =>
|
||||||
|
row && typeof row === "object" && !Array.isArray(row)
|
||||||
|
? stripUnknown(row as Record<string, unknown>, rowSpec)
|
||||||
|
: row,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
out[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edge schema is fixed (no NodeSpec for edges). Mirrors the allowed
|
||||||
|
// fields on `Workflow.edge(...)` in both SDKs.
|
||||||
|
const KNOWN_EDGE_FIELDS = new Set([
|
||||||
|
"label",
|
||||||
|
"condition",
|
||||||
|
"transition_speech",
|
||||||
|
"transition_speech_type",
|
||||||
|
"transition_speech_recording_id",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function pickEdgeFields(
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(data)) {
|
||||||
|
if (KNOWN_EDGE_FIELDS.has(k)) out[k] = v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop keys whose value equals the spec default — keeps emitted code tight.
|
||||||
|
function stripDefaults(
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
spec: NodeSpec,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
const defaults = new Map<string, unknown>();
|
||||||
|
for (const prop of spec.properties ?? []) {
|
||||||
|
if (prop.default !== undefined && prop.default !== null) {
|
||||||
|
defaults.set(prop.name, prop.default);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [k, v] of Object.entries(data)) {
|
||||||
|
if (defaults.has(k) && deepEqual(defaults.get(k), v)) continue;
|
||||||
|
out[k] = v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deepEqual(a: unknown, b: unknown): boolean {
|
||||||
|
if (a === b) return true;
|
||||||
|
if (typeof a !== typeof b) return false;
|
||||||
|
if (a === null || b === null) return false;
|
||||||
|
if (Array.isArray(a) && Array.isArray(b)) {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
return a.every((el, i) => deepEqual(el, b[i]));
|
||||||
|
}
|
||||||
|
if (typeof a === "object" && typeof b === "object") {
|
||||||
|
const ak = Object.keys(a as object).sort();
|
||||||
|
const bk = Object.keys(b as object).sort();
|
||||||
|
if (ak.length !== bk.length) return false;
|
||||||
|
if (ak.some((k, i) => k !== bk[i])) return false;
|
||||||
|
return ak.every((k) =>
|
||||||
|
deepEqual(
|
||||||
|
(a as Record<string, unknown>)[k],
|
||||||
|
(b as Record<string, unknown>)[k],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Object renderer biased for readability — strings use single-line JSON,
|
||||||
|
// nested objects/arrays indent one level per depth.
|
||||||
|
function renderObject(obj: Record<string, unknown>, depth: number): string {
|
||||||
|
const keys = Object.keys(obj);
|
||||||
|
if (keys.length === 0) return "{}";
|
||||||
|
const pad = " ".repeat(depth + 1);
|
||||||
|
const closingPad = " ".repeat(depth);
|
||||||
|
const parts = keys.map((k) => {
|
||||||
|
const v = renderValue(obj[k], depth + 1);
|
||||||
|
return `${pad}${k}: ${v}`;
|
||||||
|
});
|
||||||
|
return `{\n${parts.join(",\n")},\n${closingPad}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderValue(v: unknown, depth: number): string {
|
||||||
|
if (v === null || v === undefined) return "null";
|
||||||
|
if (typeof v === "string") return JSON.stringify(v);
|
||||||
|
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
if (v.length === 0) return "[]";
|
||||||
|
const pad = " ".repeat(depth + 1);
|
||||||
|
const closingPad = " ".repeat(depth);
|
||||||
|
const items = v.map((el) => `${pad}${renderValue(el, depth + 1)}`);
|
||||||
|
return `[\n${items.join(",\n")},\n${closingPad}]`;
|
||||||
|
}
|
||||||
|
if (typeof v === "object") {
|
||||||
|
return renderObject(v as Record<string, unknown>, depth);
|
||||||
|
}
|
||||||
|
return JSON.stringify(v);
|
||||||
|
}
|
||||||
74
api/mcp_server/ts_validator/src/index.ts
Normal file
74
api/mcp_server/ts_validator/src/index.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
// Stdin/stdout dispatch. Reads a single JSON request, routes to
|
||||||
|
// generate or parse, writes a single JSON response. Exits 0 on request
|
||||||
|
// success (including validation failures — those are in the JSON), and
|
||||||
|
// exits 1 only on internal errors (bad input JSON, unhandled exception).
|
||||||
|
|
||||||
|
import { generateCode } from "./generate.ts";
|
||||||
|
import { parseCode } from "./parse.ts";
|
||||||
|
import type { NodeSpec, WireWorkflow } from "./types.ts";
|
||||||
|
|
||||||
|
interface GenerateRequest {
|
||||||
|
command: "generate";
|
||||||
|
workflow: WireWorkflow;
|
||||||
|
specs: NodeSpec[];
|
||||||
|
workflowName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParseRequest {
|
||||||
|
command: "parse";
|
||||||
|
code: string;
|
||||||
|
specs: NodeSpec[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type Request = GenerateRequest | ParseRequest;
|
||||||
|
|
||||||
|
async function readStdin(): Promise<string> {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of process.stdin) {
|
||||||
|
chunks.push(chunk as Buffer);
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks).toString("utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeResult(payload: unknown): void {
|
||||||
|
process.stdout.write(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const input = await readStdin();
|
||||||
|
let req: Request;
|
||||||
|
try {
|
||||||
|
req = JSON.parse(input) as Request;
|
||||||
|
} catch (e) {
|
||||||
|
writeResult({
|
||||||
|
ok: false,
|
||||||
|
stage: "internal",
|
||||||
|
errors: [{ message: `Invalid JSON on stdin: ${(e as Error).message}` }],
|
||||||
|
});
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.command === "generate") {
|
||||||
|
writeResult(generateCode(req.workflow, req.specs, { workflowName: req.workflowName }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.command === "parse") {
|
||||||
|
writeResult(parseCode(req.code, req.specs));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writeResult({
|
||||||
|
ok: false,
|
||||||
|
stage: "internal",
|
||||||
|
errors: [{ message: `Unknown command: ${(req as { command?: unknown }).command}` }],
|
||||||
|
});
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err: unknown) => {
|
||||||
|
writeResult({
|
||||||
|
ok: false,
|
||||||
|
stage: "internal",
|
||||||
|
errors: [{ message: (err as Error).stack ?? String(err) }],
|
||||||
|
});
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
612
api/mcp_server/ts_validator/src/parse.ts
Normal file
612
api/mcp_server/ts_validator/src/parse.ts
Normal file
|
|
@ -0,0 +1,612 @@
|
||||||
|
// TypeScript → workflow JSON.
|
||||||
|
//
|
||||||
|
// Parses LLM-authored SDK code with the TypeScript compiler, walks the
|
||||||
|
// AST statement by statement, and builds up a workflow JSON from the
|
||||||
|
// recognized SDK patterns:
|
||||||
|
//
|
||||||
|
// const wf = new Workflow({ name: "..." });
|
||||||
|
// const X = wf.addTyped(startCall({ ...fields }));
|
||||||
|
// const Y = wf.add({ type: "endCall", ...fields });
|
||||||
|
// wf.edge(X, Y, { label: "...", condition: "..." });
|
||||||
|
//
|
||||||
|
// No code is executed. Any top-level statement that doesn't match one
|
||||||
|
// of the recognized shapes is a parse error with a file:line:col pointer
|
||||||
|
// so the LLM can iterate. Node data is validated against the spec
|
||||||
|
// catalog before returning.
|
||||||
|
|
||||||
|
import ts from "typescript";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
NodeSpec,
|
||||||
|
ParseErrorItem,
|
||||||
|
ParseResult,
|
||||||
|
PropertySpec,
|
||||||
|
WireEdge,
|
||||||
|
WireNode,
|
||||||
|
} from "./types.ts";
|
||||||
|
|
||||||
|
export function parseCode(code: string, specs: NodeSpec[]): ParseResult {
|
||||||
|
const specByName = new Map(specs.map((s) => [s.name, s]));
|
||||||
|
const sourceFile = ts.createSourceFile(
|
||||||
|
"workflow.ts",
|
||||||
|
code,
|
||||||
|
ts.ScriptTarget.ESNext,
|
||||||
|
true,
|
||||||
|
ts.ScriptKind.TS,
|
||||||
|
);
|
||||||
|
|
||||||
|
const errors: ParseErrorItem[] = [];
|
||||||
|
const nodes: WireNode[] = [];
|
||||||
|
const edges: WireEdge[] = [];
|
||||||
|
const nodeRefs = new Map<string, WireNode>();
|
||||||
|
let workflowVar: string | null = null;
|
||||||
|
let workflowName = "";
|
||||||
|
let nextId = 1;
|
||||||
|
|
||||||
|
const addError = (node: ts.Node, message: string): void => {
|
||||||
|
const pos = sourceFile.getLineAndCharacterOfPosition(node.getStart());
|
||||||
|
errors.push({
|
||||||
|
message,
|
||||||
|
line: pos.line + 1,
|
||||||
|
column: pos.character + 1,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const stmt of sourceFile.statements) {
|
||||||
|
if (ts.isImportDeclaration(stmt)) continue; // imports are harmless
|
||||||
|
if (
|
||||||
|
ts.isExportAssignment(stmt) ||
|
||||||
|
stmt.kind === ts.SyntaxKind.EmptyStatement
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `const X = ...;` or `wf.edge(...);`
|
||||||
|
if (ts.isVariableStatement(stmt)) {
|
||||||
|
handleVariableStatement(stmt);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ts.isExpressionStatement(stmt)) {
|
||||||
|
handleExpressionStatement(stmt);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
addError(
|
||||||
|
stmt,
|
||||||
|
`Only imports, \`const X = ...\` bindings, and \`wf.edge(...)\` calls are allowed at the top level. Found: ${ts.SyntaxKind[stmt.kind]}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVariableStatement(stmt: ts.VariableStatement): void {
|
||||||
|
const modifiers = ts.getModifiers(stmt);
|
||||||
|
if (modifiers && modifiers.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) {
|
||||||
|
addError(stmt, "`export` is not allowed on workflow bindings.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((stmt.declarationList.flags & ts.NodeFlags.Const) === 0) {
|
||||||
|
addError(stmt, "Use `const` for all bindings.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const decl of stmt.declarationList.declarations) {
|
||||||
|
if (!ts.isIdentifier(decl.name)) {
|
||||||
|
addError(decl, "Destructuring is not allowed — use a single identifier.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!decl.initializer) {
|
||||||
|
addError(decl, "Bindings must have an initializer.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const varName = decl.name.text;
|
||||||
|
handleBinding(varName, decl.initializer, decl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBinding(
|
||||||
|
varName: string,
|
||||||
|
initializer: ts.Expression,
|
||||||
|
origin: ts.Node,
|
||||||
|
): void {
|
||||||
|
const expr = unwrapAwait(initializer);
|
||||||
|
|
||||||
|
// `const wf = new Workflow({ name: "..." })`
|
||||||
|
if (ts.isNewExpression(expr)) {
|
||||||
|
if (!ts.isIdentifier(expr.expression) || expr.expression.text !== "Workflow") {
|
||||||
|
addError(origin, "Only `new Workflow(...)` is supported for object construction.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (workflowVar) {
|
||||||
|
addError(origin, `A Workflow is already bound (as \`${workflowVar}\`). Only one Workflow is allowed.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const args = expr.arguments ?? ts.factory.createNodeArray();
|
||||||
|
if (args.length > 0) {
|
||||||
|
const val = literalToJs(args[0]!, addError);
|
||||||
|
if (
|
||||||
|
val &&
|
||||||
|
typeof val === "object" &&
|
||||||
|
!Array.isArray(val) &&
|
||||||
|
typeof (val as Record<string, unknown>)["name"] === "string"
|
||||||
|
) {
|
||||||
|
workflowName = (val as Record<string, unknown>)["name"] as string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
workflowVar = varName;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `const X = wf.addTyped(factory({...}))` or `wf.add({ type: "...", ... })`
|
||||||
|
if (ts.isCallExpression(expr)) {
|
||||||
|
const call = expr;
|
||||||
|
const callee = call.expression;
|
||||||
|
|
||||||
|
// Must be `wf.XYZ(...)` — property access off the workflow var
|
||||||
|
if (
|
||||||
|
!ts.isPropertyAccessExpression(callee) ||
|
||||||
|
!ts.isIdentifier(callee.expression) ||
|
||||||
|
(workflowVar !== null && callee.expression.text !== workflowVar)
|
||||||
|
) {
|
||||||
|
addError(
|
||||||
|
origin,
|
||||||
|
`Expected \`${workflowVar ?? "wf"}.addTyped(...)\` or \`${workflowVar ?? "wf"}.add(...)\`.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!workflowVar) {
|
||||||
|
addError(origin, "Workflow must be constructed before adding nodes.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const method = callee.name.text;
|
||||||
|
if (method === "addTyped") {
|
||||||
|
handleAddTyped(varName, call, origin);
|
||||||
|
} else if (method === "add") {
|
||||||
|
handleAddGeneric(varName, call, origin);
|
||||||
|
} else {
|
||||||
|
addError(
|
||||||
|
origin,
|
||||||
|
`Unsupported method \`${method}\`. Use \`addTyped\` or \`add\`.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addError(
|
||||||
|
origin,
|
||||||
|
"Only `new Workflow(...)`, `wf.addTyped(...)`, and `wf.add(...)` are allowed as binding initializers.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddTyped(
|
||||||
|
varName: string,
|
||||||
|
call: ts.CallExpression,
|
||||||
|
origin: ts.Node,
|
||||||
|
): void {
|
||||||
|
if (call.arguments.length < 1 || call.arguments.length > 2) {
|
||||||
|
addError(origin, "`addTyped` takes 1 or 2 arguments.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const inner = call.arguments[0]!;
|
||||||
|
if (!ts.isCallExpression(inner) || !ts.isIdentifier(inner.expression)) {
|
||||||
|
addError(
|
||||||
|
origin,
|
||||||
|
"`addTyped` must be called with a factory invocation, e.g. `addTyped(startCall({ ... }))`.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const factoryName = inner.expression.text;
|
||||||
|
if (!specByName.has(factoryName)) {
|
||||||
|
addError(
|
||||||
|
origin,
|
||||||
|
`Unknown node type: \`${factoryName}\`. Check the list of registered node types.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const factoryArgs = inner.arguments;
|
||||||
|
let data: Record<string, unknown> = {};
|
||||||
|
if (factoryArgs.length > 0) {
|
||||||
|
const parsed = literalToJs(factoryArgs[0]!, addError);
|
||||||
|
if (parsed !== undefined) {
|
||||||
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||||
|
addError(inner, "Factory argument must be an object literal.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
data = parsed as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Optional position arg
|
||||||
|
const position = extractPositionArg(call.arguments[1], addError);
|
||||||
|
bindNode(varName, factoryName, data, position, origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddGeneric(
|
||||||
|
varName: string,
|
||||||
|
call: ts.CallExpression,
|
||||||
|
origin: ts.Node,
|
||||||
|
): void {
|
||||||
|
if (call.arguments.length !== 1) {
|
||||||
|
addError(origin, "`add` takes exactly 1 object argument.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = literalToJs(call.arguments[0]!, addError);
|
||||||
|
if (parsed === undefined) return;
|
||||||
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||||
|
addError(origin, "`add` argument must be an object literal.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const obj = parsed as Record<string, unknown>;
|
||||||
|
const typeValue = obj["type"];
|
||||||
|
if (typeof typeValue !== "string") {
|
||||||
|
addError(origin, "`add({ type, ... })` requires a string `type` field.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!specByName.has(typeValue)) {
|
||||||
|
addError(origin, `Unknown node type: \`${typeValue}\`.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let position: { x: number; y: number } | undefined;
|
||||||
|
if (obj["position"] !== undefined) {
|
||||||
|
const p = obj["position"];
|
||||||
|
if (
|
||||||
|
Array.isArray(p) &&
|
||||||
|
p.length === 2 &&
|
||||||
|
typeof p[0] === "number" &&
|
||||||
|
typeof p[1] === "number"
|
||||||
|
) {
|
||||||
|
position = { x: p[0], y: p[1] };
|
||||||
|
} else {
|
||||||
|
addError(
|
||||||
|
origin,
|
||||||
|
"`position` must be a [x, y] tuple of numbers.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { type: _ignored, position: _ignored2, ...rest } = obj;
|
||||||
|
bindNode(varName, typeValue, rest, position, origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindNode(
|
||||||
|
varName: string,
|
||||||
|
type: string,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
position: { x: number; y: number } | undefined,
|
||||||
|
origin: ts.Node,
|
||||||
|
): void {
|
||||||
|
if (nodeRefs.has(varName)) {
|
||||||
|
addError(origin, `Variable \`${varName}\` is already bound.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const node: WireNode = {
|
||||||
|
id: String(nextId++),
|
||||||
|
type,
|
||||||
|
position: position ?? { x: 0, y: 0 },
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
nodes.push(node);
|
||||||
|
nodeRefs.set(varName, node);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleExpressionStatement(stmt: ts.ExpressionStatement): void {
|
||||||
|
const expr = unwrapAwait(stmt.expression);
|
||||||
|
if (!ts.isCallExpression(expr)) {
|
||||||
|
addError(stmt, "Only `wf.edge(...)` calls are allowed as bare statements.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const callee = expr.expression;
|
||||||
|
if (
|
||||||
|
!ts.isPropertyAccessExpression(callee) ||
|
||||||
|
!ts.isIdentifier(callee.expression) ||
|
||||||
|
(workflowVar !== null && callee.expression.text !== workflowVar) ||
|
||||||
|
callee.name.text !== "edge"
|
||||||
|
) {
|
||||||
|
addError(stmt, "Only `wf.edge(source, target, opts)` is allowed as a bare statement.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (expr.arguments.length !== 3) {
|
||||||
|
addError(stmt, "`edge` takes exactly 3 arguments: (source, target, opts).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [srcArg, tgtArg, optsArg] = expr.arguments;
|
||||||
|
if (!ts.isIdentifier(srcArg!) || !ts.isIdentifier(tgtArg!)) {
|
||||||
|
addError(stmt, "`edge` source and target must be variable identifiers bound by `addTyped`/`add`.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const src = nodeRefs.get(srcArg.text);
|
||||||
|
const tgt = nodeRefs.get(tgtArg.text);
|
||||||
|
if (!src) {
|
||||||
|
addError(srcArg, `Unknown node variable: \`${srcArg.text}\`.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!tgt) {
|
||||||
|
addError(tgtArg, `Unknown node variable: \`${tgtArg.text}\`.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const opts = literalToJs(optsArg!, addError);
|
||||||
|
if (opts === undefined) return;
|
||||||
|
if (typeof opts !== "object" || opts === null || Array.isArray(opts)) {
|
||||||
|
addError(stmt, "`edge` options must be an object literal.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const optsObj = opts as Record<string, unknown>;
|
||||||
|
if (typeof optsObj["label"] !== "string" || (optsObj["label"] as string).trim() === "") {
|
||||||
|
addError(stmt, "`edge` requires a non-empty `label` string.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof optsObj["condition"] !== "string" || (optsObj["condition"] as string).trim() === "") {
|
||||||
|
addError(stmt, "`edge` requires a non-empty `condition` string.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
edges.push({
|
||||||
|
id: `${src.id}-${tgt.id}`,
|
||||||
|
source: src.id,
|
||||||
|
target: tgt.id,
|
||||||
|
data: optsObj,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── terminate early on parse errors ──────────────────────────────
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return { ok: false, stage: "parse", errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!workflowVar) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
stage: "parse",
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"No Workflow construction found. Expected `const wf = new Workflow({ name: \"...\" });`.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── spec-driven node validation ─────────────────────────────────
|
||||||
|
const validationErrors: ParseErrorItem[] = [];
|
||||||
|
for (const node of nodes) {
|
||||||
|
const spec = specByName.get(node.type)!;
|
||||||
|
const validated = validateNodeData(
|
||||||
|
spec,
|
||||||
|
node.data,
|
||||||
|
(msg) => validationErrors.push({ message: `[${node.type}] ${msg}` }),
|
||||||
|
);
|
||||||
|
if (validated !== null) node.data = validated;
|
||||||
|
}
|
||||||
|
if (validationErrors.length > 0) {
|
||||||
|
return { ok: false, stage: "validate", errors: validationErrors };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
workflow: {
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
viewport: { x: 0, y: 0, zoom: 1 },
|
||||||
|
},
|
||||||
|
workflowName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function unwrapAwait(expr: ts.Expression): ts.Expression {
|
||||||
|
return ts.isAwaitExpression(expr) ? expr.expression : expr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPositionArg(
|
||||||
|
arg: ts.Expression | undefined,
|
||||||
|
addError: (n: ts.Node, m: string) => void,
|
||||||
|
): { x: number; y: number } | undefined {
|
||||||
|
if (!arg) return undefined;
|
||||||
|
const parsed = literalToJs(arg, addError);
|
||||||
|
if (parsed === undefined || parsed === null) return undefined;
|
||||||
|
if (
|
||||||
|
typeof parsed === "object" &&
|
||||||
|
!Array.isArray(parsed) &&
|
||||||
|
Array.isArray((parsed as Record<string, unknown>)["position"])
|
||||||
|
) {
|
||||||
|
const p = (parsed as Record<string, unknown>)["position"] as unknown[];
|
||||||
|
if (p.length === 2 && typeof p[0] === "number" && typeof p[1] === "number") {
|
||||||
|
return { x: p[0], y: p[1] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addError(arg, "Optional second arg must be `{ position: [x, y] }`.");
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert an expression to a plain JS value. Accepts: string, number,
|
||||||
|
// boolean, null, undefined (→ undefined), array/object literals of the
|
||||||
|
// same. Rejects any expression with runtime semantics (identifiers other
|
||||||
|
// than `true/false/null/undefined`, function calls, arrow fns, etc.).
|
||||||
|
function literalToJs(
|
||||||
|
expr: ts.Expression,
|
||||||
|
addError: (n: ts.Node, m: string) => void,
|
||||||
|
): unknown {
|
||||||
|
if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) {
|
||||||
|
return expr.text;
|
||||||
|
}
|
||||||
|
if (ts.isNumericLiteral(expr)) return Number(expr.text);
|
||||||
|
if (expr.kind === ts.SyntaxKind.TrueKeyword) return true;
|
||||||
|
if (expr.kind === ts.SyntaxKind.FalseKeyword) return false;
|
||||||
|
if (expr.kind === ts.SyntaxKind.NullKeyword) return null;
|
||||||
|
if (ts.isIdentifier(expr) && expr.text === "undefined") return undefined;
|
||||||
|
if (ts.isPrefixUnaryExpression(expr)) {
|
||||||
|
if (expr.operator === ts.SyntaxKind.MinusToken) {
|
||||||
|
const inner = literalToJs(expr.operand, addError);
|
||||||
|
if (typeof inner === "number") return -inner;
|
||||||
|
}
|
||||||
|
if (expr.operator === ts.SyntaxKind.PlusToken) {
|
||||||
|
const inner = literalToJs(expr.operand, addError);
|
||||||
|
if (typeof inner === "number") return inner;
|
||||||
|
}
|
||||||
|
addError(expr, "Unsupported unary operator; only numeric negation is allowed.");
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (ts.isArrayLiteralExpression(expr)) {
|
||||||
|
const out: unknown[] = [];
|
||||||
|
for (const el of expr.elements) {
|
||||||
|
if (el.kind === ts.SyntaxKind.OmittedExpression) {
|
||||||
|
addError(el, "Sparse arrays are not allowed.");
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (ts.isSpreadElement(el)) {
|
||||||
|
addError(el, "Spread elements are not allowed in array literals.");
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const v = literalToJs(el, addError);
|
||||||
|
if (v === undefined && el.kind !== ts.SyntaxKind.Identifier) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
out.push(v);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (ts.isObjectLiteralExpression(expr)) {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const prop of expr.properties) {
|
||||||
|
if (!ts.isPropertyAssignment(prop)) {
|
||||||
|
addError(prop, "Only plain `key: value` properties are allowed (no methods, shorthand, spread, or computed keys).");
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
let key: string;
|
||||||
|
if (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) {
|
||||||
|
key = prop.name.text;
|
||||||
|
} else {
|
||||||
|
addError(prop.name, "Property keys must be identifiers or string literals.");
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const val = literalToJs(prop.initializer, addError);
|
||||||
|
if (val === undefined && prop.initializer.kind !== ts.SyntaxKind.Identifier) {
|
||||||
|
// treat explicit `undefined` as omission
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out[key] = val;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (ts.isTemplateExpression(expr)) {
|
||||||
|
addError(expr, "Template literals with interpolation are not allowed — use plain strings.");
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
addError(expr, `Unsupported expression (${ts.SyntaxKind[expr.kind]}). Only literals are allowed in data positions.`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spec-driven validation, mirrors the shape of
|
||||||
|
// `sdk/python/src/dograh_sdk/_validation.py` but lightweight — applies
|
||||||
|
// defaults for missing optionals, catches unknown keys, enforces `options`
|
||||||
|
// membership, and type-shapes the scalar and `fixed_collection` cases.
|
||||||
|
function validateNodeData(
|
||||||
|
spec: NodeSpec,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
addError: (message: string) => void,
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
const known = new Map<string, PropertySpec>();
|
||||||
|
for (const p of spec.properties ?? []) known.set(p.name, p);
|
||||||
|
|
||||||
|
for (const key of Object.keys(data)) {
|
||||||
|
if (!known.has(key)) {
|
||||||
|
addError(`Unknown field: \`${key}\`.`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, prop] of known) {
|
||||||
|
if (key in data) {
|
||||||
|
out[key] = data[key];
|
||||||
|
} else if (prop.default !== undefined && prop.default !== null) {
|
||||||
|
out[key] = prop.default;
|
||||||
|
} else if (prop.required) {
|
||||||
|
addError(`Missing required field: \`${key}\`.`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, prop] of known) {
|
||||||
|
if (!(key in out)) continue;
|
||||||
|
const value = out[key];
|
||||||
|
const err = checkPropertyShape(prop, value);
|
||||||
|
if (err) {
|
||||||
|
addError(`Field \`${key}\`: ${err}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkPropertyShape(prop: PropertySpec, value: unknown): string | null {
|
||||||
|
switch (prop.type) {
|
||||||
|
case "string":
|
||||||
|
case "mention_textarea":
|
||||||
|
case "url":
|
||||||
|
case "recording_ref":
|
||||||
|
case "credential_ref":
|
||||||
|
if (typeof value !== "string") return `expected string, got ${jsTypeOf(value)}.`;
|
||||||
|
return null;
|
||||||
|
case "number":
|
||||||
|
if (typeof value !== "number") return `expected number, got ${jsTypeOf(value)}.`;
|
||||||
|
return null;
|
||||||
|
case "boolean":
|
||||||
|
if (typeof value !== "boolean") return `expected boolean, got ${jsTypeOf(value)}.`;
|
||||||
|
return null;
|
||||||
|
case "tool_refs":
|
||||||
|
case "document_refs":
|
||||||
|
case "multi_options":
|
||||||
|
if (!Array.isArray(value)) return `expected array, got ${jsTypeOf(value)}.`;
|
||||||
|
for (const el of value) {
|
||||||
|
if (prop.type === "multi_options") {
|
||||||
|
if (!isInOptions(prop, el)) {
|
||||||
|
return `value \`${JSON.stringify(el)}\` is not in the allowed options.`;
|
||||||
|
}
|
||||||
|
} else if (typeof el !== "string") {
|
||||||
|
return `array elements must be strings.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
case "options":
|
||||||
|
if (!isInOptions(prop, value)) {
|
||||||
|
return `value \`${JSON.stringify(value)}\` is not in the allowed options.`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
case "json":
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||||
|
return `expected JSON object, got ${jsTypeOf(value)}.`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
case "fixed_collection":
|
||||||
|
if (!Array.isArray(value)) return `expected array of rows, got ${jsTypeOf(value)}.`;
|
||||||
|
for (let i = 0; i < value.length; i++) {
|
||||||
|
const row = value[i];
|
||||||
|
if (typeof row !== "object" || row === null || Array.isArray(row)) {
|
||||||
|
return `row ${i}: expected object, got ${jsTypeOf(row)}.`;
|
||||||
|
}
|
||||||
|
for (const sub of prop.properties ?? []) {
|
||||||
|
const subVal = (row as Record<string, unknown>)[sub.name];
|
||||||
|
if (subVal === undefined) {
|
||||||
|
if (sub.required && (sub.default === undefined || sub.default === null)) {
|
||||||
|
return `row ${i}: missing required field \`${sub.name}\`.`;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const subErr = checkPropertyShape(sub, subVal);
|
||||||
|
if (subErr) return `row ${i}, \`${sub.name}\`: ${subErr}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
default:
|
||||||
|
return null; // Unknown types pass — forward compat.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInOptions(prop: PropertySpec, value: unknown): boolean {
|
||||||
|
if (!prop.options) return true;
|
||||||
|
return prop.options.some((o) => o.value === value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsTypeOf(v: unknown): string {
|
||||||
|
if (v === null) return "null";
|
||||||
|
if (Array.isArray(v)) return "array";
|
||||||
|
return typeof v;
|
||||||
|
}
|
||||||
57
api/mcp_server/ts_validator/src/types.ts
Normal file
57
api/mcp_server/ts_validator/src/types.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
// Shared shapes used by both generate and parse. Mirror the `ReactFlowDTO`
|
||||||
|
// wire format on the Python side (`api/services/workflow/dto.py`) and the
|
||||||
|
// node-spec JSON served by `/api/v1/node-types` / dumped by
|
||||||
|
// `python -m api.services.workflow.node_specs`.
|
||||||
|
|
||||||
|
export interface PropertyOption {
|
||||||
|
value: string | number | boolean;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PropertySpec {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
required?: boolean;
|
||||||
|
default?: unknown;
|
||||||
|
options?: PropertyOption[];
|
||||||
|
properties?: PropertySpec[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeSpec {
|
||||||
|
name: string;
|
||||||
|
properties: PropertySpec[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WireNode {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
position: { x: number; y: number };
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WireEdge {
|
||||||
|
id: string;
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WireWorkflow {
|
||||||
|
nodes: WireNode[];
|
||||||
|
edges: WireEdge[];
|
||||||
|
viewport: { x: number; y: number; zoom: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParseErrorItem {
|
||||||
|
message: string;
|
||||||
|
line?: number;
|
||||||
|
column?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GenerateResult =
|
||||||
|
| { ok: true; code: string }
|
||||||
|
| { ok: false; errors: ParseErrorItem[] };
|
||||||
|
|
||||||
|
export type ParseResult =
|
||||||
|
| { ok: true; workflow: WireWorkflow; workflowName: string }
|
||||||
|
| { ok: false; stage: "parse" | "validate"; errors: ParseErrorItem[] };
|
||||||
14
api/mcp_server/ts_validator/tsconfig.json
Normal file
14
api/mcp_server/ts_validator/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"isolatedModules": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,7 @@ testpaths = tests
|
||||||
python_files = test_*.py *_test.py
|
python_files = test_*.py *_test.py
|
||||||
python_classes = Test*
|
python_classes = Test*
|
||||||
python_functions = test_*
|
python_functions = test_*
|
||||||
addopts = -v --tb=short -s
|
addopts = -v --tb=short -s --import-mode=importlib
|
||||||
markers =
|
markers =
|
||||||
asyncio: mark test as an async test
|
asyncio: mark test as an async test
|
||||||
slow: mark test as slow running
|
slow: mark test as slow running
|
||||||
|
|
@ -4,4 +4,6 @@ pytest==8.3.5
|
||||||
pytest-asyncio==0.26.0
|
pytest-asyncio==0.26.0
|
||||||
pre-commit==4.2.0
|
pre-commit==4.2.0
|
||||||
watchfiles==1.1.0
|
watchfiles==1.1.0
|
||||||
python-dotenv==1.2.1
|
python-dotenv==1.2.1
|
||||||
|
datamodel-code-generator==0.56.1
|
||||||
|
-e ./sdk/python
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,8 @@ python-multipart==0.0.20
|
||||||
sentry-sdk[fastapi]==2.38.0
|
sentry-sdk[fastapi]==2.38.0
|
||||||
sqlalchemy[asyncio]==2.0.43
|
sqlalchemy[asyncio]==2.0.43
|
||||||
msgpack==1.1.2
|
msgpack==1.1.2
|
||||||
docling[rapidocr]==2.68.0
|
|
||||||
pgvector==0.4.2
|
pgvector==0.4.2
|
||||||
bcrypt==5.0.0
|
bcrypt==5.0.0
|
||||||
email-validator==2.3.0
|
email-validator==2.3.0
|
||||||
posthog==7.11.1
|
posthog==7.11.1
|
||||||
fastmcp==3.2.4
|
fastmcp==3.2.4
|
||||||
rank-bm25==0.2.2
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from pydantic import BaseModel
|
||||||
from api.db import db_client
|
from api.db import db_client
|
||||||
from api.db.models import UserModel
|
from api.db.models import UserModel
|
||||||
from api.enums import WebhookCredentialType
|
from api.enums import WebhookCredentialType
|
||||||
|
from api.sdk_expose import sdk_expose
|
||||||
from api.services.auth.depends import get_user
|
from api.services.auth.depends import get_user
|
||||||
|
|
||||||
router = APIRouter(prefix="/credentials")
|
router = APIRouter(prefix="/credentials")
|
||||||
|
|
@ -107,7 +108,13 @@ def build_credential_response(credential) -> CredentialResponse:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
@router.get(
|
||||||
|
"/",
|
||||||
|
**sdk_expose(
|
||||||
|
method="list_credentials",
|
||||||
|
description="List webhook credentials available to the authenticated organization.",
|
||||||
|
),
|
||||||
|
)
|
||||||
async def list_credentials(
|
async def list_credentials(
|
||||||
user: UserModel = Depends(get_user),
|
user: UserModel = Depends(get_user),
|
||||||
) -> List[CredentialResponse]:
|
) -> List[CredentialResponse]:
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from api.schemas.knowledge_base import (
|
||||||
DocumentUploadResponseSchema,
|
DocumentUploadResponseSchema,
|
||||||
ProcessDocumentRequestSchema,
|
ProcessDocumentRequestSchema,
|
||||||
)
|
)
|
||||||
|
from api.sdk_expose import sdk_expose
|
||||||
from api.services.auth.depends import get_user
|
from api.services.auth.depends import get_user
|
||||||
from api.services.posthog_client import capture_event
|
from api.services.posthog_client import capture_event
|
||||||
from api.services.storage import storage_fs
|
from api.services.storage import storage_fs
|
||||||
|
|
@ -135,6 +136,7 @@ async def process_document(
|
||||||
document.id,
|
document.id,
|
||||||
request.s3_key,
|
request.s3_key,
|
||||||
user.selected_organization_id,
|
user.selected_organization_id,
|
||||||
|
str(user.provider_id),
|
||||||
128, # max_tokens (default)
|
128, # max_tokens (default)
|
||||||
request.retrieval_mode,
|
request.retrieval_mode,
|
||||||
)
|
)
|
||||||
|
|
@ -190,6 +192,10 @@ async def process_document(
|
||||||
"/documents",
|
"/documents",
|
||||||
response_model=DocumentListResponseSchema,
|
response_model=DocumentListResponseSchema,
|
||||||
summary="List documents",
|
summary="List documents",
|
||||||
|
**sdk_expose(
|
||||||
|
method="list_documents",
|
||||||
|
description="List knowledge base documents available to the authenticated organization.",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
async def list_documents(
|
async def list_documents(
|
||||||
status: Annotated[
|
status: Annotated[
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from api.routes.credentials import router as credentials_router
|
||||||
from api.routes.integration import router as integration_router
|
from api.routes.integration import router as integration_router
|
||||||
from api.routes.knowledge_base import router as knowledge_base_router
|
from api.routes.knowledge_base import router as knowledge_base_router
|
||||||
from api.routes.looptalk import router as looptalk_router
|
from api.routes.looptalk import router as looptalk_router
|
||||||
|
from api.routes.node_types import router as node_types_router
|
||||||
from api.routes.organization import router as organization_router
|
from api.routes.organization import router as organization_router
|
||||||
from api.routes.organization_usage import router as organization_usage_router
|
from api.routes.organization_usage import router as organization_usage_router
|
||||||
from api.routes.public_agent import router as public_agent_router
|
from api.routes.public_agent import router as public_agent_router
|
||||||
|
|
@ -54,6 +55,7 @@ router.include_router(workflow_embed_router)
|
||||||
router.include_router(knowledge_base_router)
|
router.include_router(knowledge_base_router)
|
||||||
router.include_router(workflow_recording_router)
|
router.include_router(workflow_recording_router)
|
||||||
router.include_router(auth_router)
|
router.include_router(auth_router)
|
||||||
|
router.include_router(node_types_router)
|
||||||
|
|
||||||
|
|
||||||
class HealthResponse(BaseModel):
|
class HealthResponse(BaseModel):
|
||||||
|
|
|
||||||
67
api/routes/node_types.py
Normal file
67
api/routes/node_types.py
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
"""API for the node-spec catalog.
|
||||||
|
|
||||||
|
Exposes the registered NodeSpecs (one per node type) so frontend renderers
|
||||||
|
and the LLM SDK can build forms / typed constructors from a single source
|
||||||
|
of truth.
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
GET /node-types → list every registered NodeSpec
|
||||||
|
GET /node-types/{name} → single NodeSpec by name
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from api.db.models import UserModel
|
||||||
|
from api.sdk_expose import sdk_expose
|
||||||
|
from api.services.auth.depends import get_user
|
||||||
|
from api.services.workflow.node_specs import (
|
||||||
|
SPEC_VERSION,
|
||||||
|
NodeSpec,
|
||||||
|
all_specs,
|
||||||
|
get_spec,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/node-types")
|
||||||
|
|
||||||
|
|
||||||
|
class NodeTypesResponse(BaseModel):
|
||||||
|
spec_version: str
|
||||||
|
node_types: list[NodeSpec]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"",
|
||||||
|
response_model=NodeTypesResponse,
|
||||||
|
**sdk_expose(
|
||||||
|
method="list_node_types",
|
||||||
|
description="List every registered node type with its spec. Pinned to spec_version.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def list_node_types(
|
||||||
|
_user: UserModel = Depends(get_user),
|
||||||
|
) -> NodeTypesResponse:
|
||||||
|
"""List every registered NodeSpec.
|
||||||
|
|
||||||
|
SDK clients should pin to `spec_version` and warn if the server reports
|
||||||
|
a higher version than what they were generated against.
|
||||||
|
"""
|
||||||
|
return NodeTypesResponse(spec_version=SPEC_VERSION, node_types=all_specs())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{name}",
|
||||||
|
response_model=NodeSpec,
|
||||||
|
**sdk_expose(
|
||||||
|
method="get_node_type",
|
||||||
|
description="Fetch a single node spec by name.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def get_node_type(
|
||||||
|
name: str,
|
||||||
|
_user: UserModel = Depends(get_user),
|
||||||
|
) -> NodeSpec:
|
||||||
|
spec = get_spec(name)
|
||||||
|
if spec is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Unknown node type: {name!r}")
|
||||||
|
return spec
|
||||||
|
|
@ -29,6 +29,7 @@ from api.db.workflow_client import WorkflowClient
|
||||||
from api.db.workflow_run_client import WorkflowRunClient
|
from api.db.workflow_run_client import WorkflowRunClient
|
||||||
from api.enums import CallType, OrganizationConfigurationKey, WorkflowRunState
|
from api.enums import CallType, OrganizationConfigurationKey, WorkflowRunState
|
||||||
from api.errors.telephony_errors import TelephonyError
|
from api.errors.telephony_errors import TelephonyError
|
||||||
|
from api.sdk_expose import sdk_expose
|
||||||
from api.services.auth.depends import get_user
|
from api.services.auth.depends import get_user
|
||||||
from api.services.campaign.campaign_call_dispatcher import campaign_call_dispatcher
|
from api.services.campaign.campaign_call_dispatcher import campaign_call_dispatcher
|
||||||
from api.services.campaign.campaign_event_publisher import get_campaign_event_publisher
|
from api.services.campaign.campaign_event_publisher import get_campaign_event_publisher
|
||||||
|
|
@ -139,7 +140,13 @@ class StatusCallbackRequest(BaseModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/initiate-call")
|
@router.post(
|
||||||
|
"/initiate-call",
|
||||||
|
**sdk_expose(
|
||||||
|
method="test_phone_call",
|
||||||
|
description="Place a test call from a workflow to a phone number.",
|
||||||
|
),
|
||||||
|
)
|
||||||
async def initiate_call(
|
async def initiate_call(
|
||||||
request: InitiateCallRequest, user: UserModel = Depends(get_user)
|
request: InitiateCallRequest, user: UserModel = Depends(get_user)
|
||||||
):
|
):
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ from pydantic import BaseModel, Field, field_validator
|
||||||
from api.db import db_client
|
from api.db import db_client
|
||||||
from api.db.models import UserModel
|
from api.db.models import UserModel
|
||||||
from api.enums import PostHogEvent, ToolCategory, ToolStatus
|
from api.enums import PostHogEvent, ToolCategory, ToolStatus
|
||||||
|
from api.sdk_expose import sdk_expose
|
||||||
from api.services.auth.depends import get_user
|
from api.services.auth.depends import get_user
|
||||||
from api.services.posthog_client import capture_event
|
from api.services.posthog_client import capture_event
|
||||||
|
|
||||||
|
|
@ -276,7 +277,13 @@ def validate_status(status: str) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
@router.get(
|
||||||
|
"/",
|
||||||
|
**sdk_expose(
|
||||||
|
method="list_tools",
|
||||||
|
description="List tools available to the authenticated organization.",
|
||||||
|
),
|
||||||
|
)
|
||||||
async def list_tools(
|
async def list_tools(
|
||||||
status: Optional[str] = None,
|
status: Optional[str] = None,
|
||||||
category: Optional[str] = None,
|
category: Optional[str] = None,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ from api.db.models import UserModel
|
||||||
from api.db.workflow_template_client import WorkflowTemplateClient
|
from api.db.workflow_template_client import WorkflowTemplateClient
|
||||||
from api.enums import CallType, PostHogEvent, StorageBackend
|
from api.enums import CallType, PostHogEvent, StorageBackend
|
||||||
from api.schemas.workflow import WorkflowRunResponseSchema
|
from api.schemas.workflow import WorkflowRunResponseSchema
|
||||||
|
from api.sdk_expose import sdk_expose
|
||||||
from api.services.auth.depends import get_user
|
from api.services.auth.depends import get_user
|
||||||
from api.services.campaign.report import generate_workflow_report_csv
|
from api.services.campaign.report import generate_workflow_report_csv
|
||||||
from api.services.configuration.check_validity import UserConfigurationValidator
|
from api.services.configuration.check_validity import UserConfigurationValidator
|
||||||
|
|
@ -27,7 +28,7 @@ from api.services.configuration.resolve import resolve_effective_config
|
||||||
from api.services.mps_service_key_client import mps_service_key_client
|
from api.services.mps_service_key_client import mps_service_key_client
|
||||||
from api.services.posthog_client import capture_event
|
from api.services.posthog_client import capture_event
|
||||||
from api.services.storage import storage_fs
|
from api.services.storage import storage_fs
|
||||||
from api.services.workflow.dto import ReactFlowDTO
|
from api.services.workflow.dto import ReactFlowDTO, sanitize_workflow_definition
|
||||||
from api.services.workflow.duplicate import duplicate_workflow
|
from api.services.workflow.duplicate import duplicate_workflow
|
||||||
from api.services.workflow.errors import ItemKind, WorkflowError
|
from api.services.workflow.errors import ItemKind, WorkflowError
|
||||||
from api.services.workflow.workflow import WorkflowGraph
|
from api.services.workflow.workflow import WorkflowGraph
|
||||||
|
|
@ -453,7 +454,13 @@ async def get_workflow_count(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/fetch")
|
@router.get(
|
||||||
|
"/fetch",
|
||||||
|
**sdk_expose(
|
||||||
|
method="list_workflows",
|
||||||
|
description="List all workflows in the authenticated organization.",
|
||||||
|
),
|
||||||
|
)
|
||||||
async def get_workflows(
|
async def get_workflows(
|
||||||
user: UserModel = Depends(get_user),
|
user: UserModel = Depends(get_user),
|
||||||
status: Optional[str] = Query(
|
status: Optional[str] = Query(
|
||||||
|
|
@ -499,7 +506,13 @@ async def get_workflows(
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/fetch/{workflow_id}")
|
@router.get(
|
||||||
|
"/fetch/{workflow_id}",
|
||||||
|
**sdk_expose(
|
||||||
|
method="get_workflow",
|
||||||
|
description="Get a single workflow by ID (returns draft if one exists, else published).",
|
||||||
|
),
|
||||||
|
)
|
||||||
async def get_workflow(
|
async def get_workflow(
|
||||||
workflow_id: int,
|
workflow_id: int,
|
||||||
user: UserModel = Depends(get_user),
|
user: UserModel = Depends(get_user),
|
||||||
|
|
@ -701,7 +714,13 @@ async def update_workflow_status(
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{workflow_id}")
|
@router.put(
|
||||||
|
"/{workflow_id}",
|
||||||
|
**sdk_expose(
|
||||||
|
method="update_workflow",
|
||||||
|
description="Update a workflow's name and/or definition. Saves as a new draft.",
|
||||||
|
),
|
||||||
|
)
|
||||||
async def update_workflow(
|
async def update_workflow(
|
||||||
workflow_id: int,
|
workflow_id: int,
|
||||||
request: UpdateWorkflowRequest,
|
request: UpdateWorkflowRequest,
|
||||||
|
|
@ -721,8 +740,10 @@ async def update_workflow(
|
||||||
HTTPException: If the workflow is not found or if there's a database error
|
HTTPException: If the workflow is not found or if there's a database error
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Restore real API keys where the incoming definition has masked placeholders
|
# Strip UI runtime-only fields (invalid, validationMessage, etc.) from
|
||||||
workflow_definition = request.workflow_definition
|
# node.data / edge.data before anything touches the DB — the UI sends
|
||||||
|
# nodes wholesale from the React Flow store, which carries those.
|
||||||
|
workflow_definition = sanitize_workflow_definition(request.workflow_definition)
|
||||||
if workflow_definition:
|
if workflow_definition:
|
||||||
existing_workflow = await db_client.get_workflow(
|
existing_workflow = await db_client.get_workflow(
|
||||||
workflow_id, organization_id=user.selected_organization_id
|
workflow_id, organization_id=user.selected_organization_id
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ from api.schemas.workflow_recording import (
|
||||||
RecordingUpdateRequestSchema,
|
RecordingUpdateRequestSchema,
|
||||||
RecordingUploadResponseSchema,
|
RecordingUploadResponseSchema,
|
||||||
)
|
)
|
||||||
|
from api.sdk_expose import sdk_expose
|
||||||
from api.services.auth.depends import get_user
|
from api.services.auth.depends import get_user
|
||||||
from api.services.mps_service_key_client import mps_service_key_client
|
from api.services.mps_service_key_client import mps_service_key_client
|
||||||
from api.services.storage import storage_fs
|
from api.services.storage import storage_fs
|
||||||
|
|
@ -165,6 +166,10 @@ async def create_recordings(
|
||||||
"/",
|
"/",
|
||||||
response_model=RecordingListResponseSchema,
|
response_model=RecordingListResponseSchema,
|
||||||
summary="List recordings",
|
summary="List recordings",
|
||||||
|
**sdk_expose(
|
||||||
|
method="list_recordings",
|
||||||
|
description="List workflow recordings available to the authenticated organization.",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
async def list_recordings(
|
async def list_recordings(
|
||||||
workflow_id: Annotated[
|
workflow_id: Annotated[
|
||||||
|
|
|
||||||
38
api/sdk_expose.py
Normal file
38
api/sdk_expose.py
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
"""Opt-in marker for exposing a FastAPI route through the Dograh SDK.
|
||||||
|
|
||||||
|
The generated SDK client (`sdk/python/src/dograh_sdk/_generated_client.py`
|
||||||
|
and the TypeScript equivalent) is built by walking the backend's OpenAPI
|
||||||
|
schema and picking up any operation tagged with `x-sdk-method`. That
|
||||||
|
means `generate_sdk.sh` stays in sync with the real HTTP paths — no more
|
||||||
|
hand-typed URL strings drifting out of date.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
from api.sdk_expose import sdk_expose
|
||||||
|
|
||||||
|
@router.post("/initiate-call", **sdk_expose(
|
||||||
|
method="test_phone_call",
|
||||||
|
description="Place a test call from a workflow to a phone number.",
|
||||||
|
))
|
||||||
|
async def initiate_call(...): ...
|
||||||
|
|
||||||
|
Anything not wrapped in `sdk_expose` is invisible to the SDK — deliberate,
|
||||||
|
so the SDK surface stays small and auditable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def sdk_expose(*, method: str, description: str = "") -> dict[str, Any]:
|
||||||
|
"""Return FastAPI route kwargs that tag the operation for SDK codegen.
|
||||||
|
|
||||||
|
`method` becomes the SDK method name in both Python and TypeScript
|
||||||
|
(converted to snake_case / camelCase as appropriate by the codegen).
|
||||||
|
`description` is emitted as the method docstring.
|
||||||
|
"""
|
||||||
|
extra: dict[str, Any] = {"x-sdk-method": method}
|
||||||
|
if description:
|
||||||
|
extra["x-sdk-description"] = description
|
||||||
|
return {"openapi_extra": extra}
|
||||||
|
|
@ -1,35 +1,22 @@
|
||||||
"""OpenAI embedding service.
|
"""OpenAI embedding service.
|
||||||
|
|
||||||
This module provides document processing capabilities using:
|
Embeds text and performs vector similarity search via the local database.
|
||||||
- OpenAI's text-embedding-3-small for embeddings (1536 dimensions)
|
Document conversion and chunking now live in the Model Proxy Service (MPS);
|
||||||
- Docling for document conversion and chunking
|
this file no longer pulls docling/transformers.
|
||||||
- pgvector for vector similarity search
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict, List, Optional
|
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 loguru import logger
|
||||||
from openai import AsyncOpenAI
|
from openai import AsyncOpenAI
|
||||||
from transformers import AutoTokenizer
|
|
||||||
|
|
||||||
from api.db.db_client import DBClient
|
from api.db.db_client import DBClient
|
||||||
from api.db.models import KnowledgeBaseChunkModel
|
|
||||||
|
|
||||||
from .base import BaseEmbeddingService
|
from .base import BaseEmbeddingService
|
||||||
|
|
||||||
# Model configuration
|
|
||||||
DEFAULT_MODEL_ID = "text-embedding-3-small"
|
DEFAULT_MODEL_ID = "text-embedding-3-small"
|
||||||
EMBEDDING_DIMENSION = 1536 # Dimension for 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):
|
class EmbeddingAPIKeyNotConfiguredError(Exception):
|
||||||
"""Raised when OpenAI API key is not configured for embeddings."""
|
"""Raised when OpenAI API key is not configured for embeddings."""
|
||||||
|
|
@ -49,24 +36,20 @@ class OpenAIEmbeddingService(BaseEmbeddingService):
|
||||||
db_client: DBClient,
|
db_client: DBClient,
|
||||||
api_key: Optional[str] = None,
|
api_key: Optional[str] = None,
|
||||||
model_id: str = DEFAULT_MODEL_ID,
|
model_id: str = DEFAULT_MODEL_ID,
|
||||||
max_tokens: int = 512,
|
|
||||||
base_url: Optional[str] = None,
|
base_url: Optional[str] = None,
|
||||||
):
|
):
|
||||||
"""Initialize the OpenAI embedding service.
|
"""Initialize the OpenAI embedding service.
|
||||||
|
|
||||||
Args:
|
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
|
api_key: OpenAI API key. If not provided, the client will not be
|
||||||
initialized and operations will fail with a clear error.
|
initialized and operations will fail with a clear error.
|
||||||
model_id: OpenAI embedding model ID (default: text-embedding-3-small)
|
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).
|
||||||
base_url: Optional base URL for the API (e.g. for OpenRouter)
|
|
||||||
"""
|
"""
|
||||||
self.db = db_client
|
self.db = db_client
|
||||||
self.model_id = model_id
|
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)
|
self._api_key_configured = bool(api_key)
|
||||||
if self._api_key_configured:
|
if self._api_key_configured:
|
||||||
client_kwargs = {"api_key": api_key}
|
client_kwargs = {"api_key": api_key}
|
||||||
|
|
@ -81,35 +64,6 @@ class OpenAIEmbeddingService(BaseEmbeddingService):
|
||||||
"Operations will fail until API key is configured in Model Configurations."
|
"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:
|
def get_model_id(self) -> str:
|
||||||
"""Return the model identifier."""
|
"""Return the model identifier."""
|
||||||
return self.model_id
|
return self.model_id
|
||||||
|
|
@ -126,28 +80,17 @@ class OpenAIEmbeddingService(BaseEmbeddingService):
|
||||||
async def embed_texts(self, texts: List[str]) -> List[List[float]]:
|
async def embed_texts(self, texts: List[str]) -> List[List[float]]:
|
||||||
"""Embed a batch of texts using OpenAI API.
|
"""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:
|
Raises:
|
||||||
EmbeddingAPIKeyNotConfiguredError: If API key is not configured
|
EmbeddingAPIKeyNotConfiguredError: If API key is not configured.
|
||||||
"""
|
"""
|
||||||
self._ensure_api_key_configured()
|
self._ensure_api_key_configured()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# OpenAI API call
|
|
||||||
response = await self.client.embeddings.create(
|
response = await self.client.embeddings.create(
|
||||||
input=texts,
|
input=texts,
|
||||||
model=self.model_id,
|
model=self.model_id,
|
||||||
)
|
)
|
||||||
|
return [item.embedding for item in response.data]
|
||||||
# Extract embeddings from response
|
|
||||||
embeddings = [item.embedding for item in response.data]
|
|
||||||
return embeddings
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error generating OpenAI embeddings: {e}")
|
logger.error(f"Error generating OpenAI embeddings: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
@ -155,14 +98,8 @@ class OpenAIEmbeddingService(BaseEmbeddingService):
|
||||||
async def embed_query(self, query: str) -> List[float]:
|
async def embed_query(self, query: str) -> List[float]:
|
||||||
"""Embed a single query text using OpenAI API.
|
"""Embed a single query text using OpenAI API.
|
||||||
|
|
||||||
Args:
|
|
||||||
query: Query text to embed
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Embedding vector as list of floats
|
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
EmbeddingAPIKeyNotConfiguredError: If API key is not configured
|
EmbeddingAPIKeyNotConfiguredError: If API key is not configured.
|
||||||
"""
|
"""
|
||||||
self._ensure_api_key_configured()
|
self._ensure_api_key_configured()
|
||||||
embeddings = await self.embed_texts([query])
|
embeddings = await self.embed_texts([query])
|
||||||
|
|
@ -177,201 +114,17 @@ class OpenAIEmbeddingService(BaseEmbeddingService):
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""Search for similar chunks using vector similarity.
|
"""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:
|
Raises:
|
||||||
EmbeddingAPIKeyNotConfiguredError: If API key is not configured
|
EmbeddingAPIKeyNotConfiguredError: If API key is not configured.
|
||||||
"""
|
"""
|
||||||
self._ensure_api_key_configured()
|
self._ensure_api_key_configured()
|
||||||
|
|
||||||
# Generate query embedding
|
|
||||||
query_embedding = await self.embed_query(query)
|
query_embedding = await self.embed_query(query)
|
||||||
|
|
||||||
# Perform vector similarity search
|
return await self.db.search_similar_chunks(
|
||||||
results = await self.db.search_similar_chunks(
|
|
||||||
query_embedding=query_embedding,
|
query_embedding=query_embedding,
|
||||||
organization_id=organization_id,
|
organization_id=organization_id,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
document_uuids=document_uuids,
|
document_uuids=document_uuids,
|
||||||
embedding_model=self.model_id,
|
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,
|
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(
|
async def call_workflow_api(
|
||||||
self,
|
self,
|
||||||
call_type: str,
|
call_type: str,
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,7 @@ async def play_audio(
|
||||||
queue_frame: Callable[[Frame], Awaitable[None]],
|
queue_frame: Callable[[Frame], Awaitable[None]],
|
||||||
transcript: Optional[str] = None,
|
transcript: Optional[str] = None,
|
||||||
append_to_context: bool = False,
|
append_to_context: bool = False,
|
||||||
|
persist_to_logs: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Play raw PCM-16 audio once.
|
"""Play raw PCM-16 audio once.
|
||||||
|
|
||||||
|
|
@ -115,6 +116,8 @@ async def play_audio(
|
||||||
transcript: Optional transcript of the recording.
|
transcript: Optional transcript of the recording.
|
||||||
append_to_context: Whether the transcript should be appended to
|
append_to_context: Whether the transcript should be appended to
|
||||||
the LLM assistant context. Defaults to False.
|
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())
|
context_id = str(uuid.uuid4())
|
||||||
await queue_frame(TTSStartedFrame(context_id=context_id))
|
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
|
text=transcript, aggregated_by="recording", context_id=context_id
|
||||||
)
|
)
|
||||||
tts_text.append_to_context = append_to_context
|
tts_text.append_to_context = append_to_context
|
||||||
|
tts_text.persist_to_logs = persist_to_logs
|
||||||
await queue_frame(tts_text)
|
await queue_frame(tts_text)
|
||||||
await queue_frame(
|
await queue_frame(
|
||||||
TTSAudioRawFrame(
|
TTSAudioRawFrame(
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ from pipecat.frames.frames import (
|
||||||
MetricsFrame,
|
MetricsFrame,
|
||||||
StopFrame,
|
StopFrame,
|
||||||
TranscriptionFrame,
|
TranscriptionFrame,
|
||||||
|
TTSSpeakFrame,
|
||||||
TTSTextFrame,
|
TTSTextFrame,
|
||||||
UserMuteStartedFrame,
|
UserMuteStartedFrame,
|
||||||
UserMuteStoppedFrame,
|
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
|
# 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):
|
elif isinstance(frame, TTSTextFrame):
|
||||||
message = {
|
message = {
|
||||||
"type": RealtimeFeedbackType.BOT_TEXT.value,
|
"type": RealtimeFeedbackType.BOT_TEXT.value,
|
||||||
|
|
@ -249,6 +264,9 @@ class RealtimeFeedbackObserver(BaseObserver):
|
||||||
|
|
||||||
await self._ensure_clock_task()
|
await self._ensure_clock_task()
|
||||||
await self._clock_queue.put((frame.pts, frame.id, message))
|
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:
|
else:
|
||||||
# No pts, send immediately
|
# No pts, send immediately
|
||||||
await self._send_ws(message)
|
await self._send_ws(message)
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,14 @@ class _OrgRoutingExporter(SpanExporter):
|
||||||
org_buckets = {}
|
org_buckets = {}
|
||||||
|
|
||||||
for span in spans:
|
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
|
org_id = span.attributes.get("dograh.org_id") if span.attributes else None
|
||||||
if org_id and str(org_id) in self._org_exporters:
|
if org_id and str(org_id) in self._org_exporters:
|
||||||
org_buckets.setdefault(str(org_id), []).append(span)
|
org_buckets.setdefault(str(org_id), []).append(span)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from enum import Enum
|
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
|
from pydantic import BaseModel, Field, ValidationError, model_validator
|
||||||
|
|
||||||
|
|
@ -42,17 +42,48 @@ class RetryConfigDTO(BaseModel):
|
||||||
retry_delay_seconds: int = 5
|
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)
|
name: str = Field(..., min_length=1)
|
||||||
prompt: Optional[str] = Field(default=None)
|
|
||||||
is_static: bool = False
|
|
||||||
is_start: bool = False
|
is_start: bool = False
|
||||||
is_end: bool = False
|
is_end: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class _PromptedNodeDataMixin(BaseModel):
|
||||||
|
prompt: Optional[str] = Field(default=None)
|
||||||
|
is_static: bool = False
|
||||||
allow_interrupt: bool = False
|
allow_interrupt: bool = False
|
||||||
|
add_global_prompt: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class _ExtractionNodeDataMixin(BaseModel):
|
||||||
extraction_enabled: bool = False
|
extraction_enabled: bool = False
|
||||||
extraction_prompt: Optional[str] = None
|
extraction_prompt: Optional[str] = None
|
||||||
extraction_variables: Optional[list[ExtractionVariableDTO]] = 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: Optional[str] = None
|
||||||
greeting_type: Optional[str] = None # 'text' or 'audio'
|
greeting_type: Optional[str] = None # 'text' or 'audio'
|
||||||
greeting_recording_id: Optional[str] = None
|
greeting_recording_id: Optional[str] = None
|
||||||
|
|
@ -61,14 +92,38 @@ class NodeDataDTO(BaseModel):
|
||||||
detect_voicemail: bool = False
|
detect_voicemail: bool = False
|
||||||
delayed_start: bool = False
|
delayed_start: bool = False
|
||||||
delayed_start_duration: Optional[float] = None
|
delayed_start_duration: Optional[float] = None
|
||||||
# Pre-call fetch (start node only)
|
|
||||||
pre_call_fetch_enabled: bool = False
|
pre_call_fetch_enabled: bool = False
|
||||||
pre_call_fetch_url: Optional[str] = None
|
pre_call_fetch_url: Optional[str] = None
|
||||||
pre_call_fetch_credential_uuid: 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
|
trigger_path: Optional[str] = None
|
||||||
# Webhook node specific fields
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookNodeData(_NodeDataBase):
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
http_method: Optional[str] = None
|
http_method: Optional[str] = None
|
||||||
endpoint_url: Optional[str] = None
|
endpoint_url: Optional[str] = None
|
||||||
|
|
@ -76,30 +131,129 @@ class NodeDataDTO(BaseModel):
|
||||||
custom_headers: Optional[list[CustomHeaderDTO]] = None
|
custom_headers: Optional[list[CustomHeaderDTO]] = None
|
||||||
payload_template: Optional[dict] = None
|
payload_template: Optional[dict] = None
|
||||||
retry_config: Optional[RetryConfigDTO] = None
|
retry_config: Optional[RetryConfigDTO] = None
|
||||||
# QA node specific fields
|
|
||||||
|
|
||||||
|
class QANodeData(_NodeDataBase):
|
||||||
qa_enabled: bool = True
|
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_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_min_call_duration: int = 15
|
||||||
qa_voicemail_calls: bool = False
|
qa_voicemail_calls: bool = False
|
||||||
qa_sample_rate: int = 100
|
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
|
id: str
|
||||||
type: NodeType = Field(default=NodeType.agentNode)
|
|
||||||
position: Position
|
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")
|
@model_validator(mode="after")
|
||||||
def _validate_prompt_required(self):
|
def _validate(self):
|
||||||
"""Require prompt for all node types except trigger, webhook, and qa."""
|
_require_prompt(self.data, "start")
|
||||||
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")
|
|
||||||
return self
|
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):
|
class EdgeDataDTO(BaseModel):
|
||||||
label: str = Field(..., min_length=1)
|
label: str = Field(..., min_length=1)
|
||||||
condition: str = Field(..., min_length=1)
|
condition: str = Field(..., min_length=1)
|
||||||
|
|
@ -144,3 +298,60 @@ class ReactFlowDTO(BaseModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
return self
|
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,
|
else 16000,
|
||||||
queue_frame=self._transport_output.queue_frame,
|
queue_frame=self._transport_output.queue_frame,
|
||||||
transcript=result.transcript,
|
transcript=result.transcript,
|
||||||
|
persist_to_logs=True,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|
@ -252,7 +253,11 @@ class PipecatEngine:
|
||||||
logger.info(f"Playing transition speech: {transition_speech}")
|
logger.info(f"Playing transition speech: {transition_speech}")
|
||||||
self._queued_speech_mute_state = "waiting"
|
self._queued_speech_mute_state = "waiting"
|
||||||
await self.task.queue_frame(
|
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
|
# Set context for the new node, so that when the function call result
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,7 @@ class CustomToolManager:
|
||||||
else 16000,
|
else 16000,
|
||||||
queue_frame=self._engine._transport_output.queue_frame,
|
queue_frame=self._engine._transport_output.queue_frame,
|
||||||
transcript=result.transcript,
|
transcript=result.transcript,
|
||||||
|
persist_to_logs=True,
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
|
|
@ -110,7 +111,11 @@ class CustomToolManager:
|
||||||
custom_message = config.get("customMessage", "")
|
custom_message = config.get("customMessage", "")
|
||||||
if custom_message:
|
if custom_message:
|
||||||
await self._engine.task.queue_frame(
|
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
|
return True
|
||||||
|
|
||||||
|
|
@ -311,6 +316,7 @@ class CustomToolManager:
|
||||||
else 16000,
|
else 16000,
|
||||||
queue_frame=self._engine._transport_output.queue_frame,
|
queue_frame=self._engine._transport_output.queue_frame,
|
||||||
transcript=result.transcript,
|
transcript=result.transcript,
|
||||||
|
persist_to_logs=True,
|
||||||
)
|
)
|
||||||
elif custom_message:
|
elif custom_message:
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -318,7 +324,11 @@ class CustomToolManager:
|
||||||
)
|
)
|
||||||
self._engine._queued_speech_mute_state = "waiting"
|
self._engine._queued_speech_mute_state = "waiting"
|
||||||
await self._engine.task.queue_frame(
|
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(
|
result = await execute_http_tool(
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from loguru import logger
|
||||||
from api.db.models import WorkflowRunModel
|
from api.db.models import WorkflowRunModel
|
||||||
from api.services.gen_ai.json_parser import parse_llm_json
|
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.pipecat.service_factory import create_llm_service_from_provider
|
||||||
|
from api.services.workflow.dto import QANodeData
|
||||||
from api.services.workflow.qa.conversation import (
|
from api.services.workflow.qa.conversation import (
|
||||||
build_conversation_structure,
|
build_conversation_structure,
|
||||||
format_transcript,
|
format_transcript,
|
||||||
|
|
@ -77,7 +78,7 @@ async def _generate_conversation_summary(
|
||||||
|
|
||||||
|
|
||||||
async def run_per_node_qa_analysis(
|
async def run_per_node_qa_analysis(
|
||||||
qa_node_data: dict[str, Any],
|
qa_data: QANodeData,
|
||||||
workflow_run: WorkflowRunModel,
|
workflow_run: WorkflowRunModel,
|
||||||
workflow_run_id: int,
|
workflow_run_id: int,
|
||||||
workflow_definition: dict,
|
workflow_definition: dict,
|
||||||
|
|
@ -106,18 +107,16 @@ async def run_per_node_qa_analysis(
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Events lack node_id for run {workflow_run_id}, falling back to whole-call QA"
|
f"Events lack node_id for run {workflow_run_id}, falling back to whole-call QA"
|
||||||
)
|
)
|
||||||
return await _run_whole_call_qa_analysis(
|
return await _run_whole_call_qa_analysis(qa_data, workflow_run, workflow_run_id)
|
||||||
qa_node_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:
|
if not system_prompt:
|
||||||
logger.warning("No system prompt defined for QA Node")
|
logger.warning("No system prompt defined for QA Node")
|
||||||
return {"error": "no_system_prompt", "node_results": {}}
|
return {"error": "no_system_prompt", "node_results": {}}
|
||||||
|
|
||||||
# Resolve LLM config
|
# Resolve LLM config
|
||||||
provider, model, api_key, service_kwargs = await 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:
|
if not api_key:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|
@ -127,7 +126,7 @@ async def run_per_node_qa_analysis(
|
||||||
|
|
||||||
# Ensure node summaries
|
# Ensure node summaries
|
||||||
node_summaries = await 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
|
# Set up Langfuse tracing
|
||||||
|
|
@ -228,7 +227,7 @@ async def run_per_node_qa_analysis(
|
||||||
|
|
||||||
|
|
||||||
async def _run_whole_call_qa_analysis(
|
async def _run_whole_call_qa_analysis(
|
||||||
qa_node_data: dict[str, Any],
|
qa_data: QANodeData,
|
||||||
workflow_run: WorkflowRunModel,
|
workflow_run: WorkflowRunModel,
|
||||||
workflow_run_id: int,
|
workflow_run_id: int,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
|
@ -254,13 +253,13 @@ async def _run_whole_call_qa_analysis(
|
||||||
metrics = compute_call_metrics(rtf_events, call_duration)
|
metrics = compute_call_metrics(rtf_events, call_duration)
|
||||||
|
|
||||||
# Resolve LLM config
|
# Resolve LLM config
|
||||||
system_prompt = qa_node_data.get("qa_system_prompt", "")
|
system_prompt = qa_data.qa_system_prompt or ""
|
||||||
if not system_prompt:
|
if not system_prompt:
|
||||||
logger.warning("No system prompt defined for QA Node")
|
logger.warning("No system prompt defined for QA Node")
|
||||||
return {"error": "no_system_prompt", "node_results": {}}
|
return {"error": "no_system_prompt", "node_results": {}}
|
||||||
|
|
||||||
provider, model, api_key, service_kwargs = await 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:
|
if not api_key:
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,11 @@ import random
|
||||||
|
|
||||||
from api.db import db_client
|
from api.db import db_client
|
||||||
from api.db.models import WorkflowRunModel
|
from api.db.models import WorkflowRunModel
|
||||||
|
from api.services.workflow.dto import QANodeData
|
||||||
|
|
||||||
|
|
||||||
async def resolve_llm_config(
|
async def resolve_llm_config(
|
||||||
qa_node_data: dict, workflow_run: WorkflowRunModel
|
qa_data: QANodeData, workflow_run: WorkflowRunModel
|
||||||
) -> tuple[str, str, str, dict]:
|
) -> tuple[str, str, str, dict]:
|
||||||
"""Resolve the LLM provider, model, API key, and extra kwargs for QA analysis.
|
"""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
|
(provider, model, api_key, service_kwargs) tuple — service_kwargs can be
|
||||||
passed directly to create_llm_service_from_provider as keyword arguments.
|
passed directly to create_llm_service_from_provider as keyword arguments.
|
||||||
"""
|
"""
|
||||||
if not qa_node_data.get("qa_use_workflow_llm", True):
|
if not qa_data.qa_use_workflow_llm:
|
||||||
provider = qa_node_data.get("qa_provider", "openai")
|
provider = qa_data.qa_provider or "openai"
|
||||||
kwargs = {}
|
kwargs = {}
|
||||||
if provider == "azure":
|
if provider == "azure":
|
||||||
kwargs["endpoint"] = qa_node_data.get("qa_endpoint", "")
|
kwargs["endpoint"] = qa_data.qa_endpoint or ""
|
||||||
return (
|
return (
|
||||||
provider,
|
provider,
|
||||||
qa_node_data.get("qa_model"),
|
qa_data.qa_model,
|
||||||
qa_node_data.get("qa_api_key"),
|
qa_data.qa_api_key,
|
||||||
kwargs,
|
kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fall back to user's configured LLM
|
# Fall back to user's configured LLM
|
||||||
provider, model, api_key, kwargs = await resolve_user_llm_config(workflow_run)
|
provider, model, api_key, kwargs = await resolve_user_llm_config(workflow_run)
|
||||||
|
|
||||||
qa_model = qa_node_data.get("qa_model", "default")
|
if qa_data.qa_model and qa_data.qa_model != "default":
|
||||||
if qa_model and qa_model != "default":
|
model = qa_data.qa_model
|
||||||
model = qa_model
|
|
||||||
|
|
||||||
return provider, model, api_key, kwargs
|
return provider, model, api_key, kwargs
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from loguru import logger
|
||||||
from api.db import db_client
|
from api.db import db_client
|
||||||
from api.db.models import WorkflowRunModel
|
from api.db.models import WorkflowRunModel
|
||||||
from api.services.pipecat.service_factory import create_llm_service_from_provider
|
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.llm_config import resolve_llm_config
|
||||||
from api.services.workflow.qa.tracing import create_node_summary_trace
|
from api.services.workflow.qa.tracing import create_node_summary_trace
|
||||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||||
|
|
@ -48,7 +48,7 @@ async def ensure_node_summaries(
|
||||||
workflow_definition: dict,
|
workflow_definition: dict,
|
||||||
definition_id: int | None,
|
definition_id: int | None,
|
||||||
workflow_run: WorkflowRunModel,
|
workflow_run: WorkflowRunModel,
|
||||||
qa_node_data: dict,
|
qa_data: QANodeData,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Ensure every agentNode/startCall node has a summary in the definition.
|
"""Ensure every agentNode/startCall node has a summary in the definition.
|
||||||
|
|
||||||
|
|
@ -69,7 +69,7 @@ async def ensure_node_summaries(
|
||||||
return existing_summaries
|
return existing_summaries
|
||||||
|
|
||||||
provider, model, api_key, service_kwargs = await 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:
|
if not api_key:
|
||||||
logger.warning("No API key for node summary generation, skipping")
|
logger.warning("No API key for node summary generation, skipping")
|
||||||
|
|
|
||||||
|
|
@ -242,7 +242,6 @@ async def _perform_retrieval(
|
||||||
|
|
||||||
embedding_service = OpenAIEmbeddingService(
|
embedding_service = OpenAIEmbeddingService(
|
||||||
db_client=db_client,
|
db_client=db_client,
|
||||||
max_tokens=128,
|
|
||||||
api_key=embeddings_api_key,
|
api_key=embeddings_api_key,
|
||||||
model_id=embeddings_model or "text-embedding-3-small",
|
model_id=embeddings_model or "text-embedding-3-small",
|
||||||
base_url=embeddings_base_url,
|
base_url=embeddings_base_url,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import re
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from typing import Dict, List, Set
|
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
|
from api.services.workflow.errors import ItemKind, WorkflowError
|
||||||
|
|
||||||
# Regex for matching {{ variable }} template placeholders.
|
# Regex for matching {{ variable }} template placeholders.
|
||||||
|
|
@ -61,32 +61,38 @@ class Edge:
|
||||||
|
|
||||||
|
|
||||||
class Node:
|
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.id, self.node_type, self.data = id, node_type, data
|
||||||
self.out: Dict[str, "Node"] = {} # forward nodes
|
self.out: Dict[str, "Node"] = {} # forward nodes
|
||||||
self.out_edges: List[Edge] = [] # forward edges with properties
|
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.name = data.name
|
||||||
self.prompt = data.prompt
|
|
||||||
self.is_static = data.is_static
|
|
||||||
self.is_start = data.is_start
|
self.is_start = data.is_start
|
||||||
self.is_end = data.is_end
|
self.is_end = data.is_end
|
||||||
self.allow_interrupt = data.allow_interrupt
|
|
||||||
self.extraction_enabled = data.extraction_enabled
|
# Type-specific fields — read with getattr so this works for every
|
||||||
self.extraction_prompt = data.extraction_prompt
|
# node variant in the discriminated union.
|
||||||
self.extraction_variables = data.extraction_variables
|
self.prompt = getattr(data, "prompt", None)
|
||||||
self.add_global_prompt = data.add_global_prompt
|
self.is_static = getattr(data, "is_static", False)
|
||||||
self.greeting = data.greeting
|
self.allow_interrupt = getattr(data, "allow_interrupt", False)
|
||||||
self.greeting_type = data.greeting_type
|
self.extraction_enabled = getattr(data, "extraction_enabled", False)
|
||||||
self.greeting_recording_id = data.greeting_recording_id
|
self.extraction_prompt = getattr(data, "extraction_prompt", None)
|
||||||
self.detect_voicemail = data.detect_voicemail
|
self.extraction_variables = getattr(data, "extraction_variables", None)
|
||||||
self.delayed_start = data.delayed_start
|
self.add_global_prompt = getattr(data, "add_global_prompt", True)
|
||||||
self.delayed_start_duration = data.delayed_start_duration
|
self.greeting = getattr(data, "greeting", None)
|
||||||
self.tool_uuids = data.tool_uuids
|
self.greeting_type = getattr(data, "greeting_type", None)
|
||||||
self.document_uuids = data.document_uuids
|
self.greeting_recording_id = getattr(data, "greeting_recording_id", None)
|
||||||
self.pre_call_fetch_enabled = data.pre_call_fetch_enabled
|
self.detect_voicemail = getattr(data, "detect_voicemail", False)
|
||||||
self.pre_call_fetch_url = data.pre_call_fetch_url
|
self.delayed_start = getattr(data, "delayed_start", False)
|
||||||
self.pre_call_fetch_credential_uuid = data.pre_call_fetch_credential_uuid
|
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
|
self.data = data
|
||||||
|
|
||||||
|
|
@ -98,9 +104,11 @@ class WorkflowGraph:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, dto: ReactFlowDTO):
|
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] = {
|
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
|
# Store all edges
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,22 @@
|
||||||
"""ARQ background task for processing knowledge base documents."""
|
"""ARQ background task for processing knowledge base documents.
|
||||||
|
|
||||||
|
Document conversion and chunking live in the Model Proxy Service (MPS);
|
||||||
|
this task downloads the file from S3, calls MPS, then handles the embedding
|
||||||
|
and DB writes locally.
|
||||||
|
"""
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
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 loguru import logger
|
||||||
from transformers import AutoTokenizer
|
|
||||||
|
|
||||||
from api.db import db_client
|
from api.db import db_client
|
||||||
from api.db.models import KnowledgeBaseChunkModel
|
from api.db.models import KnowledgeBaseChunkModel
|
||||||
from api.services.gen_ai import OpenAIEmbeddingService
|
from api.services.gen_ai import OpenAIEmbeddingService
|
||||||
|
from api.services.mps_service_key_client import mps_service_key_client
|
||||||
from api.services.storage import storage_fs
|
from api.services.storage import storage_fs
|
||||||
|
|
||||||
# For tokenization/chunking
|
MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024
|
||||||
TOKENIZER_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
|
|
||||||
|
|
||||||
|
|
||||||
async def process_knowledge_base_document(
|
async def process_knowledge_base_document(
|
||||||
|
|
@ -24,93 +24,84 @@ async def process_knowledge_base_document(
|
||||||
document_id: int,
|
document_id: int,
|
||||||
s3_key: str,
|
s3_key: str,
|
||||||
organization_id: int,
|
organization_id: int,
|
||||||
|
created_by_provider_id: str,
|
||||||
max_tokens: int = 128,
|
max_tokens: int = 128,
|
||||||
retrieval_mode: str = "chunked",
|
retrieval_mode: str = "chunked",
|
||||||
):
|
):
|
||||||
"""Process a knowledge base document: download, chunk, embed, and store.
|
"""Process a knowledge base document via MPS: download, call MPS, embed, store.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ctx: ARQ context
|
ctx: ARQ context
|
||||||
document_id: Database ID of the document
|
document_id: Database ID of the document
|
||||||
s3_key: S3 key where the file is stored
|
s3_key: S3 key where the file is stored
|
||||||
organization_id: Organization ID
|
organization_id: Organization ID
|
||||||
|
created_by_provider_id: Uploading user's provider ID (for OSS-mode auth to MPS)
|
||||||
max_tokens: Maximum number of tokens per chunk (default: 128)
|
max_tokens: Maximum number of tokens per chunk (default: 128)
|
||||||
retrieval_mode: "chunked" for vector search or "full_document" for full text
|
retrieval_mode: "chunked" for vector search or "full_document" for full text
|
||||||
"""
|
"""
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Starting knowledge base document processing for document_id={document_id}, "
|
f"Processing knowledge base document: document_id={document_id}, "
|
||||||
f"s3_key={s3_key}, organization_id={organization_id}"
|
f"s3_key={s3_key}, org={organization_id}, mode={retrieval_mode}"
|
||||||
)
|
)
|
||||||
|
|
||||||
temp_file_path = None
|
temp_file_path = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Update status to processing
|
|
||||||
await db_client.update_document_status(document_id, "processing")
|
await db_client.update_document_status(document_id, "processing")
|
||||||
|
|
||||||
# Extract file extension from S3 key
|
|
||||||
filename = s3_key.split("/")[-1]
|
filename = s3_key.split("/")[-1]
|
||||||
file_extension = (
|
file_extension = os.path.splitext(filename)[1] or ".bin"
|
||||||
os.path.splitext(filename)[1] or ".bin"
|
|
||||||
) # Default to .bin if no extension
|
|
||||||
|
|
||||||
# Create temp file for download with correct extension
|
|
||||||
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=file_extension)
|
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=file_extension)
|
||||||
temp_file_path = temp_file.name
|
temp_file_path = temp_file.name
|
||||||
temp_file.close()
|
temp_file.close()
|
||||||
|
|
||||||
# Download file from S3
|
|
||||||
logger.info(f"Downloading file from S3: {s3_key}")
|
logger.info(f"Downloading file from S3: {s3_key}")
|
||||||
download_success = await storage_fs.adownload_file(s3_key, temp_file_path)
|
download_success = await storage_fs.adownload_file(s3_key, temp_file_path)
|
||||||
|
|
||||||
if not download_success:
|
if not download_success:
|
||||||
raise Exception(f"Failed to download file from S3: {s3_key}")
|
raise Exception(f"Failed to download file from S3: {s3_key}")
|
||||||
|
|
||||||
if not os.path.exists(temp_file_path):
|
if not os.path.exists(temp_file_path):
|
||||||
raise FileNotFoundError(f"Downloaded file not found: {temp_file_path}")
|
raise FileNotFoundError(f"Downloaded file not found: {temp_file_path}")
|
||||||
|
|
||||||
file_size = os.path.getsize(temp_file_path)
|
file_size = os.path.getsize(temp_file_path)
|
||||||
logger.info(f"Downloaded file size: {file_size} bytes")
|
logger.info(f"Downloaded file size: {file_size} bytes")
|
||||||
|
|
||||||
# Validate file size (max 5MB)
|
if file_size > MAX_FILE_SIZE_BYTES:
|
||||||
max_file_size = 5 * 1024 * 1024
|
error_message = (
|
||||||
if file_size > max_file_size:
|
f"File size ({file_size / (1024 * 1024):.1f}MB) exceeds the "
|
||||||
error_message = f"File size ({file_size / (1024 * 1024):.1f}MB) exceeds the maximum allowed size of 5MB."
|
f"maximum allowed size of {MAX_FILE_SIZE_BYTES // (1024 * 1024)}MB."
|
||||||
|
)
|
||||||
logger.warning(f"Document {document_id}: {error_message}")
|
logger.warning(f"Document {document_id}: {error_message}")
|
||||||
await db_client.update_document_status(
|
await db_client.update_document_status(
|
||||||
document_id, "failed", error_message=error_message
|
document_id, "failed", error_message=error_message
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Compute file hash and get mime type
|
|
||||||
file_hash = db_client.compute_file_hash(temp_file_path)
|
file_hash = db_client.compute_file_hash(temp_file_path)
|
||||||
mime_type = db_client.get_mime_type(temp_file_path)
|
mime_type = db_client.get_mime_type(temp_file_path)
|
||||||
filename = s3_key.split("/")[-1]
|
|
||||||
|
|
||||||
# Get document record
|
|
||||||
document = await db_client.get_document_by_id(document_id)
|
document = await db_client.get_document_by_id(document_id)
|
||||||
if not document:
|
if not document:
|
||||||
raise Exception(f"Document {document_id} not found")
|
raise Exception(f"Document {document_id} not found")
|
||||||
|
|
||||||
# Check if a document with this hash already exists (reject duplicates)
|
# Reject duplicates (same hash already ingested for this org).
|
||||||
existing_doc = await db_client.get_document_by_hash(file_hash, organization_id)
|
existing_doc = await db_client.get_document_by_hash(file_hash, organization_id)
|
||||||
if existing_doc and existing_doc.id != document_id:
|
if existing_doc and existing_doc.id != document_id:
|
||||||
error_message = (
|
error_message = (
|
||||||
f"This file is a duplicate of '{existing_doc.filename}'. "
|
f"This file is a duplicate of '{existing_doc.filename}'. "
|
||||||
f"Please delete the duplicate files and consolidate them into a single unique file before uploading."
|
f"Please delete the duplicate files and consolidate them into a "
|
||||||
|
f"single unique file before uploading."
|
||||||
)
|
)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Duplicate document detected: {document_id} is duplicate of {existing_doc.id} "
|
f"Duplicate document detected: {document_id} is duplicate of "
|
||||||
f"({existing_doc.filename})"
|
f"{existing_doc.id} ({existing_doc.filename})"
|
||||||
)
|
)
|
||||||
# Update file metadata
|
|
||||||
await db_client.update_document_metadata(
|
await db_client.update_document_metadata(
|
||||||
document_id,
|
document_id,
|
||||||
file_size_bytes=file_size,
|
file_size_bytes=file_size,
|
||||||
file_hash=file_hash,
|
file_hash=file_hash,
|
||||||
mime_type=mime_type,
|
mime_type=mime_type,
|
||||||
)
|
)
|
||||||
# Mark as failed with duplicate error message
|
|
||||||
await db_client.update_document_status(
|
await db_client.update_document_status(
|
||||||
document_id,
|
document_id,
|
||||||
"failed",
|
"failed",
|
||||||
|
|
@ -122,7 +113,6 @@ async def process_knowledge_base_document(
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Update document with file metadata
|
|
||||||
await db_client.update_document_metadata(
|
await db_client.update_document_metadata(
|
||||||
document_id,
|
document_id,
|
||||||
file_size_bytes=file_size,
|
file_size_bytes=file_size,
|
||||||
|
|
@ -130,52 +120,35 @@ async def process_knowledge_base_document(
|
||||||
mime_type=mime_type,
|
mime_type=mime_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Full document mode: extract text and store it, skip chunking/embedding
|
logger.info(f"Delegating document processing to MPS (mode={retrieval_mode})")
|
||||||
|
mps_response = await mps_service_key_client.process_document(
|
||||||
|
file_path=temp_file_path,
|
||||||
|
filename=filename,
|
||||||
|
content_type=mime_type or "application/octet-stream",
|
||||||
|
retrieval_mode=retrieval_mode,
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
organization_id=organization_id,
|
||||||
|
created_by=created_by_provider_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
docling_metadata = mps_response.get("docling_metadata", {})
|
||||||
|
|
||||||
if retrieval_mode == "full_document":
|
if retrieval_mode == "full_document":
|
||||||
logger.info(f"Document {document_id}: full_document mode, extracting text")
|
full_text = mps_response.get("full_text") or ""
|
||||||
|
|
||||||
plain_text_extensions = {".txt", ".json"}
|
|
||||||
if file_extension.lower() in plain_text_extensions:
|
|
||||||
with open(temp_file_path, "r", encoding="utf-8") as f:
|
|
||||||
full_text = f.read()
|
|
||||||
if file_extension.lower() == ".json":
|
|
||||||
try:
|
|
||||||
parsed = json.loads(full_text)
|
|
||||||
full_text = json.dumps(parsed, indent=2, ensure_ascii=False)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
pass
|
|
||||||
docling_metadata = {"document_type": "PlainText"}
|
|
||||||
else:
|
|
||||||
converter = DocumentConverter()
|
|
||||||
conversion_result = converter.convert(temp_file_path)
|
|
||||||
doc = conversion_result.document
|
|
||||||
full_text = doc.export_to_text()
|
|
||||||
docling_metadata = {
|
|
||||||
"num_pages": len(doc.pages) if hasattr(doc, "pages") else None,
|
|
||||||
"document_type": type(doc).__name__,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Store full text on the document record
|
|
||||||
await db_client.update_document_full_text(document_id, full_text)
|
await db_client.update_document_full_text(document_id, full_text)
|
||||||
|
|
||||||
await db_client.update_document_status(
|
await db_client.update_document_status(
|
||||||
document_id,
|
document_id,
|
||||||
"completed",
|
"completed",
|
||||||
total_chunks=0,
|
total_chunks=0,
|
||||||
docling_metadata=docling_metadata,
|
docling_metadata=docling_metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Successfully processed full_document {document_id}. "
|
f"Successfully processed full_document {document_id}. "
|
||||||
f"Text length: {len(full_text)} chars"
|
f"Text length: {len(full_text)} chars"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Initialize the OpenAI embedding service
|
# Chunked mode: fetch user embedding config, embed via OpenAI, persist chunks.
|
||||||
logger.info(
|
|
||||||
f"Initializing OpenAI embedding service with max_tokens={max_tokens}"
|
|
||||||
)
|
|
||||||
# Try to get user's embeddings configuration
|
|
||||||
embeddings_api_key = None
|
embeddings_api_key = None
|
||||||
embeddings_model = None
|
embeddings_model = None
|
||||||
embeddings_base_url = None
|
embeddings_base_url = None
|
||||||
|
|
@ -187,7 +160,6 @@ async def process_knowledge_base_document(
|
||||||
embeddings_base_url = getattr(user_config.embeddings, "base_url", None)
|
embeddings_base_url = getattr(user_config.embeddings, "base_url", None)
|
||||||
logger.info(f"Using user embeddings config: model={embeddings_model}")
|
logger.info(f"Using user embeddings config: model={embeddings_model}")
|
||||||
|
|
||||||
# Check if API key is configured
|
|
||||||
if not embeddings_api_key:
|
if not embeddings_api_key:
|
||||||
error_message = (
|
error_message = (
|
||||||
"OpenAI API key not configured. Please set your API key in "
|
"OpenAI API key not configured. Please set your API key in "
|
||||||
|
|
@ -199,190 +171,57 @@ async def process_knowledge_base_document(
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
service = OpenAIEmbeddingService(
|
embedding_service = OpenAIEmbeddingService(
|
||||||
db_client=db_client,
|
db_client=db_client,
|
||||||
max_tokens=max_tokens,
|
|
||||||
api_key=embeddings_api_key,
|
api_key=embeddings_api_key,
|
||||||
model_id=embeddings_model or "text-embedding-3-small",
|
model_id=embeddings_model or "text-embedding-3-small",
|
||||||
base_url=embeddings_base_url,
|
base_url=embeddings_base_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 1: Initialize tokenizer for chunking
|
mps_chunks = mps_response.get("chunks", [])
|
||||||
logger.info(
|
if not mps_chunks:
|
||||||
f"Loading tokenizer: {TOKENIZER_MODEL} with max_tokens={max_tokens}"
|
logger.warning(f"Document {document_id}: MPS returned zero chunks")
|
||||||
)
|
|
||||||
hf_tokenizer = AutoTokenizer.from_pretrained(TOKENIZER_MODEL)
|
|
||||||
tokenizer = HuggingFaceTokenizer(
|
|
||||||
tokenizer=hf_tokenizer,
|
|
||||||
max_tokens=max_tokens,
|
|
||||||
)
|
|
||||||
|
|
||||||
chunk_texts = []
|
|
||||||
chunk_records = []
|
chunk_records = []
|
||||||
token_counts = []
|
chunk_texts = []
|
||||||
|
for chunk in mps_chunks:
|
||||||
# Check if file is a plain text format that docling doesn't support
|
contextualized = chunk.get("contextualized_text") or chunk["chunk_text"]
|
||||||
plain_text_extensions = {".txt", ".json"}
|
chunk_records.append(
|
||||||
if file_extension.lower() in plain_text_extensions:
|
KnowledgeBaseChunkModel(
|
||||||
# Read text content directly
|
document_id=document_id,
|
||||||
logger.info(f"Reading {file_extension} file directly (bypassing docling)")
|
organization_id=organization_id,
|
||||||
with open(temp_file_path, "r", encoding="utf-8") as f:
|
chunk_text=chunk["chunk_text"],
|
||||||
raw_content = f.read()
|
contextualized_text=contextualized,
|
||||||
|
chunk_index=chunk["chunk_index"],
|
||||||
# For JSON files, pretty-print for better readability
|
chunk_metadata=chunk.get("chunk_metadata") or {},
|
||||||
if file_extension.lower() == ".json":
|
embedding_model=embedding_service.get_model_id(),
|
||||||
try:
|
embedding_dimension=embedding_service.get_embedding_dimension(),
|
||||||
parsed = json.loads(raw_content)
|
token_count=chunk.get("token_count", 0),
|
||||||
raw_content = json.dumps(parsed, indent=2, ensure_ascii=False)
|
)
|
||||||
except json.JSONDecodeError:
|
|
||||||
logger.warning(
|
|
||||||
"JSON file is not valid JSON, treating as plain text"
|
|
||||||
)
|
|
||||||
|
|
||||||
docling_metadata = {
|
|
||||||
"num_pages": None,
|
|
||||||
"document_type": "PlainText",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Token-based chunking for plain text
|
|
||||||
tokens = hf_tokenizer.encode(raw_content, add_special_tokens=False)
|
|
||||||
total_tokens = len(tokens)
|
|
||||||
logger.info(
|
|
||||||
f"Total tokens in file: {total_tokens}, chunking with max_tokens={max_tokens}"
|
|
||||||
)
|
)
|
||||||
|
chunk_texts.append(contextualized)
|
||||||
|
|
||||||
start = 0
|
logger.info(
|
||||||
chunk_index = 0
|
f"Generating embeddings for {len(chunk_texts)} chunks "
|
||||||
while start < total_tokens:
|
f"using {embedding_service.get_model_id()}"
|
||||||
end = min(start + max_tokens, total_tokens)
|
)
|
||||||
chunk_token_ids = tokens[start:end]
|
embeddings = await embedding_service.embed_texts(chunk_texts)
|
||||||
chunk_text = hf_tokenizer.decode(
|
|
||||||
chunk_token_ids, skip_special_tokens=True
|
|
||||||
)
|
|
||||||
|
|
||||||
token_count = len(chunk_token_ids)
|
|
||||||
token_counts.append(token_count)
|
|
||||||
|
|
||||||
chunk_record = KnowledgeBaseChunkModel(
|
|
||||||
document_id=document_id,
|
|
||||||
organization_id=organization_id,
|
|
||||||
chunk_text=chunk_text,
|
|
||||||
contextualized_text=chunk_text,
|
|
||||||
chunk_index=chunk_index,
|
|
||||||
chunk_metadata={},
|
|
||||||
embedding_model=service.get_model_id(),
|
|
||||||
embedding_dimension=service.get_embedding_dimension(),
|
|
||||||
token_count=token_count,
|
|
||||||
)
|
|
||||||
|
|
||||||
chunk_records.append(chunk_record)
|
|
||||||
chunk_texts.append(chunk_text)
|
|
||||||
chunk_index += 1
|
|
||||||
start = end
|
|
||||||
|
|
||||||
total_chunks = len(chunk_records)
|
|
||||||
logger.info(f"Generated {total_chunks} chunks from plain text")
|
|
||||||
|
|
||||||
else:
|
|
||||||
# Use docling for structured formats (PDF, DOCX, etc.)
|
|
||||||
logger.info("Converting document with docling")
|
|
||||||
converter = DocumentConverter()
|
|
||||||
conversion_result = converter.convert(temp_file_path)
|
|
||||||
doc = conversion_result.document
|
|
||||||
|
|
||||||
docling_metadata = {
|
|
||||||
"num_pages": len(doc.pages) if hasattr(doc, "pages") else None,
|
|
||||||
"document_type": type(doc).__name__,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Initialize chunker
|
|
||||||
logger.info(f"Initializing HybridChunker with max_tokens={max_tokens}")
|
|
||||||
chunker = HybridChunker(tokenizer=tokenizer)
|
|
||||||
|
|
||||||
# Chunk the document
|
|
||||||
logger.info(f"Chunking document with max_tokens={max_tokens}")
|
|
||||||
chunks = list(chunker.chunk(dl_doc=doc))
|
|
||||||
total_chunks = len(chunks)
|
|
||||||
logger.info(f"Generated {total_chunks} chunks")
|
|
||||||
|
|
||||||
# Process each chunk
|
|
||||||
for i, chunk in enumerate(chunks):
|
|
||||||
chunk_text = chunk.text
|
|
||||||
contextualized_text = chunker.contextualize(chunk=chunk)
|
|
||||||
|
|
||||||
text_to_tokenize = (
|
|
||||||
contextualized_text if contextualized_text else chunk_text
|
|
||||||
)
|
|
||||||
token_count = len(
|
|
||||||
tokenizer.tokenizer.encode(
|
|
||||||
text_to_tokenize, add_special_tokens=False
|
|
||||||
)
|
|
||||||
)
|
|
||||||
token_counts.append(token_count)
|
|
||||||
|
|
||||||
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 []
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
chunk_record = KnowledgeBaseChunkModel(
|
|
||||||
document_id=document_id,
|
|
||||||
organization_id=organization_id,
|
|
||||||
chunk_text=chunk_text,
|
|
||||||
contextualized_text=contextualized_text,
|
|
||||||
chunk_index=i,
|
|
||||||
chunk_metadata=chunk_metadata,
|
|
||||||
embedding_model=service.get_model_id(),
|
|
||||||
embedding_dimension=service.get_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_actual = 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_actual} tokens")
|
|
||||||
|
|
||||||
# Step 6: Generate embeddings using OpenAI
|
|
||||||
logger.info(f"Generating embeddings using {service.get_model_id()}")
|
|
||||||
embeddings = await service.embed_texts(chunk_texts)
|
|
||||||
|
|
||||||
# Step 7: Attach embeddings to chunk records
|
|
||||||
for chunk_record, embedding in zip(chunk_records, embeddings):
|
for chunk_record, embedding in zip(chunk_records, embeddings):
|
||||||
chunk_record.embedding = embedding
|
chunk_record.embedding = embedding
|
||||||
|
|
||||||
# Step 8: Save chunks in database
|
|
||||||
logger.info("Storing chunks in database")
|
logger.info("Storing chunks in database")
|
||||||
await db_client.create_chunks_batch(chunk_records)
|
await db_client.create_chunks_batch(chunk_records)
|
||||||
|
|
||||||
# Step 9: Update document status to completed
|
|
||||||
await db_client.update_document_status(
|
await db_client.update_document_status(
|
||||||
document_id,
|
document_id,
|
||||||
"completed",
|
"completed",
|
||||||
total_chunks=total_chunks,
|
total_chunks=len(chunk_records),
|
||||||
docling_metadata=docling_metadata,
|
docling_metadata=docling_metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Successfully processed knowledge base document {document_id}. "
|
f"Successfully processed knowledge base document {document_id}. "
|
||||||
f"Total chunks: {total_chunks}"
|
f"Total chunks: {len(chunk_records)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -390,14 +229,12 @@ async def process_knowledge_base_document(
|
||||||
f"Error processing knowledge base document {document_id}: {e}",
|
f"Error processing knowledge base document {document_id}: {e}",
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
# Update document status to failed
|
|
||||||
await db_client.update_document_status(
|
await db_client.update_document_status(
|
||||||
document_id, "failed", error_message=str(e)
|
document_id, "failed", error_message=str(e)
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# Clean up temp file
|
|
||||||
if temp_file_path and os.path.exists(temp_file_path):
|
if temp_file_path and os.path.exists(temp_file_path):
|
||||||
try:
|
try:
|
||||||
os.remove(temp_file_path)
|
os.remove(temp_file_path)
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,19 @@ from typing import Any, Dict, Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from api.constants import BACKEND_API_ENDPOINT
|
from api.constants import BACKEND_API_ENDPOINT
|
||||||
from api.db import db_client
|
from api.db import db_client
|
||||||
from api.db.models import WorkflowRunModel
|
from api.db.models import WorkflowRunModel
|
||||||
from api.enums import OrganizationConfigurationKey
|
from api.enums import OrganizationConfigurationKey
|
||||||
from api.services.pipecat.tracing_config import register_org_langfuse_credentials
|
from api.services.pipecat.tracing_config import register_org_langfuse_credentials
|
||||||
|
from api.services.workflow.dto import (
|
||||||
|
QANodeData,
|
||||||
|
QARFNode,
|
||||||
|
WebhookNodeData,
|
||||||
|
WebhookRFNode,
|
||||||
|
)
|
||||||
from api.services.workflow.qa import run_per_node_qa_analysis
|
from api.services.workflow.qa import run_per_node_qa_analysis
|
||||||
from api.utils.credential_auth import build_auth_header
|
from api.utils.credential_auth import build_auth_header
|
||||||
from api.utils.template_renderer import render_template
|
from api.utils.template_renderer import render_template
|
||||||
|
|
@ -19,34 +26,34 @@ from pipecat.utils.run_context import set_current_org_id, set_current_run_id
|
||||||
|
|
||||||
|
|
||||||
def _should_skip_qa(
|
def _should_skip_qa(
|
||||||
node_data: dict,
|
qa_data: QANodeData,
|
||||||
workflow_run: WorkflowRunModel,
|
workflow_run: WorkflowRunModel,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Check whether QA analysis should be skipped for this call.
|
"""Check whether QA analysis should be skipped for this call.
|
||||||
|
|
||||||
Returns a reason string if the call should be skipped, or None if it should proceed.
|
Returns a reason string if the call should be skipped, or None if it should proceed.
|
||||||
"""
|
"""
|
||||||
# Check minimum call duration
|
|
||||||
min_duration = node_data.get("qa_min_call_duration", 15)
|
|
||||||
usage_info = workflow_run.usage_info or {}
|
usage_info = workflow_run.usage_info or {}
|
||||||
call_duration = usage_info.get("call_duration_seconds")
|
call_duration = usage_info.get("call_duration_seconds")
|
||||||
if call_duration is not None and call_duration < min_duration:
|
if call_duration is not None and call_duration < qa_data.qa_min_call_duration:
|
||||||
return f"call duration ({call_duration:.1f}s) below minimum ({min_duration}s)"
|
return (
|
||||||
|
f"call duration ({call_duration:.1f}s) below minimum "
|
||||||
|
f"({qa_data.qa_min_call_duration}s)"
|
||||||
|
)
|
||||||
|
|
||||||
# Check voicemail calls
|
if not qa_data.qa_voicemail_calls:
|
||||||
qa_voicemail_calls = node_data.get("qa_voicemail_calls", False)
|
|
||||||
if not qa_voicemail_calls:
|
|
||||||
gathered_context = workflow_run.gathered_context or {}
|
gathered_context = workflow_run.gathered_context or {}
|
||||||
call_disposition = gathered_context.get("call_disposition", "")
|
call_disposition = gathered_context.get("call_disposition", "")
|
||||||
if call_disposition == EndTaskReason.VOICEMAIL_DETECTED.value:
|
if call_disposition == EndTaskReason.VOICEMAIL_DETECTED.value:
|
||||||
return "voicemail call and QA voicemail calls is disabled"
|
return "voicemail call and QA voicemail calls is disabled"
|
||||||
|
|
||||||
# Check sample rate
|
if qa_data.qa_sample_rate < 100:
|
||||||
sample_rate = node_data.get("qa_sample_rate", 100)
|
|
||||||
if sample_rate < 100:
|
|
||||||
roll = random.randint(1, 100)
|
roll = random.randint(1, 100)
|
||||||
if roll > sample_rate:
|
if roll > qa_data.qa_sample_rate:
|
||||||
return f"excluded by sampling ({sample_rate}% sample rate, rolled {roll})"
|
return (
|
||||||
|
f"excluded by sampling ({qa_data.qa_sample_rate}% sample rate, "
|
||||||
|
f"rolled {roll})"
|
||||||
|
)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -66,15 +73,22 @@ async def _run_qa_nodes(
|
||||||
results: Dict[str, Any] = {}
|
results: Dict[str, Any] = {}
|
||||||
|
|
||||||
for node in qa_nodes:
|
for node in qa_nodes:
|
||||||
node_data = node.get("data", {})
|
|
||||||
node_id = node.get("id", "unknown")
|
node_id = node.get("id", "unknown")
|
||||||
node_name = node_data.get("name", "QA Analysis")
|
try:
|
||||||
|
qa_node = QARFNode.model_validate(node)
|
||||||
|
except ValidationError as e:
|
||||||
|
logger.warning(f"QA node #{node_id} failed validation, skipping: {e}")
|
||||||
|
results[f"qa_{node_id}"] = {"error": "validation_failed"}
|
||||||
|
continue
|
||||||
|
|
||||||
if not node_data.get("qa_enabled", True):
|
qa_data = qa_node.data
|
||||||
|
node_name = qa_data.name
|
||||||
|
|
||||||
|
if not qa_data.qa_enabled:
|
||||||
logger.debug(f"QA node '{node_name}' is disabled, skipping")
|
logger.debug(f"QA node '{node_name}' is disabled, skipping")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
skip_reason = _should_skip_qa(node_data, workflow_run)
|
skip_reason = _should_skip_qa(qa_data, workflow_run)
|
||||||
if skip_reason:
|
if skip_reason:
|
||||||
logger.info(f"Skipping QA node '{node_name}' (#{node_id}): {skip_reason}")
|
logger.info(f"Skipping QA node '{node_name}' (#{node_id}): {skip_reason}")
|
||||||
results[f"qa_{node_id}"] = {"skipped": True, "reason": skip_reason}
|
results[f"qa_{node_id}"] = {"skipped": True, "reason": skip_reason}
|
||||||
|
|
@ -83,7 +97,7 @@ async def _run_qa_nodes(
|
||||||
try:
|
try:
|
||||||
logger.info(f"Running QA analysis for node '{node_name}' (#{node_id})")
|
logger.info(f"Running QA analysis for node '{node_name}' (#{node_id})")
|
||||||
result = await run_per_node_qa_analysis(
|
result = await run_per_node_qa_analysis(
|
||||||
node_data,
|
qa_data,
|
||||||
workflow_run,
|
workflow_run,
|
||||||
workflow_run_id,
|
workflow_run_id,
|
||||||
workflow_definition,
|
workflow_definition,
|
||||||
|
|
@ -260,7 +274,16 @@ async def run_integrations_post_workflow_run(_ctx, workflow_run_id: int):
|
||||||
|
|
||||||
# Step 8: Execute each webhook node
|
# Step 8: Execute each webhook node
|
||||||
for node in webhook_nodes:
|
for node in webhook_nodes:
|
||||||
webhook_data = node.get("data", {})
|
node_id = node.get("id", "unknown")
|
||||||
|
try:
|
||||||
|
webhook_node = WebhookRFNode.model_validate(node)
|
||||||
|
except ValidationError as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Webhook node #{node_id} failed validation, skipping: {e}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
webhook_data = webhook_node.data
|
||||||
try:
|
try:
|
||||||
await _execute_webhook_node(
|
await _execute_webhook_node(
|
||||||
webhook_data=webhook_data,
|
webhook_data=webhook_data,
|
||||||
|
|
@ -268,10 +291,7 @@ async def run_integrations_post_workflow_run(_ctx, workflow_run_id: int):
|
||||||
organization_id=organization_id,
|
organization_id=organization_id,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Log error but continue with other webhooks
|
logger.warning(f"Failed to execute webhook '{webhook_data.name}': {e}")
|
||||||
logger.warning(
|
|
||||||
f"Failed to execute webhook '{webhook_data.get('name', 'unknown')}': {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error running integrations: {e}", exc_info=True)
|
logger.error(f"Error running integrations: {e}", exc_info=True)
|
||||||
|
|
@ -323,7 +343,7 @@ def _build_render_context(
|
||||||
|
|
||||||
|
|
||||||
async def _execute_webhook_node(
|
async def _execute_webhook_node(
|
||||||
webhook_data: Dict[str, Any],
|
webhook_data: WebhookNodeData,
|
||||||
render_context: Dict[str, Any],
|
render_context: Dict[str, Any],
|
||||||
organization_id: int,
|
organization_id: int,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
|
@ -331,31 +351,27 @@ async def _execute_webhook_node(
|
||||||
Execute a single webhook node.
|
Execute a single webhook node.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
webhook_data: The webhook node's data dict from workflow definition
|
webhook_data: The validated webhook node data
|
||||||
render_context: Context for template rendering
|
render_context: Context for template rendering
|
||||||
organization_id: For credential lookup
|
organization_id: For credential lookup
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if successful, False otherwise
|
True if successful, False otherwise
|
||||||
"""
|
"""
|
||||||
webhook_name = webhook_data.get("name", "Unnamed Webhook")
|
webhook_name = webhook_data.name
|
||||||
|
|
||||||
# 1. Check if enabled
|
if not webhook_data.enabled:
|
||||||
if not webhook_data.get("enabled", True):
|
|
||||||
logger.debug(f"Webhook '{webhook_name}' is disabled, skipping")
|
logger.debug(f"Webhook '{webhook_name}' is disabled, skipping")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# 2. Validate endpoint URL
|
url = webhook_data.endpoint_url
|
||||||
url = webhook_data.get("endpoint_url")
|
|
||||||
if not url:
|
if not url:
|
||||||
logger.warning(f"Webhook '{webhook_name}' has no endpoint URL")
|
logger.warning(f"Webhook '{webhook_name}' has no endpoint URL")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# 3. Build headers
|
|
||||||
headers = {"Content-Type": "application/json"}
|
headers = {"Content-Type": "application/json"}
|
||||||
|
|
||||||
# 4. Add auth header if credential configured
|
credential_uuid = webhook_data.credential_uuid
|
||||||
credential_uuid = webhook_data.get("credential_uuid")
|
|
||||||
if credential_uuid:
|
if credential_uuid:
|
||||||
credential = await db_client.get_credential_by_uuid(
|
credential = await db_client.get_credential_by_uuid(
|
||||||
credential_uuid, organization_id
|
credential_uuid, organization_id
|
||||||
|
|
@ -369,18 +385,13 @@ async def _execute_webhook_node(
|
||||||
f"Credential {credential_uuid} not found for webhook '{webhook_name}'"
|
f"Credential {credential_uuid} not found for webhook '{webhook_name}'"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 5. Add custom headers
|
for h in webhook_data.custom_headers or []:
|
||||||
custom_headers = webhook_data.get("custom_headers", [])
|
if h.key and h.value:
|
||||||
for h in custom_headers:
|
headers[h.key] = h.value
|
||||||
if h.get("key") and h.get("value"):
|
|
||||||
headers[h["key"]] = h["value"]
|
|
||||||
|
|
||||||
# 6. Render payload template
|
payload = render_template(webhook_data.payload_template or {}, render_context)
|
||||||
payload_template = webhook_data.get("payload_template", {})
|
|
||||||
payload = render_template(payload_template, render_context)
|
|
||||||
|
|
||||||
# 7. Make HTTP request
|
method = (webhook_data.http_method or "POST").upper()
|
||||||
method = webhook_data.get("http_method", "POST").upper()
|
|
||||||
|
|
||||||
logger.info(f"Executing webhook '{webhook_name}': {method}")
|
logger.info(f"Executing webhook '{webhook_name}': {method}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,14 +14,17 @@ from unittest.mock import Mock
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from api.services.workflow.dto import (
|
from api.services.workflow.dto import (
|
||||||
|
AgentNodeData,
|
||||||
|
AgentRFNode,
|
||||||
EdgeDataDTO,
|
EdgeDataDTO,
|
||||||
|
EndCallNodeData,
|
||||||
|
EndCallRFNode,
|
||||||
ExtractionVariableDTO,
|
ExtractionVariableDTO,
|
||||||
NodeDataDTO,
|
|
||||||
NodeType,
|
|
||||||
Position,
|
Position,
|
||||||
ReactFlowDTO,
|
ReactFlowDTO,
|
||||||
RFEdgeDTO,
|
RFEdgeDTO,
|
||||||
RFNodeDTO,
|
StartCallNodeData,
|
||||||
|
StartCallRFNode,
|
||||||
VariableType,
|
VariableType,
|
||||||
)
|
)
|
||||||
from api.services.workflow.workflow import WorkflowGraph
|
from api.services.workflow.workflow import WorkflowGraph
|
||||||
|
|
@ -252,11 +255,10 @@ def simple_workflow() -> WorkflowGraph:
|
||||||
"""
|
"""
|
||||||
dto = ReactFlowDTO(
|
dto = ReactFlowDTO(
|
||||||
nodes=[
|
nodes=[
|
||||||
RFNodeDTO(
|
StartCallRFNode(
|
||||||
id="start",
|
id="start",
|
||||||
type=NodeType.startNode,
|
|
||||||
position=Position(x=0, y=0),
|
position=Position(x=0, y=0),
|
||||||
data=NodeDataDTO(
|
data=StartCallNodeData(
|
||||||
name="Start Call",
|
name="Start Call",
|
||||||
prompt=START_CALL_SYSTEM_PROMPT,
|
prompt=START_CALL_SYSTEM_PROMPT,
|
||||||
is_start=True,
|
is_start=True,
|
||||||
|
|
@ -273,11 +275,10 @@ def simple_workflow() -> WorkflowGraph:
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
RFNodeDTO(
|
EndCallRFNode(
|
||||||
id="end",
|
id="end",
|
||||||
type=NodeType.endNode,
|
|
||||||
position=Position(x=0, y=200),
|
position=Position(x=0, y=200),
|
||||||
data=NodeDataDTO(
|
data=EndCallNodeData(
|
||||||
name="End Call",
|
name="End Call",
|
||||||
prompt=END_CALL_SYSTEM_PROMPT,
|
prompt=END_CALL_SYSTEM_PROMPT,
|
||||||
is_end=True,
|
is_end=True,
|
||||||
|
|
@ -317,11 +318,10 @@ def three_node_workflow() -> WorkflowGraph:
|
||||||
"""
|
"""
|
||||||
dto = ReactFlowDTO(
|
dto = ReactFlowDTO(
|
||||||
nodes=[
|
nodes=[
|
||||||
RFNodeDTO(
|
StartCallRFNode(
|
||||||
id="start",
|
id="start",
|
||||||
type=NodeType.startNode,
|
|
||||||
position=Position(x=0, y=0),
|
position=Position(x=0, y=0),
|
||||||
data=NodeDataDTO(
|
data=StartCallNodeData(
|
||||||
name="Start Call",
|
name="Start Call",
|
||||||
prompt=START_CALL_SYSTEM_PROMPT,
|
prompt=START_CALL_SYSTEM_PROMPT,
|
||||||
is_start=True,
|
is_start=True,
|
||||||
|
|
@ -338,11 +338,10 @@ def three_node_workflow() -> WorkflowGraph:
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
RFNodeDTO(
|
AgentRFNode(
|
||||||
id="agent",
|
id="agent",
|
||||||
type=NodeType.agentNode,
|
|
||||||
position=Position(x=0, y=200),
|
position=Position(x=0, y=200),
|
||||||
data=NodeDataDTO(
|
data=AgentNodeData(
|
||||||
name="Collect Info",
|
name="Collect Info",
|
||||||
prompt=AGENT_SYSTEM_PROMPT,
|
prompt=AGENT_SYSTEM_PROMPT,
|
||||||
allow_interrupt=False,
|
allow_interrupt=False,
|
||||||
|
|
@ -358,11 +357,10 @@ def three_node_workflow() -> WorkflowGraph:
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
RFNodeDTO(
|
EndCallRFNode(
|
||||||
id="end",
|
id="end",
|
||||||
type=NodeType.endNode,
|
|
||||||
position=Position(x=0, y=400),
|
position=Position(x=0, y=400),
|
||||||
data=NodeDataDTO(
|
data=EndCallNodeData(
|
||||||
name="End Call",
|
name="End Call",
|
||||||
prompt=END_CALL_SYSTEM_PROMPT,
|
prompt=END_CALL_SYSTEM_PROMPT,
|
||||||
is_end=True,
|
is_end=True,
|
||||||
|
|
@ -411,11 +409,10 @@ def three_node_workflow_extraction_start_only() -> WorkflowGraph:
|
||||||
"""
|
"""
|
||||||
dto = ReactFlowDTO(
|
dto = ReactFlowDTO(
|
||||||
nodes=[
|
nodes=[
|
||||||
RFNodeDTO(
|
StartCallRFNode(
|
||||||
id="start",
|
id="start",
|
||||||
type=NodeType.startNode,
|
|
||||||
position=Position(x=0, y=0),
|
position=Position(x=0, y=0),
|
||||||
data=NodeDataDTO(
|
data=StartCallNodeData(
|
||||||
name="Start Call",
|
name="Start Call",
|
||||||
prompt=START_CALL_SYSTEM_PROMPT,
|
prompt=START_CALL_SYSTEM_PROMPT,
|
||||||
is_start=True,
|
is_start=True,
|
||||||
|
|
@ -432,11 +429,10 @@ def three_node_workflow_extraction_start_only() -> WorkflowGraph:
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
RFNodeDTO(
|
AgentRFNode(
|
||||||
id="agent",
|
id="agent",
|
||||||
type=NodeType.agentNode,
|
|
||||||
position=Position(x=0, y=200),
|
position=Position(x=0, y=200),
|
||||||
data=NodeDataDTO(
|
data=AgentNodeData(
|
||||||
name="Collect Info",
|
name="Collect Info",
|
||||||
prompt=AGENT_SYSTEM_PROMPT,
|
prompt=AGENT_SYSTEM_PROMPT,
|
||||||
allow_interrupt=False,
|
allow_interrupt=False,
|
||||||
|
|
@ -444,11 +440,10 @@ def three_node_workflow_extraction_start_only() -> WorkflowGraph:
|
||||||
extraction_enabled=False, # Explicitly disabled for testing
|
extraction_enabled=False, # Explicitly disabled for testing
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
RFNodeDTO(
|
EndCallRFNode(
|
||||||
id="end",
|
id="end",
|
||||||
type=NodeType.endNode,
|
|
||||||
position=Position(x=0, y=400),
|
position=Position(x=0, y=400),
|
||||||
data=NodeDataDTO(
|
data=EndCallNodeData(
|
||||||
name="End Call",
|
name="End Call",
|
||||||
prompt=END_CALL_SYSTEM_PROMPT,
|
prompt=END_CALL_SYSTEM_PROMPT,
|
||||||
is_end=True,
|
is_end=True,
|
||||||
|
|
@ -493,11 +488,10 @@ def three_node_workflow_no_variable_extraction() -> WorkflowGraph:
|
||||||
"""
|
"""
|
||||||
dto = ReactFlowDTO(
|
dto = ReactFlowDTO(
|
||||||
nodes=[
|
nodes=[
|
||||||
RFNodeDTO(
|
StartCallRFNode(
|
||||||
id="start",
|
id="start",
|
||||||
type=NodeType.startNode,
|
|
||||||
position=Position(x=0, y=0),
|
position=Position(x=0, y=0),
|
||||||
data=NodeDataDTO(
|
data=StartCallNodeData(
|
||||||
name="Start Call",
|
name="Start Call",
|
||||||
prompt=START_CALL_SYSTEM_PROMPT,
|
prompt=START_CALL_SYSTEM_PROMPT,
|
||||||
is_start=True,
|
is_start=True,
|
||||||
|
|
@ -506,11 +500,10 @@ def three_node_workflow_no_variable_extraction() -> WorkflowGraph:
|
||||||
extraction_enabled=False,
|
extraction_enabled=False,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
RFNodeDTO(
|
AgentRFNode(
|
||||||
id="agent",
|
id="agent",
|
||||||
type=NodeType.agentNode,
|
|
||||||
position=Position(x=0, y=200),
|
position=Position(x=0, y=200),
|
||||||
data=NodeDataDTO(
|
data=AgentNodeData(
|
||||||
name="Collect Info",
|
name="Collect Info",
|
||||||
prompt=AGENT_SYSTEM_PROMPT,
|
prompt=AGENT_SYSTEM_PROMPT,
|
||||||
allow_interrupt=False,
|
allow_interrupt=False,
|
||||||
|
|
@ -518,11 +511,10 @@ def three_node_workflow_no_variable_extraction() -> WorkflowGraph:
|
||||||
extraction_enabled=False, # Explicitly disabled for testing
|
extraction_enabled=False, # Explicitly disabled for testing
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
RFNodeDTO(
|
EndCallRFNode(
|
||||||
id="end",
|
id="end",
|
||||||
type=NodeType.endNode,
|
|
||||||
position=Position(x=0, y=400),
|
position=Position(x=0, y=400),
|
||||||
data=NodeDataDTO(
|
data=EndCallNodeData(
|
||||||
name="End Call",
|
name="End Call",
|
||||||
prompt=END_CALL_SYSTEM_PROMPT,
|
prompt=END_CALL_SYSTEM_PROMPT,
|
||||||
is_end=True,
|
is_end=True,
|
||||||
|
|
|
||||||
39
api/tests/test_display_options_evaluator.py
Normal file
39
api/tests/test_display_options_evaluator.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
"""Golden-test parity for the display_options evaluator.
|
||||||
|
|
||||||
|
Both the Python `evaluate_display_options` and the TypeScript
|
||||||
|
`evaluateDisplayOptions` (in `ui/src/components/flow/renderer/displayOptions.ts`)
|
||||||
|
must agree on every fixture in `display_options_fixtures.json`. The TS
|
||||||
|
side is verified by `ui/scripts/test-display-options.mjs`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from api.services.workflow.node_specs import evaluate_display_options
|
||||||
|
|
||||||
|
FIXTURES_PATH = (
|
||||||
|
Path(__file__).parent.parent
|
||||||
|
/ "services"
|
||||||
|
/ "workflow"
|
||||||
|
/ "node_specs"
|
||||||
|
/ "display_options_fixtures.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_cases():
|
||||||
|
with open(FIXTURES_PATH) as f:
|
||||||
|
return json.load(f)["cases"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("case", load_cases(), ids=lambda c: c["name"])
|
||||||
|
def test_python_evaluator_matches_fixture(case):
|
||||||
|
rules = case["rules"]
|
||||||
|
values = case["values"]
|
||||||
|
expected = case["expected"]
|
||||||
|
actual = evaluate_display_options(rules, values)
|
||||||
|
assert actual is expected, (
|
||||||
|
f"{case['name']}: expected {expected}, got {actual} "
|
||||||
|
f"for rules={rules!r} values={values!r}"
|
||||||
|
)
|
||||||
235
api/tests/test_dograh_sdk.py
Normal file
235
api/tests/test_dograh_sdk.py
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
"""Tests for the Python runtime SDK (`dograh_sdk`).
|
||||||
|
|
||||||
|
Uses a stub client backed by the in-process spec registry rather than
|
||||||
|
exercising the HTTP layer — the HTTP client is a thin wrapper that's
|
||||||
|
easier to test manually against a live server.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- Workflow builder round-trips through ReactFlowDTO validation
|
||||||
|
- Validation errors fail at the `add()` call site
|
||||||
|
- from_json preserves node IDs and subsequent add() doesn't collide
|
||||||
|
- Edge labels / conditions are required
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from dograh_sdk import Workflow
|
||||||
|
from dograh_sdk._generated_models import NodeSpec
|
||||||
|
from dograh_sdk.errors import ValidationError
|
||||||
|
|
||||||
|
from api.services.workflow.dto import ReactFlowDTO
|
||||||
|
from api.services.workflow.node_specs import all_specs, get_spec
|
||||||
|
|
||||||
|
|
||||||
|
class _StubClient:
|
||||||
|
"""Stand-in for DograhClient backed by the in-process spec registry.
|
||||||
|
Matches the real client's contract: `get_node_type(name)` returns a
|
||||||
|
`NodeSpec` Pydantic model."""
|
||||||
|
|
||||||
|
def get_node_type(self, name: str) -> NodeSpec:
|
||||||
|
spec = get_spec(name)
|
||||||
|
if spec is None:
|
||||||
|
raise ValueError(f"Unknown spec: {name}")
|
||||||
|
return NodeSpec.model_validate(spec.model_dump(mode="json"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client() -> _StubClient:
|
||||||
|
return _StubClient()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Builder + to_json round-trip ────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_builds_minimal_workflow_and_roundtrips_through_dto(client: _StubClient):
|
||||||
|
wf = Workflow(client=client, name="minimal")
|
||||||
|
start = wf.add(
|
||||||
|
type="startCall",
|
||||||
|
name="greeting",
|
||||||
|
prompt="Say hi to the caller.",
|
||||||
|
)
|
||||||
|
end = wf.add(
|
||||||
|
type="endCall",
|
||||||
|
name="close",
|
||||||
|
prompt="Thank the caller and hang up.",
|
||||||
|
)
|
||||||
|
wf.edge(start, end, label="done", condition="When the greeting is complete")
|
||||||
|
|
||||||
|
payload = wf.to_json()
|
||||||
|
# Wire format must validate through the backend Pydantic union — if
|
||||||
|
# it doesn't, the SDK has silently drifted from the spec schema.
|
||||||
|
dto = ReactFlowDTO.model_validate(payload)
|
||||||
|
assert len(dto.nodes) == 2
|
||||||
|
assert {n.type for n in dto.nodes} == {"startCall", "endCall"}
|
||||||
|
assert len(dto.edges) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_defaults_applied_from_spec(client: _StubClient):
|
||||||
|
"""Spec defaults (e.g., `allow_interrupt=False` on startCall) fill in
|
||||||
|
when the user doesn't pass them."""
|
||||||
|
wf = Workflow(client=client, name="defaults")
|
||||||
|
start = wf.add(type="startCall", name="greeting", prompt="hello")
|
||||||
|
payload = wf.to_json()
|
||||||
|
data = payload["nodes"][0]["data"]
|
||||||
|
assert data["allow_interrupt"] is False # spec default
|
||||||
|
assert data["add_global_prompt"] is True # spec default
|
||||||
|
_ = start # used implicitly; silence unused
|
||||||
|
|
||||||
|
|
||||||
|
def test_webhook_complex_fields_validate(client: _StubClient):
|
||||||
|
"""Webhook's json + fixed_collection (custom_headers) round-trip."""
|
||||||
|
wf = Workflow(client=client, name="wh")
|
||||||
|
wh = wf.add(
|
||||||
|
type="webhook",
|
||||||
|
name="notify",
|
||||||
|
enabled=True,
|
||||||
|
http_method="POST",
|
||||||
|
endpoint_url="https://api.example.com/hook",
|
||||||
|
custom_headers=[{"key": "X-Source", "value": "dograh"}],
|
||||||
|
payload_template={"run": "{{workflow_run_id}}"},
|
||||||
|
)
|
||||||
|
payload = wf.to_json()
|
||||||
|
# Webhook has no incoming/outgoing graph requirements — render as a
|
||||||
|
# standalone node in the graph for the DTO round-trip.
|
||||||
|
ReactFlowDTO.model_validate(payload)
|
||||||
|
_ = wh
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Validation errors at call site ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_field_raises_at_add(client: _StubClient):
|
||||||
|
wf = Workflow(client=client, name="typo")
|
||||||
|
with pytest.raises(ValidationError, match="unknown field"):
|
||||||
|
wf.add(
|
||||||
|
type="startCall",
|
||||||
|
name="greeting",
|
||||||
|
prompt="hi",
|
||||||
|
promt="typo", # extra misspelled field
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_required_raises_at_add(client: _StubClient):
|
||||||
|
wf = Workflow(client=client, name="missing")
|
||||||
|
with pytest.raises(ValidationError, match="required field missing"):
|
||||||
|
wf.add(type="startCall", name="greeting") # no prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_scalar_type_raises(client: _StubClient):
|
||||||
|
wf = Workflow(client=client, name="wrongtype")
|
||||||
|
with pytest.raises(ValidationError, match="expected boolean"):
|
||||||
|
wf.add(
|
||||||
|
type="agentNode",
|
||||||
|
name="x",
|
||||||
|
prompt="y",
|
||||||
|
allow_interrupt="yes",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_options_value_raises(client: _StubClient):
|
||||||
|
wf = Workflow(client=client, name="wrongenum")
|
||||||
|
with pytest.raises(ValidationError, match="not in allowed"):
|
||||||
|
wf.add(
|
||||||
|
type="startCall",
|
||||||
|
name="greeting",
|
||||||
|
prompt="hi",
|
||||||
|
greeting_type="video", # only text|audio allowed
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_node_type_raises(client: _StubClient):
|
||||||
|
wf = Workflow(client=client, name="x")
|
||||||
|
with pytest.raises(ValueError, match="Unknown spec"):
|
||||||
|
wf.add(type="nonExistentType", name="x")
|
||||||
|
|
||||||
|
|
||||||
|
def test_validation_error_surfaces_llm_hint(client: _StubClient):
|
||||||
|
"""When a property carries `llm_hint`, it appears in the error message
|
||||||
|
so LLMs can self-correct on retry. `tool_uuids` on agentNode has the
|
||||||
|
hint 'List of tool UUIDs from `list_tools`.'"""
|
||||||
|
wf = Workflow(client=client, name="hint")
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
wf.add(
|
||||||
|
type="agentNode",
|
||||||
|
name="x",
|
||||||
|
prompt="y",
|
||||||
|
tool_uuids="single-uuid-not-a-list", # wrong shape: str, not list
|
||||||
|
)
|
||||||
|
msg = str(exc_info.value)
|
||||||
|
assert "tool_uuids" in msg
|
||||||
|
assert "Hint:" in msg
|
||||||
|
assert "list_tools" in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_hint_message_when_spec_has_none(client: _StubClient):
|
||||||
|
"""Properties without `llm_hint` produce a plain error (no dangling
|
||||||
|
'Hint:' line)."""
|
||||||
|
wf = Workflow(client=client, name="no-hint")
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
wf.add(type="agentNode", name="x", prompt="y", allow_interrupt="yes")
|
||||||
|
assert "Hint:" not in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_requires_label_and_condition(client: _StubClient):
|
||||||
|
wf = Workflow(client=client, name="edge")
|
||||||
|
a = wf.add(type="startCall", name="a", prompt="hi")
|
||||||
|
b = wf.add(type="endCall", name="b", prompt="bye")
|
||||||
|
with pytest.raises(ValidationError, match="label is required"):
|
||||||
|
wf.edge(a, b, label="", condition="condition")
|
||||||
|
with pytest.raises(ValidationError, match="condition is required"):
|
||||||
|
wf.edge(a, b, label="label", condition="")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Round-trip from_json → edit → to_json ────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_json_preserves_ids_and_next_id_doesnt_collide(client: _StubClient):
|
||||||
|
wf0 = Workflow(client=client, name="w0")
|
||||||
|
start = wf0.add(type="startCall", name="g", prompt="hi")
|
||||||
|
end = wf0.add(type="endCall", name="e", prompt="bye")
|
||||||
|
wf0.edge(start, end, label="done", condition="done")
|
||||||
|
|
||||||
|
payload = wf0.to_json()
|
||||||
|
wf1 = Workflow.from_json(payload, client=client, name="w0-reload")
|
||||||
|
|
||||||
|
# IDs are preserved
|
||||||
|
assert [n.id for n in wf1._nodes] == [start.id, end.id]
|
||||||
|
# Next add() gets a fresh ID, not colliding with the existing ones
|
||||||
|
new_ref = wf1.add(type="agentNode", name="qualify", prompt="ask stuff")
|
||||||
|
assert new_ref.id != start.id
|
||||||
|
assert new_ref.id != end.id
|
||||||
|
assert int(new_ref.id) > max(int(start.id), int(end.id))
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_json_validates_data(client: _StubClient):
|
||||||
|
"""Loading a JSON payload with a misnamed field raises — we don't
|
||||||
|
silently accept drift."""
|
||||||
|
bad = {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"type": "startCall",
|
||||||
|
"position": {"x": 0, "y": 0},
|
||||||
|
"data": {"name": "g", "prompt": "hi", "bogus": 1},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edges": [],
|
||||||
|
}
|
||||||
|
with pytest.raises(ValidationError, match="unknown field"):
|
||||||
|
Workflow.from_json(bad, client=client)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Sanity: all registered specs are reachable by name ───────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_registered_spec_is_reachable_by_sdk(client: _StubClient):
|
||||||
|
wf = Workflow(client=client, name="probe")
|
||||||
|
for spec in all_specs():
|
||||||
|
# Just fetch the spec via the client; doesn't add anything. This
|
||||||
|
# ensures the `_StubClient` wiring works for all types.
|
||||||
|
probe = client.get_node_type(spec.name)
|
||||||
|
assert probe.name == spec.name
|
||||||
|
_ = wf
|
||||||
128
api/tests/test_dograh_sdk_typed.py
Normal file
128
api/tests/test_dograh_sdk_typed.py
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
"""Tests for the typed SDK (`dograh_sdk.typed`).
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- Generated classes import cleanly and declare the correct spec name
|
||||||
|
- `Workflow.add_typed(node)` produces the same wire format as
|
||||||
|
`Workflow.add(type=..., **kwargs)`
|
||||||
|
- Typed-class construction respects required/optional field defaults
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from dograh_sdk import Workflow
|
||||||
|
from dograh_sdk._generated_models import NodeSpec
|
||||||
|
from dograh_sdk.typed import (
|
||||||
|
AgentNode,
|
||||||
|
EndCall,
|
||||||
|
GlobalNode,
|
||||||
|
Qa,
|
||||||
|
StartCall,
|
||||||
|
Trigger,
|
||||||
|
TypedNode,
|
||||||
|
Webhook,
|
||||||
|
)
|
||||||
|
|
||||||
|
from api.services.workflow.dto import ReactFlowDTO
|
||||||
|
from api.services.workflow.node_specs import get_spec
|
||||||
|
|
||||||
|
|
||||||
|
class _StubClient:
|
||||||
|
def get_node_type(self, name: str) -> NodeSpec:
|
||||||
|
return NodeSpec.model_validate(get_spec(name).model_dump(mode="json"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client() -> _StubClient:
|
||||||
|
return _StubClient()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Generated classes declare the correct discriminator ──────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"cls,expected_type",
|
||||||
|
[
|
||||||
|
(StartCall, "startCall"),
|
||||||
|
(AgentNode, "agentNode"),
|
||||||
|
(EndCall, "endCall"),
|
||||||
|
(GlobalNode, "globalNode"),
|
||||||
|
(Trigger, "trigger"),
|
||||||
|
(Webhook, "webhook"),
|
||||||
|
(Qa, "qa"),
|
||||||
|
],
|
||||||
|
ids=lambda v: v.__name__ if isinstance(v, type) else v,
|
||||||
|
)
|
||||||
|
def test_typed_class_declares_spec_name(cls: type[TypedNode], expected_type: str):
|
||||||
|
assert cls.type == expected_type
|
||||||
|
# Instances inherit the ClassVar
|
||||||
|
if cls is StartCall:
|
||||||
|
inst = cls(name="g", prompt="hi")
|
||||||
|
elif cls is AgentNode:
|
||||||
|
inst = cls(name="a", prompt="hi")
|
||||||
|
elif cls is EndCall:
|
||||||
|
inst = cls(name="e", prompt="hi")
|
||||||
|
elif cls is GlobalNode:
|
||||||
|
inst = cls(name="g", prompt="hi")
|
||||||
|
elif cls is Trigger:
|
||||||
|
inst = cls(name="t")
|
||||||
|
elif cls is Webhook:
|
||||||
|
inst = cls(name="wh")
|
||||||
|
else: # Qa
|
||||||
|
inst = cls(name="qa")
|
||||||
|
assert inst.type == expected_type
|
||||||
|
|
||||||
|
|
||||||
|
# ─── add_typed integrates with Workflow and round-trips through DTO ──────
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_typed_builds_valid_workflow(client: _StubClient):
|
||||||
|
wf = Workflow(client=client, name="typed-e2e")
|
||||||
|
start = wf.add_typed(StartCall(name="greeting", prompt="Hi there!"))
|
||||||
|
end = wf.add_typed(EndCall(name="done", prompt="Bye."))
|
||||||
|
wf.edge(start, end, label="done", condition="conversation over")
|
||||||
|
|
||||||
|
payload = wf.to_json()
|
||||||
|
dto = ReactFlowDTO.model_validate(payload)
|
||||||
|
assert len(dto.nodes) == 2
|
||||||
|
assert payload["nodes"][0]["type"] == "startCall"
|
||||||
|
assert payload["nodes"][1]["type"] == "endCall"
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_typed_and_add_produce_identical_data(client: _StubClient):
|
||||||
|
"""The typed path and the generic path should produce identical node
|
||||||
|
data for equivalent inputs."""
|
||||||
|
wf_typed = Workflow(client=client)
|
||||||
|
wf_typed.add_typed(AgentNode(name="q", prompt="ask"))
|
||||||
|
|
||||||
|
wf_generic = Workflow(client=client)
|
||||||
|
wf_generic.add(type="agentNode", name="q", prompt="ask")
|
||||||
|
|
||||||
|
typed_data = wf_typed.to_json()["nodes"][0]["data"]
|
||||||
|
generic_data = wf_generic.to_json()["nodes"][0]["data"]
|
||||||
|
assert typed_data == generic_data
|
||||||
|
|
||||||
|
|
||||||
|
def test_webhook_mutable_defaults_dont_share_state(client: _StubClient):
|
||||||
|
"""Dataclass default_factory ensures every Webhook() gets its own dict."""
|
||||||
|
wf = Workflow(client=client)
|
||||||
|
a = wf.add_typed(Webhook(name="a"))
|
||||||
|
b = wf.add_typed(Webhook(name="b"))
|
||||||
|
payload = wf.to_json()
|
||||||
|
a_data = payload["nodes"][0]["data"]
|
||||||
|
b_data = payload["nodes"][1]["data"]
|
||||||
|
# Both instances must end up with payload_template populated from the
|
||||||
|
# factory; mutating one must not affect the other.
|
||||||
|
assert a_data["payload_template"] is not b_data["payload_template"]
|
||||||
|
_ = a, b
|
||||||
|
|
||||||
|
|
||||||
|
def test_typed_sdk_surfaces_spec_default_to_field(client: _StubClient):
|
||||||
|
"""Spec defaults make it all the way through: StartCall().name defaults
|
||||||
|
to the spec's `"Start Call"` literal."""
|
||||||
|
s = StartCall(prompt="hi")
|
||||||
|
assert s.name == "Start Call"
|
||||||
|
assert s.allow_interrupt is False # matches spec default from earlier edits
|
||||||
|
assert s.add_global_prompt is True
|
||||||
|
|
@ -1,11 +1,98 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from api.services.workflow.dto import ReactFlowDTO
|
from api.services.workflow.dto import ReactFlowDTO, sanitize_workflow_definition
|
||||||
|
|
||||||
|
_FIXTURES_DIR = Path(__file__).parent / "definitions"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_dto():
|
async def test_dto():
|
||||||
# assert no exceptions are raised
|
# Path resolved relative to this test file so the test works regardless
|
||||||
with open("tests/definitions/rf-1.json", "r") as f:
|
# of the cwd pytest is invoked from.
|
||||||
|
with open(_FIXTURES_DIR / "rf-1.json", "r") as f:
|
||||||
dto = ReactFlowDTO.model_validate_json(f.read())
|
dto = ReactFlowDTO.model_validate_json(f.read())
|
||||||
assert dto is not None
|
assert dto is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_strips_ui_runtime_fields():
|
||||||
|
definition = {
|
||||||
|
"viewport": {"x": 0, "y": 0, "zoom": 1},
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "n1",
|
||||||
|
"type": "startCall",
|
||||||
|
"position": {"x": 0, "y": 0},
|
||||||
|
"width": 200, # ReactFlow-computed, preserved
|
||||||
|
"selected": True, # ReactFlow runtime, preserved
|
||||||
|
"data": {
|
||||||
|
"name": "Start",
|
||||||
|
"prompt": "hi",
|
||||||
|
"greeting": "hello",
|
||||||
|
"invalid": True, # UI-only, should be stripped
|
||||||
|
"validationMessage": "oops", # UI-only, should be stripped
|
||||||
|
"mystery_field": 42, # unknown, should be stripped
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "n2",
|
||||||
|
"type": "agentNode",
|
||||||
|
"position": {"x": 1, "y": 1},
|
||||||
|
"data": {"name": "A", "prompt": "p", "invalid": False},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{
|
||||||
|
"id": "e1",
|
||||||
|
"source": "n1",
|
||||||
|
"target": "n2",
|
||||||
|
"data": {
|
||||||
|
"label": "next",
|
||||||
|
"condition": "true",
|
||||||
|
"invalid": True, # UI-only, should be stripped
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
out = sanitize_workflow_definition(definition)
|
||||||
|
|
||||||
|
# Top-level keys preserved
|
||||||
|
assert out["viewport"] == {"x": 0, "y": 0, "zoom": 1}
|
||||||
|
# ReactFlow runtime fields on the node itself preserved
|
||||||
|
assert out["nodes"][0]["width"] == 200
|
||||||
|
assert out["nodes"][0]["selected"] is True
|
||||||
|
|
||||||
|
# node.data stripped of unknowns, known fields kept
|
||||||
|
n1_data = out["nodes"][0]["data"]
|
||||||
|
assert n1_data == {"name": "Start", "prompt": "hi", "greeting": "hello"}
|
||||||
|
assert "invalid" not in n1_data
|
||||||
|
assert "validationMessage" not in n1_data
|
||||||
|
assert "mystery_field" not in n1_data
|
||||||
|
|
||||||
|
n2_data = out["nodes"][1]["data"]
|
||||||
|
assert n2_data == {"name": "A", "prompt": "p"}
|
||||||
|
|
||||||
|
# edge.data stripped
|
||||||
|
assert out["edges"][0]["data"] == {"label": "next", "condition": "true"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_noop_on_empty_and_unknown_types():
|
||||||
|
assert sanitize_workflow_definition(None) is None
|
||||||
|
assert sanitize_workflow_definition({}) == {}
|
||||||
|
|
||||||
|
# Unknown node type: pass through unchanged rather than wipe data
|
||||||
|
definition = {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "n1",
|
||||||
|
"type": "unknownType",
|
||||||
|
"position": {"x": 0, "y": 0},
|
||||||
|
"data": {"anything": "goes"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edges": [],
|
||||||
|
}
|
||||||
|
out = sanitize_workflow_definition(definition)
|
||||||
|
assert out["nodes"][0]["data"] == {"anything": "goes"}
|
||||||
|
|
|
||||||
124
api/tests/test_layout.py
Normal file
124
api/tests/test_layout.py
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
"""Tests for position reconciliation after the LLM save round-trip."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from api.services.workflow.layout import reconcile_positions
|
||||||
|
|
||||||
|
|
||||||
|
def _node(
|
||||||
|
id: str,
|
||||||
|
type: str,
|
||||||
|
*,
|
||||||
|
name: str | None = None,
|
||||||
|
x: float = 0.0,
|
||||||
|
y: float = 0.0,
|
||||||
|
) -> dict:
|
||||||
|
data: dict = {}
|
||||||
|
if name is not None:
|
||||||
|
data["name"] = name
|
||||||
|
return {"id": id, "type": type, "position": {"x": x, "y": y}, "data": data}
|
||||||
|
|
||||||
|
|
||||||
|
def _edge(src: str, tgt: str) -> dict:
|
||||||
|
return {
|
||||||
|
"id": f"{src}-{tgt}",
|
||||||
|
"source": src,
|
||||||
|
"target": tgt,
|
||||||
|
"data": {"label": "x", "condition": "y"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_named_match_preserves_position():
|
||||||
|
previous = {
|
||||||
|
"nodes": [_node("99", "startCall", name="greeting", x=100, y=200)],
|
||||||
|
"edges": [],
|
||||||
|
}
|
||||||
|
new = {
|
||||||
|
"nodes": [_node("1", "startCall", name="greeting")],
|
||||||
|
"edges": [],
|
||||||
|
}
|
||||||
|
out = reconcile_positions(new, previous)
|
||||||
|
assert out["nodes"][0]["position"] == {"x": 100, "y": 200}
|
||||||
|
|
||||||
|
|
||||||
|
def test_unnamed_match_by_type_ordering():
|
||||||
|
previous = {
|
||||||
|
"nodes": [
|
||||||
|
_node("7", "agentNode", x=-648, y=-158),
|
||||||
|
_node("8", "agentNode", x=500, y=-100),
|
||||||
|
],
|
||||||
|
"edges": [],
|
||||||
|
}
|
||||||
|
new = {
|
||||||
|
"nodes": [
|
||||||
|
_node("1", "agentNode"),
|
||||||
|
_node("2", "agentNode"),
|
||||||
|
],
|
||||||
|
"edges": [],
|
||||||
|
}
|
||||||
|
out = reconcile_positions(new, previous)
|
||||||
|
assert out["nodes"][0]["position"] == {"x": -648, "y": -158}
|
||||||
|
assert out["nodes"][1]["position"] == {"x": 500, "y": -100}
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_node_placed_relative_to_incoming_neighbor():
|
||||||
|
previous = {
|
||||||
|
"nodes": [_node("99", "startCall", name="greeting", x=100, y=200)],
|
||||||
|
"edges": [],
|
||||||
|
}
|
||||||
|
new = {
|
||||||
|
"nodes": [
|
||||||
|
_node("1", "startCall", name="greeting"),
|
||||||
|
_node("2", "agentNode", name="new_node"),
|
||||||
|
],
|
||||||
|
"edges": [_edge("1", "2")],
|
||||||
|
}
|
||||||
|
out = reconcile_positions(new, previous)
|
||||||
|
# Start call keeps its previous position.
|
||||||
|
assert out["nodes"][0]["position"] == {"x": 100, "y": 200}
|
||||||
|
# New node offset from its incoming neighbor.
|
||||||
|
assert out["nodes"][1]["position"] == {"x": 500, "y": 400}
|
||||||
|
|
||||||
|
|
||||||
|
def test_orphan_new_node_stays_at_origin():
|
||||||
|
new = {
|
||||||
|
"nodes": [_node("1", "agentNode", name="orphan")],
|
||||||
|
"edges": [],
|
||||||
|
}
|
||||||
|
out = reconcile_positions(new, None)
|
||||||
|
assert out["nodes"][0]["position"] == {"x": 0.0, "y": 0.0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_named_wins_over_unnamed_ordering():
|
||||||
|
previous = {
|
||||||
|
"nodes": [
|
||||||
|
_node("7", "agentNode", x=-648, y=-158), # unnamed
|
||||||
|
_node("8", "agentNode", name="qualify", x=900, y=900),
|
||||||
|
],
|
||||||
|
"edges": [],
|
||||||
|
}
|
||||||
|
new = {
|
||||||
|
"nodes": [
|
||||||
|
_node("1", "agentNode", name="qualify"), # matches named
|
||||||
|
_node("2", "agentNode"), # falls to unnamed queue
|
||||||
|
],
|
||||||
|
"edges": [],
|
||||||
|
}
|
||||||
|
out = reconcile_positions(new, previous)
|
||||||
|
assert out["nodes"][0]["position"] == {"x": 900, "y": 900}
|
||||||
|
assert out["nodes"][1]["position"] == {"x": -648, "y": -158}
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_previous_keeps_origin_for_all_matched_positions():
|
||||||
|
new = {
|
||||||
|
"nodes": [
|
||||||
|
_node("1", "startCall", name="greeting"),
|
||||||
|
_node("2", "agentNode", name="reply"),
|
||||||
|
],
|
||||||
|
"edges": [_edge("1", "2")],
|
||||||
|
}
|
||||||
|
out = reconcile_positions(new, None)
|
||||||
|
# No previous → first node stays at origin (no incoming), second
|
||||||
|
# node placed relative to its incoming neighbor at origin.
|
||||||
|
assert out["nodes"][0]["position"] == {"x": 0.0, "y": 0.0}
|
||||||
|
assert out["nodes"][1]["position"] == {"x": 400.0, "y": 200.0}
|
||||||
225
api/tests/test_mcp_save_workflow.py
Normal file
225
api/tests/test_mcp_save_workflow.py
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
"""Integration tests for the `save_workflow` MCP tool.
|
||||||
|
|
||||||
|
Mocks `authenticate_mcp_request` and the db_client so tests don't need
|
||||||
|
a live DB, but exercises the real TS validator subprocess end-to-end —
|
||||||
|
parse is part of the contract the LLM relies on.
|
||||||
|
|
||||||
|
Round-trip and pure-parser tests live in `test_ts_bridge.py`; this file
|
||||||
|
focuses on the MCP tool's error-routing, version tagging, and DB-call
|
||||||
|
shape.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from api.mcp_server.tools.save_workflow import save_workflow
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.skipif(
|
||||||
|
shutil.which("node") is None, reason="node binary not available"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Fixtures & helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _FakeDraft:
|
||||||
|
version_number: int = 2
|
||||||
|
status: str = "draft"
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeWorkflowModel:
|
||||||
|
id = 1
|
||||||
|
organization_id = 1
|
||||||
|
name = "test"
|
||||||
|
# reconcile_positions reads whichever of these holds the previous
|
||||||
|
# stored workflow JSON; None on all three is fine for a greenfield
|
||||||
|
# test and causes reconcile_positions to fall back to the placement
|
||||||
|
# heuristic for any new node.
|
||||||
|
current_definition = None
|
||||||
|
released_definition = None
|
||||||
|
workflow_definition = None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def authed_user() -> MagicMock:
|
||||||
|
user = MagicMock()
|
||||||
|
user.selected_organization_id = 1
|
||||||
|
user.id = 1
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_backends(authed_user: MagicMock):
|
||||||
|
save_mock = AsyncMock(return_value=_FakeDraft())
|
||||||
|
update_mock = AsyncMock(return_value=_FakeWorkflowModel())
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"api.mcp_server.tools.save_workflow.authenticate_mcp_request",
|
||||||
|
AsyncMock(return_value=authed_user),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"api.mcp_server.tools.save_workflow.db_client.get_workflow",
|
||||||
|
AsyncMock(return_value=_FakeWorkflowModel()),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"api.mcp_server.tools.save_workflow.db_client.save_workflow_draft",
|
||||||
|
save_mock,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"api.mcp_server.tools.save_workflow.db_client.update_workflow",
|
||||||
|
update_mock,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"api.mcp_server.tools.save_workflow.db_client.get_draft_version",
|
||||||
|
AsyncMock(return_value=None),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
yield save_mock, update_mock
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_code(name: str = "tool-test") -> str:
|
||||||
|
return f'''import {{ Workflow }} from "@dograh/sdk";
|
||||||
|
import {{ startCall, endCall }} from "@dograh/sdk/typed";
|
||||||
|
|
||||||
|
const wf = new Workflow({{ name: "{name}" }});
|
||||||
|
|
||||||
|
const greeting = wf.addTyped(startCall({{ name: "greeting", prompt: "Hi!" }}));
|
||||||
|
const done = wf.addTyped(endCall({{ name: "done", prompt: "Bye." }}));
|
||||||
|
|
||||||
|
wf.edge(greeting, done, {{ label: "done", condition: "conversation complete" }});
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Happy path ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_happy_path_saves_draft(mock_backends):
|
||||||
|
save_mock, update_mock = mock_backends
|
||||||
|
# Match the stored name so the rename branch stays dormant here.
|
||||||
|
result = await save_workflow(
|
||||||
|
workflow_id=1, code=_valid_code(name=_FakeWorkflowModel.name)
|
||||||
|
)
|
||||||
|
assert result["saved"] is True
|
||||||
|
assert result["workflow_id"] == 1
|
||||||
|
assert result["version_number"] == 2
|
||||||
|
assert result["status"] == "draft"
|
||||||
|
assert result["node_count"] == 2
|
||||||
|
assert result["edge_count"] == 1
|
||||||
|
assert result["renamed"] is False
|
||||||
|
assert result["name"] == _FakeWorkflowModel.name
|
||||||
|
save_mock.assert_awaited_once()
|
||||||
|
update_mock.assert_not_awaited()
|
||||||
|
payload = save_mock.call_args.kwargs["workflow_definition"]
|
||||||
|
assert len(payload["nodes"]) == 2
|
||||||
|
assert len(payload["edges"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rename_propagates_to_update_workflow(mock_backends):
|
||||||
|
save_mock, update_mock = mock_backends
|
||||||
|
result = await save_workflow(workflow_id=1, code=_valid_code(name="renamed"))
|
||||||
|
assert result["saved"] is True
|
||||||
|
assert result["renamed"] is True
|
||||||
|
assert result["name"] == "renamed"
|
||||||
|
update_mock.assert_awaited_once()
|
||||||
|
kwargs = update_mock.call_args.kwargs
|
||||||
|
assert kwargs["workflow_id"] == 1
|
||||||
|
assert kwargs["name"] == "renamed"
|
||||||
|
assert kwargs["workflow_definition"] is None
|
||||||
|
assert kwargs["organization_id"] == 1
|
||||||
|
save_mock.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Parse-stage rejections ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parser_rejects_disallowed_top_level(mock_backends):
|
||||||
|
save_mock, update_mock = mock_backends
|
||||||
|
code = _valid_code() + "function evil() { return 1; }\n"
|
||||||
|
result = await save_workflow(workflow_id=1, code=code)
|
||||||
|
assert result["saved"] is False
|
||||||
|
assert result["error_code"] == "parse_error"
|
||||||
|
save_mock.assert_not_awaited()
|
||||||
|
update_mock.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parser_rejects_unknown_factory(mock_backends):
|
||||||
|
save_mock, update_mock = mock_backends
|
||||||
|
code = """import { Workflow } from "@dograh/sdk";
|
||||||
|
const wf = new Workflow({ name: "x" });
|
||||||
|
const n = wf.addTyped(fakeNode({ name: "x", prompt: "y" }));
|
||||||
|
"""
|
||||||
|
result = await save_workflow(workflow_id=1, code=code)
|
||||||
|
assert result["saved"] is False
|
||||||
|
assert result["error_code"] == "parse_error"
|
||||||
|
assert "Unknown node type" in result["error"]
|
||||||
|
save_mock.assert_not_awaited()
|
||||||
|
update_mock.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Validation-stage rejections ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unknown_field_surfaces_validation_error(mock_backends):
|
||||||
|
save_mock, update_mock = mock_backends
|
||||||
|
code = """import { Workflow } from "@dograh/sdk";
|
||||||
|
import { startCall } from "@dograh/sdk/typed";
|
||||||
|
const wf = new Workflow({ name: "x" });
|
||||||
|
const n = wf.addTyped(startCall({ name: "g", prompt: "hi", promt: "typo" }));
|
||||||
|
"""
|
||||||
|
result = await save_workflow(workflow_id=1, code=code)
|
||||||
|
assert result["saved"] is False
|
||||||
|
assert result["error_code"] == "validation_error"
|
||||||
|
assert "Unknown field" in result["error"]
|
||||||
|
save_mock.assert_not_awaited()
|
||||||
|
update_mock.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Graph-stage rejections ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_graph_validation_catches_missing_start_node(mock_backends):
|
||||||
|
save_mock, update_mock = mock_backends
|
||||||
|
# Only an end node — WorkflowGraph requires exactly one start node.
|
||||||
|
code = """import { Workflow } from "@dograh/sdk";
|
||||||
|
import { endCall } from "@dograh/sdk/typed";
|
||||||
|
const wf = new Workflow({ name: "orphan" });
|
||||||
|
const only = wf.addTyped(endCall({ name: "only", prompt: "bye" }));
|
||||||
|
"""
|
||||||
|
result = await save_workflow(workflow_id=1, code=code)
|
||||||
|
assert result["saved"] is False
|
||||||
|
assert result["error_code"] == "graph_validation"
|
||||||
|
save_mock.assert_not_awaited()
|
||||||
|
update_mock.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Workflow not found / unauthorized ───────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unknown_workflow_raises_404(authed_user: MagicMock):
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"api.mcp_server.tools.save_workflow.authenticate_mcp_request",
|
||||||
|
AsyncMock(return_value=authed_user),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"api.mcp_server.tools.save_workflow.db_client.get_workflow",
|
||||||
|
AsyncMock(return_value=None),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await save_workflow(workflow_id=999, code=_valid_code())
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
196
api/tests/test_node_specs.py
Normal file
196
api/tests/test_node_specs.py
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
"""Spec-quality lint.
|
||||||
|
|
||||||
|
Catches drift between NodeSpecs and the rest of the system before it lands:
|
||||||
|
- Placeholder/empty descriptions
|
||||||
|
- Missing examples
|
||||||
|
- display_options referencing fields that don't exist
|
||||||
|
- Examples that don't validate against the per-type Pydantic DTO
|
||||||
|
- Spec name not matching a discriminator value in dto.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from api.services.workflow.dto import NodeType, ReactFlowDTO
|
||||||
|
from api.services.workflow.node_specs import (
|
||||||
|
NodeSpec,
|
||||||
|
PropertySpec,
|
||||||
|
PropertyType,
|
||||||
|
all_specs,
|
||||||
|
)
|
||||||
|
|
||||||
|
PLACEHOLDER_DESCRIPTION_PATTERN = re.compile(
|
||||||
|
r"^\s*(todo|fixme|tbd|xxx|\.\.\.|placeholder|description|n/?a|\?)\s*\.?\s*$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _walk_properties(props: list[PropertySpec], path: str = ""):
|
||||||
|
"""Yield (full_path, property) for every property and nested sub-property."""
|
||||||
|
for prop in props:
|
||||||
|
full_path = f"{path}.{prop.name}" if path else prop.name
|
||||||
|
yield full_path, prop
|
||||||
|
if prop.properties:
|
||||||
|
yield from _walk_properties(prop.properties, full_path)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
# Lint
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("spec", all_specs(), ids=lambda s: s.name)
|
||||||
|
def test_node_spec_has_non_placeholder_description(spec: NodeSpec):
|
||||||
|
assert spec.description.strip(), f"{spec.name}: empty description"
|
||||||
|
assert not PLACEHOLDER_DESCRIPTION_PATTERN.match(spec.description), (
|
||||||
|
f"{spec.name}: description looks like a placeholder: {spec.description!r}"
|
||||||
|
)
|
||||||
|
assert len(spec.description) >= 20, (
|
||||||
|
f"{spec.name}: description too short to be useful for an LLM "
|
||||||
|
f"({len(spec.description)} chars)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("spec", all_specs(), ids=lambda s: s.name)
|
||||||
|
def test_node_spec_has_at_least_one_example(spec: NodeSpec):
|
||||||
|
assert spec.examples, (
|
||||||
|
f"{spec.name}: must have at least one NodeExample so LLMs have a "
|
||||||
|
f"realistic shape to pattern-match."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("spec", all_specs(), ids=lambda s: s.name)
|
||||||
|
def test_property_descriptions_non_placeholder(spec: NodeSpec):
|
||||||
|
for path, prop in _walk_properties(spec.properties):
|
||||||
|
assert prop.description.strip(), f"{spec.name}.{path}: empty description"
|
||||||
|
assert not PLACEHOLDER_DESCRIPTION_PATTERN.match(prop.description), (
|
||||||
|
f"{spec.name}.{path}: description looks like a placeholder: "
|
||||||
|
f"{prop.description!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("spec", all_specs(), ids=lambda s: s.name)
|
||||||
|
def test_display_options_reference_real_fields(spec: NodeSpec):
|
||||||
|
"""A property's display_options must only reference sibling property
|
||||||
|
names. Nested properties are scoped to their parent's siblings."""
|
||||||
|
|
||||||
|
def _check(scope_props: list[PropertySpec], scope_path: str = ""):
|
||||||
|
names_in_scope = {p.name for p in scope_props}
|
||||||
|
for prop in scope_props:
|
||||||
|
current_path = f"{scope_path}.{prop.name}" if scope_path else prop.name
|
||||||
|
if prop.display_options:
|
||||||
|
refs = set((prop.display_options.show or {}).keys()) | set(
|
||||||
|
(prop.display_options.hide or {}).keys()
|
||||||
|
)
|
||||||
|
missing = refs - names_in_scope
|
||||||
|
assert not missing, (
|
||||||
|
f"{spec.name}.{current_path}: display_options references "
|
||||||
|
f"unknown sibling fields: {sorted(missing)}"
|
||||||
|
)
|
||||||
|
if prop.properties:
|
||||||
|
_check(prop.properties, current_path)
|
||||||
|
|
||||||
|
_check(spec.properties)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("spec", all_specs(), ids=lambda s: s.name)
|
||||||
|
def test_options_properties_have_options(spec: NodeSpec):
|
||||||
|
for path, prop in _walk_properties(spec.properties):
|
||||||
|
if prop.type in (PropertyType.options, PropertyType.multi_options):
|
||||||
|
assert prop.options, (
|
||||||
|
f"{spec.name}.{path}: type={prop.type.value} requires at "
|
||||||
|
f"least one PropertyOption."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("spec", all_specs(), ids=lambda s: s.name)
|
||||||
|
def test_fixed_collection_has_sub_properties(spec: NodeSpec):
|
||||||
|
for path, prop in _walk_properties(spec.properties):
|
||||||
|
if prop.type == PropertyType.fixed_collection:
|
||||||
|
assert prop.properties, (
|
||||||
|
f"{spec.name}.{path}: fixed_collection requires nested "
|
||||||
|
f"`properties` describing each row."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("spec", all_specs(), ids=lambda s: s.name)
|
||||||
|
def test_spec_name_matches_dto_discriminator(spec: NodeSpec):
|
||||||
|
valid_names = {t.value for t in NodeType}
|
||||||
|
assert spec.name in valid_names, (
|
||||||
|
f"NodeSpec {spec.name!r} doesn't match any NodeType discriminator. "
|
||||||
|
f"Valid: {sorted(valid_names)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("spec", all_specs(), ids=lambda s: s.name)
|
||||||
|
def test_examples_validate_against_dto(spec: NodeSpec):
|
||||||
|
"""Each NodeExample.data must pass per-type DTO validation. This stops
|
||||||
|
examples from drifting away from the actual wire schema."""
|
||||||
|
for ex in spec.examples:
|
||||||
|
wire_node = {
|
||||||
|
"id": "example",
|
||||||
|
"type": spec.name,
|
||||||
|
"position": {"x": 0, "y": 0},
|
||||||
|
"data": ex.data,
|
||||||
|
}
|
||||||
|
# Build a minimal valid graph: example node plus a synthetic peer if
|
||||||
|
# graph_constraints require an incoming or outgoing edge.
|
||||||
|
nodes = [wire_node]
|
||||||
|
edges: list[dict] = []
|
||||||
|
constraints = spec.graph_constraints
|
||||||
|
|
||||||
|
if constraints and (constraints.min_outgoing or 0) > 0:
|
||||||
|
nodes.append(
|
||||||
|
{
|
||||||
|
"id": "downstream",
|
||||||
|
"type": "endCall",
|
||||||
|
"position": {"x": 0, "y": 0},
|
||||||
|
"data": {"name": "End", "prompt": "End", "is_end": True},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
edges.append(
|
||||||
|
{
|
||||||
|
"id": "e_out",
|
||||||
|
"source": "example",
|
||||||
|
"target": "downstream",
|
||||||
|
"data": {"label": "next", "condition": "next"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if constraints and (constraints.min_incoming or 0) > 0:
|
||||||
|
nodes.append(
|
||||||
|
{
|
||||||
|
"id": "upstream",
|
||||||
|
"type": "startCall",
|
||||||
|
"position": {"x": 0, "y": 0},
|
||||||
|
"data": {
|
||||||
|
"name": "Start",
|
||||||
|
"prompt": "Hello",
|
||||||
|
"is_start": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
edges.append(
|
||||||
|
{
|
||||||
|
"id": "e_in",
|
||||||
|
"source": "upstream",
|
||||||
|
"target": "example",
|
||||||
|
"data": {"label": "in", "condition": "in"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate. If this raises, the example is broken.
|
||||||
|
ReactFlowDTO.model_validate({"nodes": nodes, "edges": edges})
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_dto_types_have_specs():
|
||||||
|
"""Every NodeType discriminator value must have a registered NodeSpec —
|
||||||
|
catches the case where someone adds a new node type to dto.py but
|
||||||
|
forgets to author a spec."""
|
||||||
|
spec_names = {s.name for s in all_specs()}
|
||||||
|
type_values = {t.value for t in NodeType}
|
||||||
|
missing = type_values - spec_names
|
||||||
|
assert not missing, f"NodeType discriminators without specs: {sorted(missing)}"
|
||||||
|
|
@ -27,12 +27,13 @@ import pytest
|
||||||
from api.enums import ToolCategory
|
from api.enums import ToolCategory
|
||||||
from api.services.workflow.dto import (
|
from api.services.workflow.dto import (
|
||||||
EdgeDataDTO,
|
EdgeDataDTO,
|
||||||
NodeDataDTO,
|
EndCallNodeData,
|
||||||
NodeType,
|
EndCallRFNode,
|
||||||
Position,
|
Position,
|
||||||
ReactFlowDTO,
|
ReactFlowDTO,
|
||||||
RFEdgeDTO,
|
RFEdgeDTO,
|
||||||
RFNodeDTO,
|
StartCallNodeData,
|
||||||
|
StartCallRFNode,
|
||||||
)
|
)
|
||||||
from api.services.workflow.pipecat_engine import PipecatEngine
|
from api.services.workflow.pipecat_engine import PipecatEngine
|
||||||
from api.services.workflow.pipecat_engine_custom_tools import CustomToolManager
|
from api.services.workflow.pipecat_engine_custom_tools import CustomToolManager
|
||||||
|
|
@ -1013,11 +1014,10 @@ class TestEndCallExtractionBehavior:
|
||||||
# Create a workflow where start node has NO extraction
|
# Create a workflow where start node has NO extraction
|
||||||
dto = ReactFlowDTO(
|
dto = ReactFlowDTO(
|
||||||
nodes=[
|
nodes=[
|
||||||
RFNodeDTO(
|
StartCallRFNode(
|
||||||
id="start",
|
id="start",
|
||||||
type=NodeType.startNode,
|
|
||||||
position=Position(x=0, y=0),
|
position=Position(x=0, y=0),
|
||||||
data=NodeDataDTO(
|
data=StartCallNodeData(
|
||||||
name="Start Call",
|
name="Start Call",
|
||||||
prompt=START_CALL_SYSTEM_PROMPT,
|
prompt=START_CALL_SYSTEM_PROMPT,
|
||||||
is_start=True,
|
is_start=True,
|
||||||
|
|
@ -1026,11 +1026,10 @@ class TestEndCallExtractionBehavior:
|
||||||
extraction_enabled=False, # No extraction
|
extraction_enabled=False, # No extraction
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
RFNodeDTO(
|
EndCallRFNode(
|
||||||
id="end",
|
id="end",
|
||||||
type=NodeType.endNode,
|
|
||||||
position=Position(x=0, y=200),
|
position=Position(x=0, y=200),
|
||||||
data=NodeDataDTO(
|
data=EndCallNodeData(
|
||||||
name="End Call",
|
name="End Call",
|
||||||
prompt=END_CALL_SYSTEM_PROMPT,
|
prompt=END_CALL_SYSTEM_PROMPT,
|
||||||
is_end=True,
|
is_end=True,
|
||||||
|
|
|
||||||
99
api/tests/test_sdk_sync.py
Normal file
99
api/tests/test_sdk_sync.py
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
"""Drift guard: committed SDK typed files must match what codegen
|
||||||
|
produces from the current `node_specs/` registry.
|
||||||
|
|
||||||
|
Fails loudly if a spec was edited without running
|
||||||
|
`./scripts/generate_sdk.sh`. CI also runs the full script and asserts
|
||||||
|
an empty `git diff` as the authoritative cross-language check; this
|
||||||
|
test is the fast local feedback loop inside pytest.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Ensure the Python SDK package is importable without requiring a
|
||||||
|
# `pip install -e sdk/python`. The codegen lives there because it ships
|
||||||
|
# with the SDK wheel, but tests need to reach it directly.
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
SDK_PY_SRC = REPO_ROOT / "sdk" / "python" / "src"
|
||||||
|
if str(SDK_PY_SRC) not in sys.path:
|
||||||
|
sys.path.insert(0, str(SDK_PY_SRC))
|
||||||
|
|
||||||
|
from dograh_sdk.codegen import generate_all # noqa: E402
|
||||||
|
|
||||||
|
from api.services.workflow.node_specs import SPEC_VERSION, all_specs # noqa: E402
|
||||||
|
|
||||||
|
PY_OUT = REPO_ROOT / "sdk" / "python" / "src" / "dograh_sdk" / "typed"
|
||||||
|
TS_OUT = REPO_ROOT / "sdk" / "typescript" / "src" / "typed"
|
||||||
|
TS_CODEGEN = REPO_ROOT / "sdk" / "typescript" / "scripts" / "codegen.mts"
|
||||||
|
REGEN_HINT = "Run ./scripts/generate_sdk.sh to regenerate."
|
||||||
|
|
||||||
|
|
||||||
|
def _specs_payload() -> dict:
|
||||||
|
return {
|
||||||
|
"spec_version": SPEC_VERSION,
|
||||||
|
"node_types": [s.model_dump(mode="json") for s in all_specs()],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _compare_trees(expected_dir: Path, actual_dir: Path, *, skip: set[str]) -> None:
|
||||||
|
def tree(d: Path) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
p.name: p.read_text()
|
||||||
|
for p in d.iterdir()
|
||||||
|
if p.is_file() and p.name not in skip
|
||||||
|
}
|
||||||
|
|
||||||
|
expected = tree(expected_dir)
|
||||||
|
actual = tree(actual_dir)
|
||||||
|
|
||||||
|
if expected.keys() != actual.keys():
|
||||||
|
pytest.fail(
|
||||||
|
f"File set differs in {expected_dir.name}/.\n"
|
||||||
|
f" committed: {sorted(expected)}\n"
|
||||||
|
f" generated: {sorted(actual)}\n"
|
||||||
|
f"{REGEN_HINT}"
|
||||||
|
)
|
||||||
|
for name in sorted(expected):
|
||||||
|
if expected[name] != actual[name]:
|
||||||
|
pytest.fail(
|
||||||
|
f"{expected_dir.name}/{name} is out of sync with node_specs. "
|
||||||
|
f"{REGEN_HINT}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_python_sdk_typed_in_sync(tmp_path: Path) -> None:
|
||||||
|
specs = _specs_payload()["node_types"]
|
||||||
|
generate_all(specs, tmp_path)
|
||||||
|
# _base.py is hand-written and lives alongside generated files.
|
||||||
|
_compare_trees(PY_OUT, tmp_path, skip={"_base.py", "__pycache__"})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(shutil.which("node") is None, reason="node binary not available")
|
||||||
|
def test_typescript_sdk_typed_in_sync(tmp_path: Path) -> None:
|
||||||
|
specs_file = tmp_path / "specs.json"
|
||||||
|
specs_file.write_text(json.dumps(_specs_payload()))
|
||||||
|
out = tmp_path / "ts_out"
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"node",
|
||||||
|
str(TS_CODEGEN),
|
||||||
|
"--input",
|
||||||
|
str(specs_file),
|
||||||
|
"--out",
|
||||||
|
str(out),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, (
|
||||||
|
f"TS codegen failed:\nstdout: {result.stdout}\nstderr: {result.stderr}"
|
||||||
|
)
|
||||||
|
_compare_trees(TS_OUT, out, skip=set())
|
||||||
|
|
@ -15,12 +15,13 @@ import pytest
|
||||||
from api.services.pipecat.recording_audio_cache import RecordingAudio
|
from api.services.pipecat.recording_audio_cache import RecordingAudio
|
||||||
from api.services.workflow.dto import (
|
from api.services.workflow.dto import (
|
||||||
EdgeDataDTO,
|
EdgeDataDTO,
|
||||||
NodeDataDTO,
|
EndCallNodeData,
|
||||||
NodeType,
|
EndCallRFNode,
|
||||||
Position,
|
Position,
|
||||||
ReactFlowDTO,
|
ReactFlowDTO,
|
||||||
RFEdgeDTO,
|
RFEdgeDTO,
|
||||||
RFNodeDTO,
|
StartCallNodeData,
|
||||||
|
StartCallRFNode,
|
||||||
)
|
)
|
||||||
from api.services.workflow.pipecat_engine import PipecatEngine
|
from api.services.workflow.pipecat_engine import PipecatEngine
|
||||||
from api.services.workflow.pipecat_engine_custom_tools import CustomToolManager
|
from api.services.workflow.pipecat_engine_custom_tools import CustomToolManager
|
||||||
|
|
@ -64,11 +65,10 @@ def text_workflow() -> WorkflowGraph:
|
||||||
"""Start->End workflow with text greeting and text transition speech."""
|
"""Start->End workflow with text greeting and text transition speech."""
|
||||||
dto = ReactFlowDTO(
|
dto = ReactFlowDTO(
|
||||||
nodes=[
|
nodes=[
|
||||||
RFNodeDTO(
|
StartCallRFNode(
|
||||||
id="start",
|
id="start",
|
||||||
type=NodeType.startNode,
|
|
||||||
position=Position(x=0, y=0),
|
position=Position(x=0, y=0),
|
||||||
data=NodeDataDTO(
|
data=StartCallNodeData(
|
||||||
name="Start Call",
|
name="Start Call",
|
||||||
prompt=START_PROMPT,
|
prompt=START_PROMPT,
|
||||||
is_start=True,
|
is_start=True,
|
||||||
|
|
@ -79,11 +79,10 @@ def text_workflow() -> WorkflowGraph:
|
||||||
extraction_enabled=False,
|
extraction_enabled=False,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
RFNodeDTO(
|
EndCallRFNode(
|
||||||
id="end",
|
id="end",
|
||||||
type=NodeType.endNode,
|
|
||||||
position=Position(x=0, y=200),
|
position=Position(x=0, y=200),
|
||||||
data=NodeDataDTO(
|
data=EndCallNodeData(
|
||||||
name="End Call",
|
name="End Call",
|
||||||
prompt=END_PROMPT,
|
prompt=END_PROMPT,
|
||||||
is_end=True,
|
is_end=True,
|
||||||
|
|
@ -115,11 +114,10 @@ def audio_workflow() -> WorkflowGraph:
|
||||||
"""Start->End workflow with audio greeting and audio transition speech."""
|
"""Start->End workflow with audio greeting and audio transition speech."""
|
||||||
dto = ReactFlowDTO(
|
dto = ReactFlowDTO(
|
||||||
nodes=[
|
nodes=[
|
||||||
RFNodeDTO(
|
StartCallRFNode(
|
||||||
id="start",
|
id="start",
|
||||||
type=NodeType.startNode,
|
|
||||||
position=Position(x=0, y=0),
|
position=Position(x=0, y=0),
|
||||||
data=NodeDataDTO(
|
data=StartCallNodeData(
|
||||||
name="Start Call",
|
name="Start Call",
|
||||||
prompt=START_PROMPT,
|
prompt=START_PROMPT,
|
||||||
is_start=True,
|
is_start=True,
|
||||||
|
|
@ -130,11 +128,10 @@ def audio_workflow() -> WorkflowGraph:
|
||||||
extraction_enabled=False,
|
extraction_enabled=False,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
RFNodeDTO(
|
EndCallRFNode(
|
||||||
id="end",
|
id="end",
|
||||||
type=NodeType.endNode,
|
|
||||||
position=Position(x=0, y=200),
|
position=Position(x=0, y=200),
|
||||||
data=NodeDataDTO(
|
data=EndCallNodeData(
|
||||||
name="End Call",
|
name="End Call",
|
||||||
prompt=END_PROMPT,
|
prompt=END_PROMPT,
|
||||||
is_end=True,
|
is_end=True,
|
||||||
|
|
@ -293,11 +290,10 @@ class TestStartGreeting:
|
||||||
"""No greeting configured should return None."""
|
"""No greeting configured should return None."""
|
||||||
dto = ReactFlowDTO(
|
dto = ReactFlowDTO(
|
||||||
nodes=[
|
nodes=[
|
||||||
RFNodeDTO(
|
StartCallRFNode(
|
||||||
id="start",
|
id="start",
|
||||||
type=NodeType.startNode,
|
|
||||||
position=Position(x=0, y=0),
|
position=Position(x=0, y=0),
|
||||||
data=NodeDataDTO(
|
data=StartCallNodeData(
|
||||||
name="Start",
|
name="Start",
|
||||||
prompt="Prompt",
|
prompt="Prompt",
|
||||||
is_start=True,
|
is_start=True,
|
||||||
|
|
@ -305,11 +301,10 @@ class TestStartGreeting:
|
||||||
extraction_enabled=False,
|
extraction_enabled=False,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
RFNodeDTO(
|
EndCallRFNode(
|
||||||
id="end",
|
id="end",
|
||||||
type=NodeType.endNode,
|
|
||||||
position=Position(x=0, y=200),
|
position=Position(x=0, y=200),
|
||||||
data=NodeDataDTO(
|
data=EndCallNodeData(
|
||||||
name="End",
|
name="End",
|
||||||
prompt="End",
|
prompt="End",
|
||||||
is_end=True,
|
is_end=True,
|
||||||
|
|
@ -338,11 +333,10 @@ class TestStartGreeting:
|
||||||
"""Text greeting with {{variable}} placeholders should be rendered."""
|
"""Text greeting with {{variable}} placeholders should be rendered."""
|
||||||
dto = ReactFlowDTO(
|
dto = ReactFlowDTO(
|
||||||
nodes=[
|
nodes=[
|
||||||
RFNodeDTO(
|
StartCallRFNode(
|
||||||
id="start",
|
id="start",
|
||||||
type=NodeType.startNode,
|
|
||||||
position=Position(x=0, y=0),
|
position=Position(x=0, y=0),
|
||||||
data=NodeDataDTO(
|
data=StartCallNodeData(
|
||||||
name="Start",
|
name="Start",
|
||||||
prompt="Prompt",
|
prompt="Prompt",
|
||||||
is_start=True,
|
is_start=True,
|
||||||
|
|
@ -352,11 +346,10 @@ class TestStartGreeting:
|
||||||
extraction_enabled=False,
|
extraction_enabled=False,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
RFNodeDTO(
|
EndCallRFNode(
|
||||||
id="end",
|
id="end",
|
||||||
type=NodeType.endNode,
|
|
||||||
position=Position(x=0, y=200),
|
position=Position(x=0, y=200),
|
||||||
data=NodeDataDTO(
|
data=EndCallNodeData(
|
||||||
name="End",
|
name="End",
|
||||||
prompt="End",
|
prompt="End",
|
||||||
is_end=True,
|
is_end=True,
|
||||||
|
|
|
||||||
275
api/tests/test_ts_bridge.py
Normal file
275
api/tests/test_ts_bridge.py
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
"""End-to-end tests for the Node TS validator bridge.
|
||||||
|
|
||||||
|
Exercises the real `node` subprocess — slow-ish but the whole point is
|
||||||
|
that code → JSON and JSON → code round-trip losslessly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from api.mcp_server.ts_bridge import TsBridgeError, generate_code, parse_code
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.skipif(
|
||||||
|
shutil.which("node") is None, reason="node binary not available"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _minimal_workflow() -> dict:
|
||||||
|
"""Start → End, one edge. Stored shape matches ReactFlowDTO."""
|
||||||
|
return {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"type": "startCall",
|
||||||
|
"position": {"x": 0, "y": 0},
|
||||||
|
"data": {
|
||||||
|
"name": "Greeting",
|
||||||
|
"prompt": "Greet warmly.",
|
||||||
|
"greeting_type": "text",
|
||||||
|
"greeting": "Hi {{first_name}}!",
|
||||||
|
"allow_interrupt": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"type": "endCall",
|
||||||
|
"position": {"x": 200, "y": 0},
|
||||||
|
"data": {"name": "Done", "prompt": "Say goodbye."},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{
|
||||||
|
"id": "1-2",
|
||||||
|
"source": "1",
|
||||||
|
"target": "2",
|
||||||
|
"data": {"label": "done", "condition": "conversation complete"},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"viewport": {"x": 0, "y": 0, "zoom": 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize(wf: dict) -> dict:
|
||||||
|
"""Strip cosmetics before comparing a round-tripped workflow.
|
||||||
|
|
||||||
|
Node IDs are regenerated deterministically by the parser
|
||||||
|
(1, 2, 3, ...) so the inputs already match if constructed that way.
|
||||||
|
Position is preserved. Edge ids follow `source-target`.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": n["id"],
|
||||||
|
"type": n["type"],
|
||||||
|
"position": n["position"],
|
||||||
|
"data": n["data"],
|
||||||
|
}
|
||||||
|
for n in wf["nodes"]
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{
|
||||||
|
"id": e["id"],
|
||||||
|
"source": e["source"],
|
||||||
|
"target": e["target"],
|
||||||
|
"data": e["data"],
|
||||||
|
}
|
||||||
|
for e in wf["edges"]
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── generate_code ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_emits_imports_and_factories():
|
||||||
|
code = await generate_code(_minimal_workflow(), workflow_name="test")
|
||||||
|
assert 'import { Workflow } from "@dograh/sdk";' in code
|
||||||
|
assert "startCall" in code
|
||||||
|
assert "endCall" in code
|
||||||
|
assert "wf.addTyped(startCall(" in code
|
||||||
|
assert "wf.edge(" in code
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_strips_spec_defaults():
|
||||||
|
wf = _minimal_workflow()
|
||||||
|
code = await generate_code(wf)
|
||||||
|
# `add_global_prompt=True` is a spec default for startCall; emitted
|
||||||
|
# code should omit it. Keeps the LLM-facing projection tight.
|
||||||
|
assert "add_global_prompt" not in code
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_omits_position():
|
||||||
|
"""Positions are hidden from the LLM — auto-layout post-processing
|
||||||
|
(future) reassigns them on save. Keeping them out of the edit
|
||||||
|
surface avoids the LLM producing cramped/overlapping layouts."""
|
||||||
|
wf = _minimal_workflow()
|
||||||
|
code = await generate_code(wf)
|
||||||
|
assert "position" not in code
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_strips_legacy_ui_state_fields():
|
||||||
|
"""Stored workflows from before spec validation carry UI-state fields
|
||||||
|
(`invalid`, `selected`, `is_start`, etc.). `get_workflow_code` hides
|
||||||
|
those from the LLM so edits don't round-trip the noise."""
|
||||||
|
wf = {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"type": "startCall",
|
||||||
|
"position": {"x": 0, "y": 0},
|
||||||
|
"data": {
|
||||||
|
"name": "g",
|
||||||
|
"prompt": "hi",
|
||||||
|
"invalid": False,
|
||||||
|
"validationMessage": None,
|
||||||
|
"is_start": True,
|
||||||
|
"selected": True,
|
||||||
|
"dragging": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"edges": [],
|
||||||
|
"viewport": {"x": 0, "y": 0, "zoom": 1},
|
||||||
|
}
|
||||||
|
code = await generate_code(wf)
|
||||||
|
for dropped in ("invalid", "validationMessage", "is_start", "selected", "dragging"):
|
||||||
|
assert dropped not in code, f"{dropped} should be stripped"
|
||||||
|
assert 'prompt: "hi"' in code
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_strips_unknown_edge_fields():
|
||||||
|
wf = _minimal_workflow()
|
||||||
|
wf["edges"][0]["data"]["invalid"] = False
|
||||||
|
wf["edges"][0]["data"]["validationMessage"] = None
|
||||||
|
code = await generate_code(wf)
|
||||||
|
assert "invalid" not in code
|
||||||
|
assert "validationMessage" not in code
|
||||||
|
|
||||||
|
|
||||||
|
# ─── parse_code ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_accepts_minimal_code():
|
||||||
|
code = """import { Workflow } from "@dograh/sdk";
|
||||||
|
import { startCall, endCall } from "@dograh/sdk/typed";
|
||||||
|
|
||||||
|
const wf = new Workflow({ name: "min" });
|
||||||
|
const a = wf.addTyped(startCall({ name: "g", prompt: "hi" }));
|
||||||
|
const b = wf.addTyped(endCall({ name: "d", prompt: "bye" }));
|
||||||
|
wf.edge(a, b, { label: "done", condition: "wrapped" });
|
||||||
|
"""
|
||||||
|
result = await parse_code(code)
|
||||||
|
assert result["ok"] is True
|
||||||
|
wf = result["workflow"]
|
||||||
|
assert len(wf["nodes"]) == 2
|
||||||
|
assert len(wf["edges"]) == 1
|
||||||
|
assert wf["nodes"][0]["type"] == "startCall"
|
||||||
|
assert wf["edges"][0]["source"] == wf["nodes"][0]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_rejects_function_declaration():
|
||||||
|
code = """import { Workflow } from "@dograh/sdk";
|
||||||
|
const wf = new Workflow({ name: "x" });
|
||||||
|
function evil() { return 1; }
|
||||||
|
"""
|
||||||
|
result = await parse_code(code)
|
||||||
|
assert result["ok"] is False
|
||||||
|
assert result["stage"] == "parse"
|
||||||
|
assert any("FunctionDeclaration" in e["message"] for e in result["errors"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_rejects_unknown_field():
|
||||||
|
code = """import { Workflow } from "@dograh/sdk";
|
||||||
|
import { startCall } from "@dograh/sdk/typed";
|
||||||
|
const wf = new Workflow({ name: "x" });
|
||||||
|
const a = wf.addTyped(startCall({ name: "g", prompt: "hi", promt: "typo" }));
|
||||||
|
"""
|
||||||
|
result = await parse_code(code)
|
||||||
|
assert result["ok"] is False
|
||||||
|
assert result["stage"] == "validate"
|
||||||
|
assert any("Unknown field" in e["message"] for e in result["errors"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_rejects_unknown_variable_in_edge():
|
||||||
|
code = """import { Workflow } from "@dograh/sdk";
|
||||||
|
import { startCall, endCall } from "@dograh/sdk/typed";
|
||||||
|
const wf = new Workflow({ name: "x" });
|
||||||
|
const a = wf.addTyped(startCall({ name: "g", prompt: "hi" }));
|
||||||
|
wf.edge(a, missing, { label: "done", condition: "c" });
|
||||||
|
"""
|
||||||
|
result = await parse_code(code)
|
||||||
|
assert result["ok"] is False
|
||||||
|
assert result["stage"] == "parse"
|
||||||
|
assert any("Unknown node variable" in e["message"] for e in result["errors"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_requires_label_and_condition_on_edge():
|
||||||
|
code = """import { Workflow } from "@dograh/sdk";
|
||||||
|
import { startCall, endCall } from "@dograh/sdk/typed";
|
||||||
|
const wf = new Workflow({ name: "x" });
|
||||||
|
const a = wf.addTyped(startCall({ name: "g", prompt: "hi" }));
|
||||||
|
const b = wf.addTyped(endCall({ name: "d", prompt: "bye" }));
|
||||||
|
wf.edge(a, b, { label: "", condition: "c" });
|
||||||
|
"""
|
||||||
|
result = await parse_code(code)
|
||||||
|
assert result["ok"] is False
|
||||||
|
assert result["stage"] == "parse"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Round-trip ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_round_trip_minimal():
|
||||||
|
wf = _minimal_workflow()
|
||||||
|
code = await generate_code(wf, workflow_name="rt")
|
||||||
|
result = await parse_code(code)
|
||||||
|
assert result["ok"] is True, result
|
||||||
|
# Positions are intentionally not preserved — they'll be reassigned
|
||||||
|
# by a downstream auto-layout pass. Parser defaults to {0, 0}.
|
||||||
|
for in_node, out_node in zip(wf["nodes"], result["workflow"]["nodes"]):
|
||||||
|
assert out_node["type"] == in_node["type"]
|
||||||
|
assert out_node["position"] == {"x": 0, "y": 0}
|
||||||
|
for k, v in in_node["data"].items():
|
||||||
|
assert out_node["data"][k] == v, (
|
||||||
|
f"{k}: {out_node['data'].get(k)!r} != {v!r}"
|
||||||
|
)
|
||||||
|
assert _normalize({"nodes": [], "edges": result["workflow"]["edges"]})["edges"] == [
|
||||||
|
{
|
||||||
|
"id": "1-2",
|
||||||
|
"source": "1",
|
||||||
|
"target": "2",
|
||||||
|
"data": {"label": "done", "condition": "conversation complete"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_fails_on_unknown_type():
|
||||||
|
bad = {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"type": "doesNotExist",
|
||||||
|
"position": {"x": 0, "y": 0},
|
||||||
|
"data": {},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edges": [],
|
||||||
|
"viewport": {"x": 0, "y": 0, "zoom": 1},
|
||||||
|
}
|
||||||
|
with pytest.raises(TsBridgeError, match="Unknown node type"):
|
||||||
|
await generate_code(bad)
|
||||||
|
|
@ -107,6 +107,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"group": "Integrations",
|
"group": "Integrations",
|
||||||
|
"tag": "NEW",
|
||||||
"pages": [
|
"pages": [
|
||||||
"integrations/mcp"
|
"integrations/mcp"
|
||||||
]
|
]
|
||||||
|
|
@ -125,6 +126,15 @@
|
||||||
"developer/environment-variables"
|
"developer/environment-variables"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"group": "SDKs",
|
||||||
|
"tag": "NEW",
|
||||||
|
"pages": [
|
||||||
|
"sdks/introduction",
|
||||||
|
"sdks/build-an-agent",
|
||||||
|
"sdks/outbound-calls"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"group": "Deployment",
|
"group": "Deployment",
|
||||||
"pages": [
|
"pages": [
|
||||||
|
|
@ -243,7 +253,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banner": {
|
"banner": {
|
||||||
"content": "🎉 **New: Gemini Live 3.1 Support** — Try the latest Google Gemini Live 3.1 on Dograh platform. [Learn more →](/configurations/inference-providers#gemini-3-1-live)",
|
"content": "🎉 **New: MCP Server & SDKs Released** — Drive Dograh from Claude, Cursor, or your own Python/TypeScript code. [MCP →](/integrations/mcp) · [SDKs →](/sdks/introduction)",
|
||||||
"dismissible": true
|
"dismissible": true
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,16 @@ Dograh exposes an [MCP (Model Context Protocol)](https://modelcontextprotocol.io
|
||||||
|
|
||||||
The endpoint is also shown in **Platform Settings → MCP Server** inside the Dograh UI.
|
The endpoint is also shown in **Platform Settings → MCP Server** inside the Dograh UI.
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
If you deployed Dograh to a remote server using [`setup_remote.sh`](/deployment/docker#option-2%3A-remote-server-deployment), your endpoint is served with a self-signed SSL certificate. Claude Code will refuse MCP connections to it unless you start the CLI with TLS verification disabled:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NODE_TLS_REJECT_UNAUTHORIZED=0 claude
|
||||||
|
```
|
||||||
|
|
||||||
|
This is only required for self-signed certificates. If you have set up a [custom domain](/deployment/custom-domain) with Let's Encrypt certificates via Certbot, no extra flag is needed.
|
||||||
|
</Note>
|
||||||
|
|
||||||
## Claude Code
|
## Claude Code
|
||||||
|
|
||||||
Register Dograh as an MCP server with the Claude Code CLI:
|
Register Dograh as an MCP server with the Claude Code CLI:
|
||||||
|
|
@ -56,14 +66,33 @@ Restart Claude Desktop after saving. The Dograh tools should appear in the tool
|
||||||
|
|
||||||
Any MCP client that supports Streamable HTTP transport can connect with the same URL and header. Paste the configuration above into your client's MCP settings file and replace `YOUR_API_KEY`.
|
Any MCP client that supports Streamable HTTP transport can connect with the same URL and header. Paste the configuration above into your client's MCP settings file and replace `YOUR_API_KEY`.
|
||||||
|
|
||||||
## Available tools
|
## Example prompts
|
||||||
|
|
||||||
Once connected, your AI assistant can:
|
Once the MCP server is connected, you can drive Dograh from your coding agent in plain English. A few prompts to try:
|
||||||
|
|
||||||
- `list_workflows` — list agents in your workspace
|
**Explore your workspace**
|
||||||
- `get_workflow` — fetch an agent's definition by ID
|
|
||||||
- `search_dograh_docs` — search Dograh documentation
|
- "List my agents in Dograh."
|
||||||
- `fetch_dograh_doc` — retrieve a specific doc page
|
- "Show me the definition of the agent called *Lead Qualifier*."
|
||||||
|
- "Which credentials and tools are configured in my Dograh workspace?"
|
||||||
|
- "List the recordings from my most recent agent."
|
||||||
|
|
||||||
|
**Edit an agent**
|
||||||
|
|
||||||
|
- "In my *Lead Qualifier* agent, add a new agent node after the greeting that asks the caller for their budget, then routes to the existing qualification node."
|
||||||
|
- "Add an end-call node to *Support Bot* that triggers when the user says they are done, with a polite goodbye prompt."
|
||||||
|
- "Rename the *intro* node in *Lead Qualifier* to *greeting* and update any edges that reference it."
|
||||||
|
- "Change the LLM model on all agent nodes in *Support Bot* to `gpt-4o-mini`."
|
||||||
|
|
||||||
|
**Learn the platform**
|
||||||
|
|
||||||
|
- "Search the Dograh docs for how to configure a TURN server."
|
||||||
|
- "What node types does Dograh support, and what fields does a `knowledge_base` node take?"
|
||||||
|
- "How do I deploy Dograh on a custom domain with HTTPS?"
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
Agent edits are saved as a new **draft** version — your published agent keeps serving calls until you explicitly publish the draft from the Dograh UI.
|
||||||
|
</Note>
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
The API key controls which workspace the assistant sees. Treat it like any other credential — do not commit it to source control or paste it into shared chats.
|
The API key controls which workspace the assistant sees. Treat it like any other credential — do not commit it to source control or paste it into shared chats.
|
||||||
|
|
|
||||||
111
docs/sdks/build-an-agent.mdx
Normal file
111
docs/sdks/build-an-agent.mdx
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
---
|
||||||
|
title: "Build an agent"
|
||||||
|
description: "Assemble a Dograh voice agent programmatically with the SDK and save it as a draft"
|
||||||
|
---
|
||||||
|
|
||||||
|
The SDK mirrors the node-and-edge model of the [Voice Agent Builder](/voice-agent/introduction). You create a `Workflow`, add nodes (`startCall`, `agentNode`, `endCall`, …) with `add()`, connect them with `edge()`, and persist the result via `save_workflow`.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- A Dograh [API key](/configurations/api-keys) exported as `DOGRAH_API_KEY`
|
||||||
|
- An existing agent ID to save drafts against (create one in the Dograh UI or via [`POST /api/v1/workflow/create`](/api-reference/agents/create-from-template))
|
||||||
|
|
||||||
|
## Build and save
|
||||||
|
|
||||||
|
The example below builds a three-node loan-qualification agent and saves it as a **new draft version** on an existing agent. Your published agent keeps serving calls until you explicitly publish the draft.
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```python Python
|
||||||
|
from dograh_sdk import DograhClient, Workflow
|
||||||
|
|
||||||
|
with DograhClient(api_key="YOUR_API_KEY") as client:
|
||||||
|
wf = Workflow(client=client, name="loan_qualification")
|
||||||
|
|
||||||
|
greeting = wf.add(
|
||||||
|
type="startCall",
|
||||||
|
name="greeting",
|
||||||
|
prompt="You are Sarah from Acme Loans. Greet the caller warmly.",
|
||||||
|
)
|
||||||
|
qualify = wf.add(
|
||||||
|
type="agentNode",
|
||||||
|
name="qualify",
|
||||||
|
prompt="Ask about loan amount, purpose, and monthly income.",
|
||||||
|
)
|
||||||
|
done = wf.add(
|
||||||
|
type="endCall",
|
||||||
|
name="done",
|
||||||
|
prompt="Thank them and end the call politely.",
|
||||||
|
)
|
||||||
|
|
||||||
|
wf.edge(greeting, qualify, label="interested", condition="Caller wants to continue.")
|
||||||
|
wf.edge(qualify, done, label="done", condition="All qualification questions answered.")
|
||||||
|
|
||||||
|
client.save_workflow(workflow_id=123, workflow=wf)
|
||||||
|
```
|
||||||
|
```typescript TypeScript
|
||||||
|
import { DograhClient, Workflow } from "@dograh/sdk";
|
||||||
|
|
||||||
|
const client = new DograhClient({ apiKey: "YOUR_API_KEY" });
|
||||||
|
const wf = new Workflow({ client, name: "loan_qualification" });
|
||||||
|
|
||||||
|
const greeting = await wf.add({
|
||||||
|
type: "startCall",
|
||||||
|
name: "greeting",
|
||||||
|
prompt: "You are Sarah from Acme Loans. Greet the caller warmly.",
|
||||||
|
});
|
||||||
|
const qualify = await wf.add({
|
||||||
|
type: "agentNode",
|
||||||
|
name: "qualify",
|
||||||
|
prompt: "Ask about loan amount, purpose, and monthly income.",
|
||||||
|
});
|
||||||
|
const done = await wf.add({
|
||||||
|
type: "endCall",
|
||||||
|
name: "done",
|
||||||
|
prompt: "Thank them and end the call politely.",
|
||||||
|
});
|
||||||
|
|
||||||
|
wf.edge(greeting, qualify, { label: "interested", condition: "Caller wants to continue." });
|
||||||
|
wf.edge(qualify, done, { label: "done", condition: "All qualification questions answered." });
|
||||||
|
|
||||||
|
await client.saveWorkflow(123, wf);
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
## Edit an existing agent
|
||||||
|
|
||||||
|
Load an agent into an editable `Workflow`, mutate it, then save:
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```python Python
|
||||||
|
wf = client.load_workflow(workflow_id=123)
|
||||||
|
wf.name = "loan_qualification_v2"
|
||||||
|
client.save_workflow(workflow_id=123, workflow=wf)
|
||||||
|
```
|
||||||
|
```typescript TypeScript
|
||||||
|
const wf = await client.loadWorkflow(123);
|
||||||
|
wf.name = "loan_qualification_v2";
|
||||||
|
await client.saveWorkflow(123, wf);
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
## Discover node types
|
||||||
|
|
||||||
|
Each node's `type` string and required fields come from the backend's node-spec catalog. Fetch it at runtime to validate what you can build:
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```python Python
|
||||||
|
types = client.list_node_types()
|
||||||
|
for spec in types.node_types:
|
||||||
|
print(spec.name, [p.name for p in spec.properties])
|
||||||
|
```
|
||||||
|
```typescript TypeScript
|
||||||
|
const types = await client.listNodeTypes();
|
||||||
|
for (const spec of types.node_types) {
|
||||||
|
console.log(spec.name, spec.properties.map(p => p.name));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
For a full description of each node type and its fields, see the [Nodes](/voice-agent/start-call) section of the Voice Agent Builder docs.
|
||||||
|
</Note>
|
||||||
75
docs/sdks/introduction.mdx
Normal file
75
docs/sdks/introduction.mdx
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
---
|
||||||
|
title: "Introduction"
|
||||||
|
description: "Build and operate Dograh voice AI agents programmatically from Python or TypeScript"
|
||||||
|
---
|
||||||
|
|
||||||
|
Dograh ships official SDKs for **Python** and **TypeScript** that wrap the Dograh REST API and the workflow builder. Use them to create or edit agents, place outbound calls, and inspect runs from your own code.
|
||||||
|
|
||||||
|
- **Python** — [`dograh-sdk`](https://pypi.org/project/dograh-sdk/) on PyPI
|
||||||
|
- **TypeScript** — [`@dograh/sdk`](https://www.npmjs.com/package/@dograh/sdk) on npm
|
||||||
|
|
||||||
|
Both packages are generated from the same backend OpenAPI spec, so the method surface is equivalent — only the naming convention differs (`snake_case` in Python, `camelCase` in TypeScript).
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```bash Python
|
||||||
|
pip install dograh-sdk
|
||||||
|
```
|
||||||
|
```bash TypeScript
|
||||||
|
npm install @dograh/sdk
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
## Authenticate
|
||||||
|
|
||||||
|
Generate an API key at [`/api-keys`](https://app.dograh.com/api-keys) (or `http://localhost:3010/api-keys` for self-hosted). See [API Keys](/configurations/api-keys) for details.
|
||||||
|
|
||||||
|
Both SDKs read the API key from the `DOGRAH_API_KEY` environment variable by default, and the base URL from `DOGRAH_API_URL` (defaults to `http://localhost:8000`). You can also pass them explicitly.
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```python Python
|
||||||
|
from dograh_sdk import DograhClient
|
||||||
|
|
||||||
|
client = DograhClient(
|
||||||
|
base_url="https://app.dograh.com",
|
||||||
|
api_key="YOUR_API_KEY",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
```typescript TypeScript
|
||||||
|
import { DograhClient } from "@dograh/sdk";
|
||||||
|
|
||||||
|
const client = new DograhClient({
|
||||||
|
baseUrl: "https://app.dograh.com",
|
||||||
|
apiKey: "YOUR_API_KEY",
|
||||||
|
});
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
For self-hosted deployments, swap `baseUrl` for your backend URL (e.g. `http://localhost:8000`).
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
## Quick tour
|
||||||
|
|
||||||
|
List the agents in your workspace:
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```python Python
|
||||||
|
workflows = client.list_workflows()
|
||||||
|
for wf in workflows:
|
||||||
|
print(wf.id, wf.name)
|
||||||
|
```
|
||||||
|
```typescript TypeScript
|
||||||
|
const workflows = await client.listWorkflows();
|
||||||
|
for (const wf of workflows) {
|
||||||
|
console.log(wf.id, wf.name);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
## Next steps
|
||||||
|
|
||||||
|
- [Build an agent](/sdks/build-an-agent) — assemble nodes and edges, save as a draft
|
||||||
|
- [Place an outbound call](/sdks/outbound-calls) — trigger a call from an agent to a phone number
|
||||||
|
- [MCP Server](/integrations/mcp) — let Claude and other coding agents drive the SDK for you
|
||||||
49
docs/sdks/outbound-calls.mdx
Normal file
49
docs/sdks/outbound-calls.mdx
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
---
|
||||||
|
title: "Place an outbound call"
|
||||||
|
description: "Trigger a Dograh voice agent to call a phone number from the SDK"
|
||||||
|
---
|
||||||
|
|
||||||
|
Use the SDK to place a test outbound call from a specific agent to a phone number. This is the same endpoint used by the **Test Call** button in the Dograh UI.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- A Dograh [API key](/configurations/api-keys) exported as `DOGRAH_API_KEY`
|
||||||
|
- A published agent (you need the agent ID)
|
||||||
|
- A configured telephony provider — see [Telephony](/integrations/telephony/overview) for Twilio, Vonage, and other setups
|
||||||
|
|
||||||
|
## Place the call
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```python Python
|
||||||
|
from dograh_sdk import DograhClient
|
||||||
|
from dograh_sdk._generated_models import InitiateCallRequest
|
||||||
|
|
||||||
|
with DograhClient(api_key="YOUR_API_KEY") as client:
|
||||||
|
client.test_phone_call(
|
||||||
|
body=InitiateCallRequest(
|
||||||
|
workflow_id=123,
|
||||||
|
phone_number="+14155551234",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
```typescript TypeScript
|
||||||
|
import { DograhClient } from "@dograh/sdk";
|
||||||
|
|
||||||
|
const client = new DograhClient({ apiKey: "YOUR_API_KEY" });
|
||||||
|
|
||||||
|
await client.testPhoneCall({
|
||||||
|
body: {
|
||||||
|
workflow_id: 123,
|
||||||
|
phone_number: "+14155551234",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
## Inspect the run
|
||||||
|
|
||||||
|
Every call creates a **run** you can inspect afterwards. See [Calls & runs](/core-concepts/calls-and-runs) for what's tracked, or use the [Runs API](/api-reference/agents/runs/list) to list and fetch runs programmatically.
|
||||||
|
|
||||||
|
## Bulk campaigns
|
||||||
|
|
||||||
|
For placing many calls at once (say, from a CSV), use [Campaigns](/core-concepts/campaigns) rather than looping over `test_phone_call` — campaigns handle pacing, retries, and progress tracking.
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
---
|
---
|
||||||
title: "Pre-Call Data Fetch"
|
title: "Pre-Call Data Fetch"
|
||||||
description: "Fetch customer data from your CRM or ERP before the call starts, so your voice agent can greet callers by name and reference their account details."
|
description: "Fetch customer data from your CRM or ERP before the call starts, so your voice agent can greet callers by name and reference their account details."
|
||||||
tag: "NEW"
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Pre-Call Data Fetch allows you to enrich the call context with external data before the voice agent starts speaking. When enabled on the **Start Call** node, Dograh sends an HTTP request to your API as soon as a call is initiated. While the response is loading, the caller hears a ring-back tone. Once the data arrives, it is merged into the call's [initial context](/core-concepts/context-and-variables#initial_context) and becomes available as template variables in your prompts and greetings.
|
Pre-Call Data Fetch allows you to enrich the call context with external data before the voice agent starts speaking. When enabled on the **Start Call** node, Dograh sends an HTTP request to your API as soon as a call is initiated. While the response is loading, the caller hears a ring-back tone. Once the data arrives, it is merged into the call's [initial context](/core-concepts/context-and-variables#initial_context) and becomes available as template variables in your prompts and greetings.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
---
|
---
|
||||||
title: "Pre-recorded Audio"
|
title: "Pre-recorded Audio"
|
||||||
description: "Build hybrid voice agents that combine pre-recorded audio with dynamic text generation for lower latency, reduced TTS costs, and natural-sounding conversations."
|
description: "Build hybrid voice agents that combine pre-recorded audio with dynamic text generation for lower latency, reduced TTS costs, and natural-sounding conversations."
|
||||||
tag: "NEW"
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Custom recordings allow you to build **hybrid voice agents** that use your own pre-recorded audio for key parts of the conversation, while falling back to LLM-generated speech (via a cloned voice) for dynamic responses. This gives you the best of both worlds — the emotional depth of real human speech and the flexibility of AI-generated dialogue.
|
Custom recordings allow you to build **hybrid voice agents** that use your own pre-recorded audio for key parts of the conversation, while falling back to LLM-generated speech (via a cloned voice) for dynamic responses. This gives you the best of both worlds — the emotional depth of real human speech and the flexibility of AI-generated dialogue.
|
||||||
|
|
|
||||||
2
pipecat
2
pipecat
|
|
@ -1 +1 @@
|
||||||
Subproject commit edefaad42b97e52a3ad5eef8d15115a5c6ba3b11
|
Subproject commit a6869df4bc7de8bd14f0533f7112f7d6a24891d9
|
||||||
109
scripts/generate_sdk.sh
Executable file
109
scripts/generate_sdk.sh
Executable file
|
|
@ -0,0 +1,109 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regenerate every file the SDKs derive from authoritative backend state:
|
||||||
|
#
|
||||||
|
# 1. Typed node dataclasses / TS interfaces (from node_specs registry)
|
||||||
|
# 2. Filtered OpenAPI spec (routes tagged via @sdk_expose)
|
||||||
|
# 3. Pydantic request/response models + TS interfaces (datamodel-codegen
|
||||||
|
# / openapi-typescript)
|
||||||
|
# 4. Client method mixins (_generated_client.py / _generated_client.ts)
|
||||||
|
#
|
||||||
|
# Run from anywhere — the script resolves the repo root relative to itself.
|
||||||
|
# Requires:
|
||||||
|
# - `python` in the `dograh` conda env, `api/.env` sourced; the `api`
|
||||||
|
# package must be importable. `datamodel-code-generator` installed
|
||||||
|
# (`pip install datamodel-code-generator`).
|
||||||
|
# - `node` (>= 22.6 for native .mts support) and npm. openapi-typescript
|
||||||
|
# is a devDependency of sdk/typescript; `npm install` in that dir is
|
||||||
|
# done for you if node_modules is missing.
|
||||||
|
#
|
||||||
|
# Invoked manually after editing any NodeSpec or after adding/removing an
|
||||||
|
# `@sdk_expose` decorator. CI runs this and asserts the git diff is empty.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
if [ -f "$REPO_ROOT/api/.env" ]; then
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "$REPO_ROOT/api/.env"
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
SPECS_JSON="$(mktemp -t dograh-specs-XXXXXX.json)"
|
||||||
|
OPENAPI_JSON="$(mktemp -t dograh-openapi-XXXXXX.json)"
|
||||||
|
trap 'rm -f "$SPECS_JSON" "$OPENAPI_JSON"' EXIT
|
||||||
|
|
||||||
|
# ── 1. Node-spec typed dataclasses ────────────────────────────────────
|
||||||
|
|
||||||
|
echo "→ Dumping node specs from in-process registry..."
|
||||||
|
python -m api.services.workflow.node_specs > "$SPECS_JSON"
|
||||||
|
|
||||||
|
echo "→ Generating Python typed dataclasses..."
|
||||||
|
PYTHONPATH="$REPO_ROOT/sdk/python/src" python -m dograh_sdk.codegen \
|
||||||
|
--input "$SPECS_JSON" \
|
||||||
|
--out "sdk/python/src/dograh_sdk/typed"
|
||||||
|
|
||||||
|
echo "→ Generating TypeScript typed interfaces..."
|
||||||
|
node "sdk/typescript/scripts/codegen.mts" \
|
||||||
|
--input "$SPECS_JSON" \
|
||||||
|
--out "sdk/typescript/src/typed"
|
||||||
|
|
||||||
|
# ── 2. SDK-scoped OpenAPI spec ────────────────────────────────────────
|
||||||
|
|
||||||
|
echo "→ Dumping filtered OpenAPI (sdk_expose routes only)..."
|
||||||
|
python - <<PY
|
||||||
|
import json
|
||||||
|
from loguru import logger
|
||||||
|
logger.remove()
|
||||||
|
from fastapi.openapi.utils import get_openapi
|
||||||
|
from api.app import app
|
||||||
|
|
||||||
|
sdk_routes = [
|
||||||
|
r for r in app.routes
|
||||||
|
if getattr(r, "openapi_extra", None)
|
||||||
|
and "x-sdk-method" in (r.openapi_extra or {})
|
||||||
|
]
|
||||||
|
spec = get_openapi(title=app.title, version=app.version, routes=sdk_routes)
|
||||||
|
with open("$OPENAPI_JSON", "w") as f:
|
||||||
|
json.dump(spec, f)
|
||||||
|
print(f" → {len(sdk_routes)} operations, "
|
||||||
|
f"{len(spec.get('components', {}).get('schemas', {}))} schemas reachable")
|
||||||
|
PY
|
||||||
|
|
||||||
|
# ── 3. Request/response models (off-the-shelf) ────────────────────────
|
||||||
|
|
||||||
|
echo "→ Generating Python Pydantic models (datamodel-codegen)..."
|
||||||
|
datamodel-codegen \
|
||||||
|
--input "$OPENAPI_JSON" \
|
||||||
|
--input-file-type openapi \
|
||||||
|
--output "sdk/python/src/dograh_sdk/_generated_models.py" \
|
||||||
|
--output-model-type pydantic_v2.BaseModel \
|
||||||
|
--target-python-version 3.10 \
|
||||||
|
--use-schema-description \
|
||||||
|
--use-field-description \
|
||||||
|
--use-annotated \
|
||||||
|
--use-union-operator \
|
||||||
|
--field-constraints \
|
||||||
|
--wrap-string-literal
|
||||||
|
|
||||||
|
echo "→ Generating TypeScript types (openapi-typescript)..."
|
||||||
|
if [ ! -d "sdk/typescript/node_modules" ]; then
|
||||||
|
(cd sdk/typescript && npm install --silent)
|
||||||
|
fi
|
||||||
|
(cd sdk/typescript && npx --no-install openapi-typescript \
|
||||||
|
"$OPENAPI_JSON" \
|
||||||
|
--output "src/_generated_models.ts" \
|
||||||
|
--root-types \
|
||||||
|
--root-types-no-schema-prefix)
|
||||||
|
|
||||||
|
# ── 4. Client method mixins ──────────────────────────────────────────
|
||||||
|
|
||||||
|
echo "→ Emitting client method mixins..."
|
||||||
|
python -m sdk.codegen.client_codegen \
|
||||||
|
--input "$OPENAPI_JSON" \
|
||||||
|
--py-out "sdk/python/src/dograh_sdk/_generated_client.py" \
|
||||||
|
--ts-out "sdk/typescript/src/_generated_client.ts"
|
||||||
|
|
||||||
|
echo "✓ SDK regenerated."
|
||||||
123
scripts/release_sdks.sh
Executable file
123
scripts/release_sdks.sh
Executable file
|
|
@ -0,0 +1,123 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Cut a release of both SDKs — dograh-sdk (PyPI) and @dograh/sdk (npm) —
|
||||||
|
# at the given version. Regenerates typed files from node_specs first so
|
||||||
|
# a stale SDK can't ship.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/release_sdks.sh 0.1.2
|
||||||
|
#
|
||||||
|
# Prerequisites (one-time setup):
|
||||||
|
# - `build` + `twine` installed: `pip install --upgrade build twine`
|
||||||
|
# - `npm login` completed as a member of the `dograh` npm org. npm
|
||||||
|
# publish will prompt interactively for a 2FA OTP — run this script
|
||||||
|
# in a terminal where you can type the code.
|
||||||
|
#
|
||||||
|
# The script is idempotent up to the upload steps: each publish is gated
|
||||||
|
# by a y/N prompt, so you can dry-run the build and bail before anything
|
||||||
|
# hits a registry.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION="${1:-}"
|
||||||
|
if [[ -z "$VERSION" ]]; then
|
||||||
|
echo "usage: $0 <version> # e.g. 0.1.2" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.\-][A-Za-z0-9.]+)?$ ]]; then
|
||||||
|
echo "error: '$VERSION' does not look like semver (e.g. 0.1.2 or 0.2.0-rc.1)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
confirm() {
|
||||||
|
local reply
|
||||||
|
read -r -p "$1 [y/N] " reply
|
||||||
|
[[ "$reply" =~ ^[Yy]$ ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "→ Regenerating typed SDK sources from node_specs..."
|
||||||
|
./scripts/generate_sdk.sh
|
||||||
|
|
||||||
|
if ! git diff --quiet -- sdk/python/src/dograh_sdk/typed sdk/typescript/src/typed; then
|
||||||
|
echo
|
||||||
|
echo "⚠ node_specs regeneration changed typed files. Review the diff"
|
||||||
|
echo " above and commit before releasing — otherwise the tag will"
|
||||||
|
echo " point at a tree that disagrees with what ships to the registry."
|
||||||
|
if ! confirm "Continue anyway?"; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "→ Bumping versions to $VERSION..."
|
||||||
|
VERSION="$VERSION" python - <<'PY'
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
|
||||||
|
version = os.environ["VERSION"]
|
||||||
|
|
||||||
|
py = pathlib.Path("sdk/python/pyproject.toml")
|
||||||
|
py.write_text(
|
||||||
|
re.sub(r'^version = "[^"]+"', f'version = "{version}"', py.read_text(), count=1, flags=re.M)
|
||||||
|
)
|
||||||
|
|
||||||
|
ts = pathlib.Path("sdk/typescript/package.json")
|
||||||
|
ts.write_text(
|
||||||
|
re.sub(r'"version": "[^"]+"', f'"version": "{version}"', ts.read_text(), count=1)
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f" pyproject.toml → {version}")
|
||||||
|
print(f" package.json → {version}")
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo "→ Building Python wheel + sdist..."
|
||||||
|
(
|
||||||
|
cd sdk/python
|
||||||
|
rm -rf dist build
|
||||||
|
python -m build >/dev/null
|
||||||
|
twine check dist/*
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "→ Building TypeScript + running tests..."
|
||||||
|
(
|
||||||
|
cd sdk/typescript
|
||||||
|
rm -rf dist
|
||||||
|
npm ci --silent
|
||||||
|
npm run build
|
||||||
|
npm test
|
||||||
|
)
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "============================================================"
|
||||||
|
echo " Built dograh-sdk==$VERSION and @dograh/sdk@$VERSION"
|
||||||
|
echo " Nothing has been published yet."
|
||||||
|
echo "============================================================"
|
||||||
|
echo
|
||||||
|
|
||||||
|
if confirm "Upload dograh-sdk==$VERSION to TestPyPI first (recommended)?"; then
|
||||||
|
(cd sdk/python && twine upload --repository testpypi dist/*)
|
||||||
|
echo " → https://test.pypi.org/project/dograh-sdk/$VERSION/"
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
|
||||||
|
if confirm "Upload dograh-sdk==$VERSION to PyPI?"; then
|
||||||
|
(cd sdk/python && twine upload dist/*)
|
||||||
|
echo " → https://pypi.org/project/dograh-sdk/$VERSION/"
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
|
||||||
|
if confirm "Publish @dograh/sdk@$VERSION to npm? (will prompt for 2FA OTP)"; then
|
||||||
|
(cd sdk/typescript && npm publish --access public)
|
||||||
|
echo " → https://www.npmjs.com/package/@dograh/sdk/v/$VERSION"
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
|
||||||
|
if confirm "Create annotated git tag sdks-v$VERSION at HEAD?"; then
|
||||||
|
git tag -a "sdks-v$VERSION" -m "dograh-sdk + @dograh/sdk $VERSION"
|
||||||
|
echo " → created tag (not pushed). Push with:"
|
||||||
|
echo " git push origin sdks-v$VERSION"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✓ Done."
|
||||||
0
sdk/codegen/__init__.py
Normal file
0
sdk/codegen/__init__.py
Normal file
355
sdk/codegen/client_codegen.py
Normal file
355
sdk/codegen/client_codegen.py
Normal file
|
|
@ -0,0 +1,355 @@
|
||||||
|
"""Generate SDK client mixins (Python + TypeScript) from a filtered OpenAPI dump.
|
||||||
|
|
||||||
|
Input: a spec produced by calling FastAPI's `get_openapi(routes=...)` with
|
||||||
|
only the routes tagged via `sdk_expose(...)`. Because it's already filtered,
|
||||||
|
this script does *no* filtering — it just walks the operations and emits
|
||||||
|
typed method stubs.
|
||||||
|
|
||||||
|
Request/response types come from sibling model files already produced by
|
||||||
|
`datamodel-codegen` (Python) and `openapi-typescript --root-types
|
||||||
|
--root-types-no-schema-prefix` (TypeScript). We only import the names
|
||||||
|
here; this script doesn't generate types itself.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
--py-out sdk/python/src/dograh_sdk/_generated_client.py
|
||||||
|
--ts-out sdk/typescript/src/_generated_client.ts
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
_API_PREFIX = "/api/v1"
|
||||||
|
|
||||||
|
# openapi scalar → (python, typescript)
|
||||||
|
_TYPE_MAP = {
|
||||||
|
"integer": ("int", "number"),
|
||||||
|
"number": ("float", "number"),
|
||||||
|
"string": ("str", "string"),
|
||||||
|
"boolean": ("bool", "boolean"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _map_scalar(schema: dict[str, Any]) -> tuple[str, str]:
|
||||||
|
t = schema.get("type")
|
||||||
|
if t in _TYPE_MAP:
|
||||||
|
return _TYPE_MAP[t]
|
||||||
|
# optional string often shown as anyOf:[{type:string}, {type:null}]
|
||||||
|
for branch in schema.get("anyOf") or []:
|
||||||
|
if branch.get("type") in _TYPE_MAP and branch.get("type") != "null":
|
||||||
|
return _TYPE_MAP[branch["type"]]
|
||||||
|
return ("Any", "unknown")
|
||||||
|
|
||||||
|
|
||||||
|
def _ref_name(schema: dict[str, Any]) -> str | None:
|
||||||
|
ref = schema.get("$ref")
|
||||||
|
if isinstance(ref, str) and ref.startswith("#/components/schemas/"):
|
||||||
|
return ref.rsplit("/", 1)[-1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ResponseType:
|
||||||
|
"""What comes back from an operation. `class_name` is a model class from
|
||||||
|
`_generated_models`; `is_list` wraps it as a list."""
|
||||||
|
class_name: str | None = None
|
||||||
|
is_list: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def py(self) -> str:
|
||||||
|
if self.class_name is None:
|
||||||
|
return "Any"
|
||||||
|
return f"list[{self.class_name}]" if self.is_list else self.class_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ts(self) -> str:
|
||||||
|
if self.class_name is None:
|
||||||
|
return "unknown"
|
||||||
|
return f"{self.class_name}[]" if self.is_list else self.class_name
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Param:
|
||||||
|
name: str
|
||||||
|
py_type: str
|
||||||
|
ts_type: str
|
||||||
|
required: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Operation:
|
||||||
|
method: str
|
||||||
|
verb: str
|
||||||
|
path: str
|
||||||
|
description: str
|
||||||
|
path_params: list[Param] = field(default_factory=list)
|
||||||
|
query_params: list[Param] = field(default_factory=list)
|
||||||
|
request_class: str | None = None # None → no body
|
||||||
|
response: ResponseType = field(default_factory=ResponseType)
|
||||||
|
|
||||||
|
|
||||||
|
def _collect(spec: dict[str, Any]) -> list[Operation]:
|
||||||
|
ops: list[Operation] = []
|
||||||
|
used_models: set[str] = set()
|
||||||
|
|
||||||
|
for path, methods in spec.get("paths", {}).items():
|
||||||
|
for verb, op in methods.items():
|
||||||
|
if not isinstance(op, dict) or "x-sdk-method" not in op:
|
||||||
|
continue
|
||||||
|
|
||||||
|
description = (op.get("x-sdk-description") or op.get("summary") or "").strip()
|
||||||
|
sdk_path = path[len(_API_PREFIX):] if path.startswith(_API_PREFIX) else path
|
||||||
|
|
||||||
|
path_params: list[Param] = []
|
||||||
|
query_params: list[Param] = []
|
||||||
|
for p in op.get("parameters") or []:
|
||||||
|
py_t, ts_t = _map_scalar(p.get("schema") or {})
|
||||||
|
param = Param(
|
||||||
|
name=p["name"],
|
||||||
|
py_type=py_t,
|
||||||
|
ts_type=ts_t,
|
||||||
|
required=bool(p.get("required")),
|
||||||
|
)
|
||||||
|
if p.get("in") == "path":
|
||||||
|
path_params.append(param)
|
||||||
|
elif p.get("in") == "query":
|
||||||
|
query_params.append(param)
|
||||||
|
|
||||||
|
request_class: str | None = None
|
||||||
|
rb = op.get("requestBody") or {}
|
||||||
|
rb_schema = (
|
||||||
|
(rb.get("content") or {}).get("application/json", {}).get("schema") or {}
|
||||||
|
)
|
||||||
|
if rb_schema:
|
||||||
|
request_class = _ref_name(rb_schema)
|
||||||
|
|
||||||
|
response = ResponseType()
|
||||||
|
r200 = (
|
||||||
|
op.get("responses", {})
|
||||||
|
.get("200", {})
|
||||||
|
.get("content", {})
|
||||||
|
.get("application/json", {})
|
||||||
|
.get("schema")
|
||||||
|
or {}
|
||||||
|
)
|
||||||
|
if r200:
|
||||||
|
name = _ref_name(r200)
|
||||||
|
if name:
|
||||||
|
response = ResponseType(class_name=name)
|
||||||
|
elif r200.get("type") == "array":
|
||||||
|
item = r200.get("items") or {}
|
||||||
|
name = _ref_name(item)
|
||||||
|
if name:
|
||||||
|
response = ResponseType(class_name=name, is_list=True)
|
||||||
|
|
||||||
|
for cls in (request_class, response.class_name):
|
||||||
|
if cls:
|
||||||
|
used_models.add(cls)
|
||||||
|
|
||||||
|
ops.append(Operation(
|
||||||
|
method=op["x-sdk-method"],
|
||||||
|
verb=verb.lower(),
|
||||||
|
path=sdk_path,
|
||||||
|
description=description,
|
||||||
|
path_params=path_params,
|
||||||
|
query_params=query_params,
|
||||||
|
request_class=request_class,
|
||||||
|
response=response,
|
||||||
|
))
|
||||||
|
|
||||||
|
ops.sort(key=lambda o: o.method)
|
||||||
|
return ops, sorted(used_models)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Python emitter ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _py_method(op: Operation) -> str:
|
||||||
|
positional = [f"{p.name}: {p.py_type}" for p in op.path_params]
|
||||||
|
kw_only: list[str] = []
|
||||||
|
if op.request_class:
|
||||||
|
kw_only.append(f"body: {op.request_class}")
|
||||||
|
for p in op.query_params:
|
||||||
|
kw_only.append(f"{p.name}: {p.py_type} | None = None")
|
||||||
|
|
||||||
|
sig = ", ".join(["self", *positional] + (["*"] + kw_only if kw_only else []))
|
||||||
|
|
||||||
|
lines: list[str] = []
|
||||||
|
lines.append(f" def {op.method}({sig}) -> {op.response.py}:")
|
||||||
|
lines.append(f' """{op.description or op.verb.upper() + " " + op.path}"""')
|
||||||
|
|
||||||
|
path_expr = f'f"{op.path}"' if op.path_params else f'"{op.path}"'
|
||||||
|
|
||||||
|
call_kwargs: list[str] = []
|
||||||
|
if op.query_params:
|
||||||
|
lines.append(" params: dict[str, Any] = {}")
|
||||||
|
for p in op.query_params:
|
||||||
|
lines.append(f" if {p.name} is not None:")
|
||||||
|
lines.append(f' params["{p.name}"] = {p.name}')
|
||||||
|
call_kwargs.append("params=params")
|
||||||
|
if op.request_class:
|
||||||
|
call_kwargs.append('json=body.model_dump(mode="json", exclude_none=True)')
|
||||||
|
|
||||||
|
extra = (", " + ", ".join(call_kwargs)) if call_kwargs else ""
|
||||||
|
raw_call = f'self._request("{op.verb.upper()}", {path_expr}{extra})'
|
||||||
|
|
||||||
|
if op.response.class_name is None:
|
||||||
|
lines.append(f" return {raw_call}")
|
||||||
|
elif op.response.is_list:
|
||||||
|
lines.append(f" data = {raw_call}")
|
||||||
|
lines.append(f" return [{op.response.class_name}.model_validate(x) for x in data]")
|
||||||
|
else:
|
||||||
|
lines.append(f" data = {raw_call}")
|
||||||
|
lines.append(f" return {op.response.class_name}.model_validate(data)")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
_PY_HEADER = '''\
|
||||||
|
"""GENERATED — do not edit. Source: filtered OpenAPI from `api.app`.
|
||||||
|
|
||||||
|
Regenerate with `./scripts/generate_sdk.sh`.
|
||||||
|
|
||||||
|
`DograhClient` mixes in this class to get HTTP methods for every route
|
||||||
|
decorated with `sdk_expose(...)` on the backend. Request/response types
|
||||||
|
come from `_generated_models` (datamodel-codegen output).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from dograh_sdk._generated_models import (
|
||||||
|
{imports}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _GeneratedClient:
|
||||||
|
# `DograhClient.__init__` installs `self._request` (see client.py).
|
||||||
|
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def emit_python(ops: list[Operation], models: list[str]) -> str:
|
||||||
|
imports = "\n".join(f" {m}," for m in models)
|
||||||
|
body = "\n".join(_py_method(op) for op in ops)
|
||||||
|
return _PY_HEADER.format(imports=imports) + body
|
||||||
|
|
||||||
|
|
||||||
|
# ── TypeScript emitter ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _snake_to_camel(s: str) -> str:
|
||||||
|
parts = s.split("_")
|
||||||
|
return parts[0] + "".join(p.title() for p in parts[1:])
|
||||||
|
|
||||||
|
|
||||||
|
def _ts_method(op: Operation) -> str:
|
||||||
|
name = _snake_to_camel(op.method)
|
||||||
|
positional = [f"{_snake_to_camel(p.name)}: {p.ts_type}" for p in op.path_params]
|
||||||
|
|
||||||
|
opts_props: list[str] = []
|
||||||
|
if op.request_class:
|
||||||
|
opts_props.append(f"body: {op.request_class}")
|
||||||
|
for p in op.query_params:
|
||||||
|
opts_props.append(f"{_snake_to_camel(p.name)}?: {p.ts_type}")
|
||||||
|
|
||||||
|
args = list(positional)
|
||||||
|
if opts_props:
|
||||||
|
required_in_opts = op.request_class is not None
|
||||||
|
opts_sig = "{ " + "; ".join(opts_props) + " }"
|
||||||
|
# If body is required, opts is required too (no `= {}` default)
|
||||||
|
args.append(f"opts: {opts_sig}" if required_in_opts else f"opts: {opts_sig} = {{}}")
|
||||||
|
|
||||||
|
sig = ", ".join(args)
|
||||||
|
ret = op.response.ts
|
||||||
|
|
||||||
|
lines: list[str] = []
|
||||||
|
lines.append(f" /** {op.description or op.verb.upper() + ' ' + op.path} */")
|
||||||
|
lines.append(f" async {name}({sig}): Promise<{ret}> {{")
|
||||||
|
|
||||||
|
path_expr = op.path
|
||||||
|
for p in op.path_params:
|
||||||
|
path_expr = path_expr.replace("{" + p.name + "}", "${" + _snake_to_camel(p.name) + "}")
|
||||||
|
tmpl = f"`{path_expr}`" if op.path_params else f'"{op.path}"'
|
||||||
|
|
||||||
|
call_opts: list[str] = []
|
||||||
|
if op.query_params:
|
||||||
|
entries: list[str] = []
|
||||||
|
for p in op.query_params:
|
||||||
|
camel = _snake_to_camel(p.name)
|
||||||
|
entries.append(f' ...(opts.{camel} !== undefined ? {{ "{p.name}": opts.{camel} }} : {{}}),')
|
||||||
|
lines.append(" const params: Record<string, unknown> = {")
|
||||||
|
lines.extend(entries)
|
||||||
|
lines.append(" };")
|
||||||
|
call_opts.append("params")
|
||||||
|
if op.request_class:
|
||||||
|
call_opts.append("json: opts.body")
|
||||||
|
|
||||||
|
extra = (", { " + ", ".join(call_opts) + " }") if call_opts else ""
|
||||||
|
generic = f"<{ret}>" if ret != "unknown" else ""
|
||||||
|
lines.append(f' return this.request{generic}("{op.verb.upper()}", {tmpl}{extra});')
|
||||||
|
lines.append(" }")
|
||||||
|
lines.append("")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
_TS_HEADER = """\
|
||||||
|
// GENERATED — do not edit. Source: filtered OpenAPI from `api.app`.
|
||||||
|
//
|
||||||
|
// Regenerate with `./scripts/generate_sdk.sh`.
|
||||||
|
//
|
||||||
|
// `DograhClient` extends this base to get HTTP methods for every route
|
||||||
|
// decorated with `sdk_expose(...)`. Request/response types come from
|
||||||
|
// `_generated_models` (openapi-typescript output, --root-types).
|
||||||
|
|
||||||
|
import type {{
|
||||||
|
{imports}
|
||||||
|
}} from "./_generated_models.js";
|
||||||
|
|
||||||
|
export abstract class _GeneratedClient {{
|
||||||
|
protected abstract request<T = unknown>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
opts?: {{ json?: unknown; params?: Record<string, unknown> }},
|
||||||
|
): Promise<T>;
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def emit_typescript(ops: list[Operation], models: list[str]) -> str:
|
||||||
|
imports = "\n".join(f" {m}," for m in models)
|
||||||
|
body = "\n".join(_ts_method(op) for op in ops)
|
||||||
|
return _TS_HEADER.format(imports=imports) + body + "}\n"
|
||||||
|
|
||||||
|
|
||||||
|
# ── CLI ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__)
|
||||||
|
ap.add_argument("--input", required=True, help="Path to filtered openapi.json")
|
||||||
|
ap.add_argument("--py-out", required=True)
|
||||||
|
ap.add_argument("--ts-out", required=True)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
spec = json.loads(Path(args.input).read_text())
|
||||||
|
ops, models = _collect(spec)
|
||||||
|
if not ops:
|
||||||
|
raise SystemExit("No x-sdk-method operations — nothing to emit.")
|
||||||
|
|
||||||
|
Path(args.py_out).write_text(emit_python(ops, models))
|
||||||
|
Path(args.ts_out).write_text(emit_typescript(ops, models))
|
||||||
|
print(f" → {len(ops)} operations, {len(models)} models referenced")
|
||||||
|
print(f" → {args.py_out}")
|
||||||
|
print(f" → {args.ts_out}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
3
sdk/python/.gitignore
vendored
Normal file
3
sdk/python/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
24
sdk/python/LICENSE
Normal file
24
sdk/python/LICENSE
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
BSD 2-Clause License
|
||||||
|
|
||||||
|
Copyright (c) 2025, Zansat Technologies Private Limited
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright notice, this
|
||||||
|
list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||||
|
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||||
|
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||||
|
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||||
|
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||||
|
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
77
sdk/python/README.md
Normal file
77
sdk/python/README.md
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
# dograh-sdk
|
||||||
|
|
||||||
|
Typed builder for Dograh voice-AI workflows. Fetches the node-spec catalog from
|
||||||
|
the Dograh backend at session start, validates every call against it at the
|
||||||
|
call site, and produces `ReactFlowDTO`-compatible JSON.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install dograh-sdk
|
||||||
|
```
|
||||||
|
|
||||||
|
For local development against a checked-out monorepo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e sdk/python/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
from dograh_sdk import DograhClient, Workflow
|
||||||
|
|
||||||
|
with DograhClient(base_url="http://localhost:8000", api_key="...") as client:
|
||||||
|
wf = Workflow(client=client, name="loan_qualification")
|
||||||
|
|
||||||
|
start = wf.add(
|
||||||
|
type="startCall",
|
||||||
|
name="greeting",
|
||||||
|
prompt="You are Sarah from Acme Loans. Greet the caller warmly.",
|
||||||
|
greeting_type="text",
|
||||||
|
greeting="Hi {{first_name}}, this is Sarah.",
|
||||||
|
)
|
||||||
|
qualify = wf.add(
|
||||||
|
type="agentNode",
|
||||||
|
name="qualify",
|
||||||
|
prompt="Ask about loan amount and timeline.",
|
||||||
|
)
|
||||||
|
done = wf.add(type="endCall", name="done", prompt="Thank the caller.")
|
||||||
|
|
||||||
|
wf.edge(start, qualify, label="interested", condition="Caller expressed interest.")
|
||||||
|
wf.edge(qualify, done, label="done", condition="Qualification complete.")
|
||||||
|
|
||||||
|
client.save_workflow(workflow_id=123, workflow=wf)
|
||||||
|
```
|
||||||
|
|
||||||
|
## What gets validated at the call site
|
||||||
|
|
||||||
|
The SDK fetches the spec for each node type via `get_node_type` and raises
|
||||||
|
`ValidationError` immediately when:
|
||||||
|
|
||||||
|
- an unknown field is passed (catches typos)
|
||||||
|
- a required field is missing or empty
|
||||||
|
- a scalar type is wrong (e.g., string for a boolean)
|
||||||
|
- an `options` value isn't in the allowed list
|
||||||
|
|
||||||
|
When a spec carries an `llm_hint`, the hint is appended to the error message so
|
||||||
|
an LLM agent can self-correct on retry:
|
||||||
|
|
||||||
|
```
|
||||||
|
tool_uuids: expected tool_refs, got str
|
||||||
|
Hint: List of tool UUIDs from `list_tools`.
|
||||||
|
```
|
||||||
|
|
||||||
|
Server-side Pydantic validators run on save and surface anything the SDK lets
|
||||||
|
through (compound invariants, cross-field rules).
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DOGRAH_API_URL=http://localhost:8000 # default
|
||||||
|
DOGRAH_API_KEY=sk-... # sent as X-API-Key
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
BSD 2-Clause — see `LICENSE`.
|
||||||
49
sdk/python/pyproject.toml
Normal file
49
sdk/python/pyproject.toml
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
[project]
|
||||||
|
name = "dograh-sdk"
|
||||||
|
version = "0.1.2"
|
||||||
|
description = "Typed builder for Dograh voice-AI workflows"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
license = { text = "BSD-2-Clause" }
|
||||||
|
authors = [
|
||||||
|
{ name = "Zansat Technologies Private Limited", email = "contact@dograh.com" },
|
||||||
|
]
|
||||||
|
keywords = ["dograh", "voice-ai", "workflow", "sdk", "llm", "agent"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 3 - Alpha",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"License :: OSI Approved :: BSD License",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
"Topic :: Software Development :: Libraries",
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
"httpx>=0.27",
|
||||||
|
"pydantic>=2.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=7.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://dograh.com"
|
||||||
|
Documentation = "https://docs.dograh.com"
|
||||||
|
Repository = "https://github.com/dograh-hq/dograh"
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
dograh-sdk-codegen = "dograh_sdk.codegen:main"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/dograh_sdk"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
35
sdk/python/src/dograh_sdk/__init__.py
Normal file
35
sdk/python/src/dograh_sdk/__init__.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
"""Dograh SDK — typed builder for voice-AI workflows.
|
||||||
|
|
||||||
|
Runtime SDK: fetches the spec catalog from the Dograh backend at session
|
||||||
|
start and validates every `Workflow.add()` call against it. LLMs don't
|
||||||
|
need to import per-node-type classes — the `type` argument is a string
|
||||||
|
keyed against the fetched spec catalog.
|
||||||
|
|
||||||
|
from dograh_sdk import DograhClient, Workflow
|
||||||
|
|
||||||
|
with DograhClient(base_url="http://localhost:8000", api_key=...) as client:
|
||||||
|
wf = Workflow(client=client, name="loan_qualification")
|
||||||
|
start = wf.add(type="startCall", name="greeting", prompt="...")
|
||||||
|
qualify = wf.add(type="agentNode", name="qualify", prompt="...")
|
||||||
|
wf.edge(start, qualify, label="interested", condition="...")
|
||||||
|
client.save_workflow(workflow_id=123, workflow=wf)
|
||||||
|
|
||||||
|
For typed IDE autocomplete, generate per-node dataclasses via the SDK
|
||||||
|
codegen (Phase 6) — the runtime and typed SDKs share this same core.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .client import DograhClient
|
||||||
|
from .errors import ApiError, DograhSdkError, SpecMismatchError, ValidationError
|
||||||
|
from .typed._base import TypedNode
|
||||||
|
from .workflow import NodeRef, Workflow
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ApiError",
|
||||||
|
"DograhClient",
|
||||||
|
"DograhSdkError",
|
||||||
|
"NodeRef",
|
||||||
|
"SpecMismatchError",
|
||||||
|
"TypedNode",
|
||||||
|
"ValidationError",
|
||||||
|
"Workflow",
|
||||||
|
]
|
||||||
102
sdk/python/src/dograh_sdk/_generated_client.py
Normal file
102
sdk/python/src/dograh_sdk/_generated_client.py
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
"""GENERATED — do not edit. Source: filtered OpenAPI from `api.app`.
|
||||||
|
|
||||||
|
Regenerate with `./scripts/generate_sdk.sh`.
|
||||||
|
|
||||||
|
`DograhClient` mixes in this class to get HTTP methods for every route
|
||||||
|
decorated with `sdk_expose(...)` on the backend. Request/response types
|
||||||
|
come from `_generated_models` (datamodel-codegen output).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from dograh_sdk._generated_models import (
|
||||||
|
CredentialResponse,
|
||||||
|
DocumentListResponseSchema,
|
||||||
|
InitiateCallRequest,
|
||||||
|
NodeSpec,
|
||||||
|
NodeTypesResponse,
|
||||||
|
RecordingListResponseSchema,
|
||||||
|
ToolResponse,
|
||||||
|
UpdateWorkflowRequest,
|
||||||
|
WorkflowListResponse,
|
||||||
|
WorkflowResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _GeneratedClient:
|
||||||
|
# `DograhClient.__init__` installs `self._request` (see client.py).
|
||||||
|
|
||||||
|
def get_node_type(self, name: str) -> NodeSpec:
|
||||||
|
"""Fetch a single node spec by name."""
|
||||||
|
data = self._request("GET", f"/node-types/{name}")
|
||||||
|
return NodeSpec.model_validate(data)
|
||||||
|
|
||||||
|
def get_workflow(self, workflow_id: int) -> WorkflowResponse:
|
||||||
|
"""Get a single workflow by ID (returns draft if one exists, else published)."""
|
||||||
|
data = self._request("GET", f"/workflow/fetch/{workflow_id}")
|
||||||
|
return WorkflowResponse.model_validate(data)
|
||||||
|
|
||||||
|
def list_credentials(self) -> list[CredentialResponse]:
|
||||||
|
"""List webhook credentials available to the authenticated organization."""
|
||||||
|
data = self._request("GET", "/credentials/")
|
||||||
|
return [CredentialResponse.model_validate(x) for x in data]
|
||||||
|
|
||||||
|
def list_documents(self, *, status: str | None = None, limit: int | None = None, offset: int | None = None) -> DocumentListResponseSchema:
|
||||||
|
"""List knowledge base documents available to the authenticated organization."""
|
||||||
|
params: dict[str, Any] = {}
|
||||||
|
if status is not None:
|
||||||
|
params["status"] = status
|
||||||
|
if limit is not None:
|
||||||
|
params["limit"] = limit
|
||||||
|
if offset is not None:
|
||||||
|
params["offset"] = offset
|
||||||
|
data = self._request("GET", "/knowledge-base/documents", params=params)
|
||||||
|
return DocumentListResponseSchema.model_validate(data)
|
||||||
|
|
||||||
|
def list_node_types(self) -> NodeTypesResponse:
|
||||||
|
"""List every registered node type with its spec. Pinned to spec_version."""
|
||||||
|
data = self._request("GET", "/node-types")
|
||||||
|
return NodeTypesResponse.model_validate(data)
|
||||||
|
|
||||||
|
def list_recordings(self, *, workflow_id: int | None = None, tts_provider: str | None = None, tts_model: str | None = None, tts_voice_id: str | None = None) -> RecordingListResponseSchema:
|
||||||
|
"""List workflow recordings available to the authenticated organization."""
|
||||||
|
params: dict[str, Any] = {}
|
||||||
|
if workflow_id is not None:
|
||||||
|
params["workflow_id"] = workflow_id
|
||||||
|
if tts_provider is not None:
|
||||||
|
params["tts_provider"] = tts_provider
|
||||||
|
if tts_model is not None:
|
||||||
|
params["tts_model"] = tts_model
|
||||||
|
if tts_voice_id is not None:
|
||||||
|
params["tts_voice_id"] = tts_voice_id
|
||||||
|
data = self._request("GET", "/workflow-recordings/", params=params)
|
||||||
|
return RecordingListResponseSchema.model_validate(data)
|
||||||
|
|
||||||
|
def list_tools(self, *, status: str | None = None, category: str | None = None) -> list[ToolResponse]:
|
||||||
|
"""List tools available to the authenticated organization."""
|
||||||
|
params: dict[str, Any] = {}
|
||||||
|
if status is not None:
|
||||||
|
params["status"] = status
|
||||||
|
if category is not None:
|
||||||
|
params["category"] = category
|
||||||
|
data = self._request("GET", "/tools/", params=params)
|
||||||
|
return [ToolResponse.model_validate(x) for x in data]
|
||||||
|
|
||||||
|
def list_workflows(self, *, status: str | None = None) -> list[WorkflowListResponse]:
|
||||||
|
"""List all workflows in the authenticated organization."""
|
||||||
|
params: dict[str, Any] = {}
|
||||||
|
if status is not None:
|
||||||
|
params["status"] = status
|
||||||
|
data = self._request("GET", "/workflow/fetch", params=params)
|
||||||
|
return [WorkflowListResponse.model_validate(x) for x in data]
|
||||||
|
|
||||||
|
def test_phone_call(self, *, body: InitiateCallRequest) -> Any:
|
||||||
|
"""Place a test call from a workflow to a phone number."""
|
||||||
|
return self._request("POST", "/telephony/initiate-call", json=body.model_dump(mode="json", exclude_none=True))
|
||||||
|
|
||||||
|
def update_workflow(self, workflow_id: int, *, body: UpdateWorkflowRequest) -> WorkflowResponse:
|
||||||
|
"""Update a workflow's name and/or definition. Saves as a new draft."""
|
||||||
|
data = self._request("PUT", f"/workflow/{workflow_id}", json=body.model_dump(mode="json", exclude_none=True))
|
||||||
|
return WorkflowResponse.model_validate(data)
|
||||||
358
sdk/python/src/dograh_sdk/_generated_models.py
Normal file
358
sdk/python/src/dograh_sdk/_generated_models.py
Normal file
|
|
@ -0,0 +1,358 @@
|
||||||
|
# generated by datamodel-codegen:
|
||||||
|
# filename: dograh-openapi-XXXXXX.json.oPRfLAwVZP
|
||||||
|
# timestamp: 2026-04-21T02:15:12+00:00
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class CallDispositionCodes(BaseModel):
|
||||||
|
disposition_codes: Annotated[list[str] | None, Field(title='Disposition Codes')] = (
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CreatedByResponse(BaseModel):
|
||||||
|
"""
|
||||||
|
Response schema for the user who created a tool.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: Annotated[int, Field(title='Id')]
|
||||||
|
provider_id: Annotated[str, Field(title='Provider Id')]
|
||||||
|
|
||||||
|
|
||||||
|
class CredentialResponse(BaseModel):
|
||||||
|
"""
|
||||||
|
Response schema for a webhook credential (never includes sensitive data).
|
||||||
|
"""
|
||||||
|
|
||||||
|
uuid: Annotated[str, Field(title='Uuid')]
|
||||||
|
name: Annotated[str, Field(title='Name')]
|
||||||
|
description: Annotated[str | None, Field(title='Description')]
|
||||||
|
credential_type: Annotated[str, Field(title='Credential Type')]
|
||||||
|
created_at: Annotated[AwareDatetime, Field(title='Created At')]
|
||||||
|
updated_at: Annotated[AwareDatetime | None, Field(title='Updated At')]
|
||||||
|
|
||||||
|
|
||||||
|
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"]})
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
extra='forbid',
|
||||||
|
)
|
||||||
|
show: Annotated[dict[str, list[Any]] | None, Field(title='Show')] = None
|
||||||
|
hide: Annotated[dict[str, list[Any]] | None, Field(title='Hide')] = None
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentResponseSchema(BaseModel):
|
||||||
|
"""
|
||||||
|
Response schema for document metadata.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: Annotated[int, Field(title='Id')]
|
||||||
|
document_uuid: Annotated[str, Field(title='Document Uuid')]
|
||||||
|
filename: Annotated[str, Field(title='Filename')]
|
||||||
|
file_size_bytes: Annotated[int, Field(title='File Size Bytes')]
|
||||||
|
file_hash: Annotated[str, Field(title='File Hash')]
|
||||||
|
mime_type: Annotated[str, Field(title='Mime Type')]
|
||||||
|
processing_status: Annotated[str, Field(title='Processing Status')]
|
||||||
|
processing_error: Annotated[str | None, Field(title='Processing Error')] = None
|
||||||
|
total_chunks: Annotated[int, Field(title='Total Chunks')]
|
||||||
|
retrieval_mode: Annotated[str | None, Field(title='Retrieval Mode')] = 'chunked'
|
||||||
|
custom_metadata: Annotated[dict[str, Any], Field(title='Custom Metadata')]
|
||||||
|
docling_metadata: Annotated[dict[str, Any], Field(title='Docling Metadata')]
|
||||||
|
source_url: Annotated[str | None, Field(title='Source Url')] = None
|
||||||
|
created_at: Annotated[AwareDatetime, Field(title='Created At')]
|
||||||
|
updated_at: Annotated[AwareDatetime, Field(title='Updated At')]
|
||||||
|
organization_id: Annotated[int, Field(title='Organization Id')]
|
||||||
|
created_by: Annotated[int, Field(title='Created By')]
|
||||||
|
is_active: Annotated[bool, Field(title='Is Active')]
|
||||||
|
|
||||||
|
|
||||||
|
class GraphConstraints(BaseModel):
|
||||||
|
"""
|
||||||
|
Per-node-type graph rules. WorkflowGraph enforces these at validation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
extra='forbid',
|
||||||
|
)
|
||||||
|
min_incoming: Annotated[int | None, Field(title='Min Incoming')] = None
|
||||||
|
max_incoming: Annotated[int | None, Field(title='Max Incoming')] = None
|
||||||
|
min_outgoing: Annotated[int | None, Field(title='Min Outgoing')] = None
|
||||||
|
max_outgoing: Annotated[int | None, Field(title='Max Outgoing')] = None
|
||||||
|
|
||||||
|
|
||||||
|
class InitiateCallRequest(BaseModel):
|
||||||
|
workflow_id: Annotated[int, Field(title='Workflow Id')]
|
||||||
|
workflow_run_id: Annotated[int | None, Field(title='Workflow Run Id')] = None
|
||||||
|
phone_number: Annotated[str | None, Field(title='Phone Number')] = None
|
||||||
|
|
||||||
|
|
||||||
|
class NodeCategory(Enum):
|
||||||
|
"""
|
||||||
|
Drives grouping in the AddNodePanel UI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
call_node = 'call_node'
|
||||||
|
global_node = 'global_node'
|
||||||
|
trigger = 'trigger'
|
||||||
|
integration = 'integration'
|
||||||
|
|
||||||
|
|
||||||
|
class NodeExample(BaseModel):
|
||||||
|
"""
|
||||||
|
A worked example LLMs can pattern-match. Keep small and realistic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
extra='forbid',
|
||||||
|
)
|
||||||
|
name: Annotated[str, Field(title='Name')]
|
||||||
|
description: Annotated[str | None, Field(title='Description')] = None
|
||||||
|
data: Annotated[dict[str, Any], Field(title='Data')]
|
||||||
|
|
||||||
|
|
||||||
|
class PropertyOption(BaseModel):
|
||||||
|
"""
|
||||||
|
An option in an `options` or `multi_options` dropdown.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
extra='forbid',
|
||||||
|
)
|
||||||
|
value: Annotated[str | int | bool | float, Field(title='Value')]
|
||||||
|
label: Annotated[str, Field(title='Label')]
|
||||||
|
description: Annotated[str | None, Field(title='Description')] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PropertyType(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'
|
||||||
|
multi_options = 'multi_options'
|
||||||
|
fixed_collection = 'fixed_collection'
|
||||||
|
json = 'json'
|
||||||
|
tool_refs = 'tool_refs'
|
||||||
|
document_refs = 'document_refs'
|
||||||
|
recording_ref = 'recording_ref'
|
||||||
|
credential_ref = 'credential_ref'
|
||||||
|
mention_textarea = 'mention_textarea'
|
||||||
|
url = 'url'
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingResponseSchema(BaseModel):
|
||||||
|
"""
|
||||||
|
Response schema for a single recording.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: Annotated[int, Field(title='Id')]
|
||||||
|
recording_id: Annotated[str, Field(title='Recording Id')]
|
||||||
|
workflow_id: Annotated[int | None, Field(title='Workflow Id')] = None
|
||||||
|
organization_id: Annotated[int, Field(title='Organization Id')]
|
||||||
|
tts_provider: Annotated[str | None, Field(title='Tts Provider')] = None
|
||||||
|
tts_model: Annotated[str | None, Field(title='Tts Model')] = None
|
||||||
|
tts_voice_id: Annotated[str | None, Field(title='Tts Voice Id')] = None
|
||||||
|
transcript: Annotated[str, Field(title='Transcript')]
|
||||||
|
storage_key: Annotated[str, Field(title='Storage Key')]
|
||||||
|
storage_backend: Annotated[str, Field(title='Storage Backend')]
|
||||||
|
metadata: Annotated[dict[str, Any], Field(title='Metadata')]
|
||||||
|
created_by: Annotated[int, Field(title='Created By')]
|
||||||
|
created_at: Annotated[AwareDatetime, Field(title='Created At')]
|
||||||
|
is_active: Annotated[bool, Field(title='Is Active')]
|
||||||
|
|
||||||
|
|
||||||
|
class ToolResponse(BaseModel):
|
||||||
|
"""
|
||||||
|
Response schema for a tool.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: Annotated[int, Field(title='Id')]
|
||||||
|
tool_uuid: Annotated[str, Field(title='Tool Uuid')]
|
||||||
|
name: Annotated[str, Field(title='Name')]
|
||||||
|
description: Annotated[str | None, Field(title='Description')]
|
||||||
|
category: Annotated[str, Field(title='Category')]
|
||||||
|
icon: Annotated[str | None, Field(title='Icon')]
|
||||||
|
icon_color: Annotated[str | None, Field(title='Icon Color')]
|
||||||
|
status: Annotated[str, Field(title='Status')]
|
||||||
|
definition: Annotated[dict[str, Any], Field(title='Definition')]
|
||||||
|
created_at: Annotated[AwareDatetime, Field(title='Created At')]
|
||||||
|
updated_at: Annotated[AwareDatetime | None, Field(title='Updated At')]
|
||||||
|
created_by: CreatedByResponse | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateWorkflowRequest(BaseModel):
|
||||||
|
name: Annotated[str | None, Field(title='Name')] = None
|
||||||
|
workflow_definition: Annotated[
|
||||||
|
dict[str, Any] | None, Field(title='Workflow Definition')
|
||||||
|
] = None
|
||||||
|
template_context_variables: Annotated[
|
||||||
|
dict[str, Any] | None, Field(title='Template Context Variables')
|
||||||
|
] = None
|
||||||
|
workflow_configurations: Annotated[
|
||||||
|
dict[str, Any] | None, Field(title='Workflow Configurations')
|
||||||
|
] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationError(BaseModel):
|
||||||
|
loc: Annotated[list[str | int], Field(title='Location')]
|
||||||
|
msg: Annotated[str, Field(title='Message')]
|
||||||
|
type: Annotated[str, Field(title='Error Type')]
|
||||||
|
input: Annotated[Any | None, Field(title='Input')] = None
|
||||||
|
ctx: Annotated[dict[str, Any] | None, Field(title='Context')] = None
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowListResponse(BaseModel):
|
||||||
|
"""
|
||||||
|
Lightweight response for workflow listings (excludes large fields).
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: Annotated[int, Field(title='Id')]
|
||||||
|
name: Annotated[str, Field(title='Name')]
|
||||||
|
status: Annotated[str, Field(title='Status')]
|
||||||
|
created_at: Annotated[AwareDatetime, Field(title='Created At')]
|
||||||
|
total_runs: Annotated[int, Field(title='Total Runs')]
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowResponse(BaseModel):
|
||||||
|
id: Annotated[int, Field(title='Id')]
|
||||||
|
name: Annotated[str, Field(title='Name')]
|
||||||
|
status: Annotated[str, Field(title='Status')]
|
||||||
|
created_at: Annotated[AwareDatetime, Field(title='Created At')]
|
||||||
|
workflow_definition: Annotated[dict[str, Any], Field(title='Workflow Definition')]
|
||||||
|
current_definition_id: Annotated[int | None, Field(title='Current Definition Id')]
|
||||||
|
template_context_variables: Annotated[
|
||||||
|
dict[str, Any] | None, Field(title='Template Context Variables')
|
||||||
|
] = None
|
||||||
|
call_disposition_codes: CallDispositionCodes | None = None
|
||||||
|
total_runs: Annotated[int | None, Field(title='Total Runs')] = None
|
||||||
|
workflow_configurations: Annotated[
|
||||||
|
dict[str, Any] | None, Field(title='Workflow Configurations')
|
||||||
|
] = None
|
||||||
|
version_number: Annotated[int | None, Field(title='Version Number')] = None
|
||||||
|
version_status: Annotated[str | None, Field(title='Version Status')] = None
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentListResponseSchema(BaseModel):
|
||||||
|
"""
|
||||||
|
Response schema for list of documents.
|
||||||
|
"""
|
||||||
|
|
||||||
|
documents: Annotated[list[DocumentResponseSchema], Field(title='Documents')]
|
||||||
|
total: Annotated[int, Field(title='Total')]
|
||||||
|
limit: Annotated[int, Field(title='Limit')]
|
||||||
|
offset: Annotated[int, Field(title='Offset')]
|
||||||
|
|
||||||
|
|
||||||
|
class HTTPValidationError(BaseModel):
|
||||||
|
detail: Annotated[list[ValidationError] | None, Field(title='Detail')] = None
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
extra='forbid',
|
||||||
|
)
|
||||||
|
name: Annotated[str, Field(title='Name')]
|
||||||
|
type: PropertyType
|
||||||
|
display_name: Annotated[str, Field(title='Display Name')]
|
||||||
|
description: Annotated[str, Field(min_length=1, title='Description')]
|
||||||
|
"""
|
||||||
|
Human-facing explanation shown in the UI.
|
||||||
|
"""
|
||||||
|
llm_hint: Annotated[str | None, Field(title='Llm Hint')] = None
|
||||||
|
"""
|
||||||
|
LLM-only guidance; omitted from the UI.
|
||||||
|
"""
|
||||||
|
default: Annotated[Any | None, Field(title='Default')] = None
|
||||||
|
required: Annotated[bool | None, Field(title='Required')] = False
|
||||||
|
placeholder: Annotated[str | None, Field(title='Placeholder')] = None
|
||||||
|
display_options: DisplayOptions | None = None
|
||||||
|
options: Annotated[list[PropertyOption] | None, Field(title='Options')] = None
|
||||||
|
properties: Annotated[list[PropertySpec] | None, Field(title='Properties')] = None
|
||||||
|
min_value: Annotated[float | None, Field(title='Min Value')] = None
|
||||||
|
max_value: Annotated[float | None, Field(title='Max Value')] = None
|
||||||
|
min_length: Annotated[int | None, Field(title='Min Length')] = None
|
||||||
|
max_length: Annotated[int | None, Field(title='Max Length')] = None
|
||||||
|
pattern: Annotated[str | None, Field(title='Pattern')] = None
|
||||||
|
editor: Annotated[str | None, Field(title='Editor')] = None
|
||||||
|
extra: Annotated[dict[str, Any] | None, Field(title='Extra')] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingListResponseSchema(BaseModel):
|
||||||
|
"""
|
||||||
|
Response schema for list of recordings.
|
||||||
|
"""
|
||||||
|
|
||||||
|
recordings: Annotated[list[RecordingResponseSchema], Field(title='Recordings')]
|
||||||
|
total: Annotated[int, Field(title='Total')]
|
||||||
|
|
||||||
|
|
||||||
|
class NodeSpec(BaseModel):
|
||||||
|
"""
|
||||||
|
Single source of truth for a node type.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
extra='forbid',
|
||||||
|
)
|
||||||
|
name: Annotated[str, Field(title='Name')]
|
||||||
|
display_name: Annotated[str, Field(title='Display Name')]
|
||||||
|
description: Annotated[str, Field(min_length=1, title='Description')]
|
||||||
|
"""
|
||||||
|
Human-facing explanation shown in AddNodePanel.
|
||||||
|
"""
|
||||||
|
llm_hint: Annotated[str | None, Field(title='Llm Hint')] = None
|
||||||
|
"""
|
||||||
|
LLM-only guidance; omitted from the UI.
|
||||||
|
"""
|
||||||
|
category: NodeCategory
|
||||||
|
icon: Annotated[str, Field(title='Icon')]
|
||||||
|
version: Annotated[str | None, Field(title='Version')] = '1.0.0'
|
||||||
|
properties: Annotated[list[PropertySpec], Field(title='Properties')]
|
||||||
|
examples: Annotated[list[NodeExample] | None, Field(title='Examples')] = None
|
||||||
|
graph_constraints: GraphConstraints | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class NodeTypesResponse(BaseModel):
|
||||||
|
spec_version: Annotated[str, Field(title='Spec Version')]
|
||||||
|
node_types: Annotated[list[NodeSpec], Field(title='Node Types')]
|
||||||
|
|
||||||
|
|
||||||
|
PropertySpec.model_rebuild()
|
||||||
166
sdk/python/src/dograh_sdk/_validation.py
Normal file
166
sdk/python/src/dograh_sdk/_validation.py
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
"""Client-side validation of node data against a fetched spec.
|
||||||
|
|
||||||
|
Intentionally lightweight: we catch the hallucinations that matter (typo'd
|
||||||
|
field names, missing required fields, obvious scalar-type mismatches) and
|
||||||
|
leave rigorous coercion to the backend's Pydantic validators, which run at
|
||||||
|
save time. The tradeoff: the SDK fails fast on mistakes an LLM makes, and
|
||||||
|
the backend remains the single authority on wire-format correctness.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .errors import ValidationError
|
||||||
|
|
||||||
|
# Map PropertyType → primitive Python type(s) we check at call site.
|
||||||
|
# `None` means "skip scalar-type check" (compound types, refs, JSON, etc.).
|
||||||
|
_SCALAR_TYPES: dict[str, tuple[type, ...] | None] = {
|
||||||
|
"string": (str,),
|
||||||
|
"number": (int, float),
|
||||||
|
"boolean": (bool,),
|
||||||
|
"options": None, # value-in-options handled separately
|
||||||
|
"multi_options": None,
|
||||||
|
"fixed_collection": (list,),
|
||||||
|
"json": None, # any JSON-serializable
|
||||||
|
"tool_refs": (list,),
|
||||||
|
"document_refs": (list,),
|
||||||
|
"recording_ref": (str,),
|
||||||
|
"credential_ref": (str,),
|
||||||
|
"mention_textarea": (str,),
|
||||||
|
"url": (str,),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _with_hint(prop: dict[str, Any], message: str) -> str:
|
||||||
|
"""Append `prop.llm_hint` to an error message when set.
|
||||||
|
|
||||||
|
Surfacing the hint inside validation errors lets an LLM author
|
||||||
|
self-correct on retry — it sees the catalog reference or value-shape
|
||||||
|
guidance inline with the failure.
|
||||||
|
"""
|
||||||
|
hint = prop.get("llm_hint")
|
||||||
|
if hint:
|
||||||
|
return f"{message}\n Hint: {hint}"
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
def _check_scalar(prop: dict[str, Any], value: Any) -> None:
|
||||||
|
# None is always allowed (missing value handled by required check).
|
||||||
|
if value is None:
|
||||||
|
return
|
||||||
|
allowed = _SCALAR_TYPES.get(prop["type"])
|
||||||
|
if allowed is None:
|
||||||
|
return
|
||||||
|
# Booleans ARE ints in Python, so exclude accidentally-matching bools.
|
||||||
|
if bool in allowed and not (int in allowed or float in allowed):
|
||||||
|
if not isinstance(value, bool):
|
||||||
|
raise ValidationError(
|
||||||
|
_with_hint(
|
||||||
|
prop,
|
||||||
|
f"{prop['name']}: expected boolean, got {type(value).__name__}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if not isinstance(value, allowed):
|
||||||
|
raise ValidationError(
|
||||||
|
_with_hint(
|
||||||
|
prop,
|
||||||
|
f"{prop['name']}: expected {prop['type']}, "
|
||||||
|
f"got {type(value).__name__}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_options(prop: dict[str, Any], value: Any) -> None:
|
||||||
|
if value is None:
|
||||||
|
return
|
||||||
|
allowed = {o["value"] for o in prop.get("options") or []}
|
||||||
|
if not allowed:
|
||||||
|
return
|
||||||
|
if prop["type"] == "multi_options":
|
||||||
|
if not isinstance(value, list):
|
||||||
|
raise ValidationError(
|
||||||
|
_with_hint(
|
||||||
|
prop,
|
||||||
|
f"{prop['name']}: expected list, got {type(value).__name__}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
bad = [v for v in value if v not in allowed]
|
||||||
|
if bad:
|
||||||
|
raise ValidationError(
|
||||||
|
_with_hint(
|
||||||
|
prop,
|
||||||
|
f"{prop['name']}: values {bad} not in allowed {sorted(allowed)}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else: # 'options'
|
||||||
|
if value not in allowed:
|
||||||
|
raise ValidationError(
|
||||||
|
_with_hint(
|
||||||
|
prop,
|
||||||
|
f"{prop['name']}: {value!r} not in allowed {sorted(allowed)}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_node_data(
|
||||||
|
spec: dict[str, Any],
|
||||||
|
kwargs: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Validate LLM-supplied kwargs against the node spec. Returns the data
|
||||||
|
dict to embed in the wire format, with defaults applied.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError if kwargs contain unknown fields, omit required
|
||||||
|
fields, or carry obvious type mismatches.
|
||||||
|
"""
|
||||||
|
declared = {p["name"]: p for p in spec["properties"]}
|
||||||
|
|
||||||
|
# Unknown field names — the most common LLM hallucination.
|
||||||
|
unknown = set(kwargs) - set(declared)
|
||||||
|
if unknown:
|
||||||
|
raise ValidationError(
|
||||||
|
f"{spec['name']}: unknown field(s) {sorted(unknown)}. "
|
||||||
|
f"Allowed: {sorted(declared)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Per-property validation
|
||||||
|
data: dict[str, Any] = {}
|
||||||
|
for name, prop in declared.items():
|
||||||
|
if name in kwargs:
|
||||||
|
value = kwargs[name]
|
||||||
|
elif prop.get("default") is not None:
|
||||||
|
value = prop["default"]
|
||||||
|
else:
|
||||||
|
value = None
|
||||||
|
|
||||||
|
# Scalar / collection shape
|
||||||
|
if prop["type"] in ("options", "multi_options"):
|
||||||
|
_check_options(prop, value)
|
||||||
|
else:
|
||||||
|
_check_scalar(prop, value)
|
||||||
|
|
||||||
|
# Nested fixed_collection rows — validate each row as a sub-spec.
|
||||||
|
if prop["type"] == "fixed_collection" and isinstance(value, list):
|
||||||
|
sub_spec = {"name": f"{spec['name']}.{name}", "properties": prop.get("properties") or []}
|
||||||
|
data[name] = [validate_node_data(sub_spec, row) for row in value]
|
||||||
|
continue
|
||||||
|
|
||||||
|
if value is not None:
|
||||||
|
data[name] = value
|
||||||
|
|
||||||
|
# Required check — must be set AND non-empty for strings.
|
||||||
|
for name, prop in declared.items():
|
||||||
|
if not prop.get("required"):
|
||||||
|
continue
|
||||||
|
val = data.get(name)
|
||||||
|
if val is None or (isinstance(val, str) and val.strip() == ""):
|
||||||
|
raise ValidationError(
|
||||||
|
_with_hint(
|
||||||
|
prop,
|
||||||
|
f"{spec['name']}: required field missing: {name}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return data
|
||||||
151
sdk/python/src/dograh_sdk/client.py
Normal file
151
sdk/python/src/dograh_sdk/client.py
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
"""HTTP client for the Dograh REST API.
|
||||||
|
|
||||||
|
Most endpoint methods come from `_GeneratedClient` (auto-generated from
|
||||||
|
the FastAPI OpenAPI spec — see `scripts/generate_sdk.sh`). This class
|
||||||
|
adds the session/auth/cache surface around that mixin plus a couple of
|
||||||
|
ergonomic wrappers (`load_workflow`, `save_workflow`) that compose a
|
||||||
|
generated call with local `Workflow` hydration.
|
||||||
|
|
||||||
|
The SDK surface on the backend is controlled by decorating routes with
|
||||||
|
`@sdk_expose(method="...")`; anything else is invisible here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from ._generated_client import _GeneratedClient
|
||||||
|
from ._generated_models import (
|
||||||
|
NodeSpec,
|
||||||
|
NodeTypesResponse,
|
||||||
|
UpdateWorkflowRequest,
|
||||||
|
WorkflowResponse,
|
||||||
|
)
|
||||||
|
from .errors import ApiError, SpecMismatchError
|
||||||
|
from .workflow import Workflow
|
||||||
|
|
||||||
|
|
||||||
|
class DograhClient(_GeneratedClient):
|
||||||
|
"""Sync HTTP client. Suitable for scripts, pytest, and the LLM SDK
|
||||||
|
exec sandbox.
|
||||||
|
|
||||||
|
Auth precedence:
|
||||||
|
1. `api_key` kwarg
|
||||||
|
2. `DOGRAH_API_KEY` env var
|
||||||
|
3. unauthenticated (most endpoints will 401)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
base_url: str | None = None,
|
||||||
|
api_key: str | None = None,
|
||||||
|
timeout: float = 30.0,
|
||||||
|
):
|
||||||
|
resolved_url = base_url or os.environ.get(
|
||||||
|
"DOGRAH_API_URL", "http://localhost:8000"
|
||||||
|
)
|
||||||
|
self.base_url = resolved_url.rstrip("/")
|
||||||
|
self.api_key = api_key or os.environ.get("DOGRAH_API_KEY")
|
||||||
|
|
||||||
|
headers = {"Accept": "application/json"}
|
||||||
|
if self.api_key:
|
||||||
|
headers["X-API-Key"] = self.api_key
|
||||||
|
|
||||||
|
self._http = httpx.Client(
|
||||||
|
base_url=f"{self.base_url}/api/v1",
|
||||||
|
headers=headers,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Populated by the first call to `list_node_types` / `get_node_type`
|
||||||
|
# — avoids repeated round-trips when building a workflow.
|
||||||
|
self._spec_cache: dict[str, NodeSpec] = {}
|
||||||
|
self._spec_version: str | None = None
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._http.close()
|
||||||
|
|
||||||
|
def __enter__(self) -> DograhClient:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args: Any) -> None:
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def spec_version(self) -> str | None:
|
||||||
|
"""Contract version reported by the server, or None until the
|
||||||
|
first `list_node_types` / `get_node_type` call."""
|
||||||
|
return self._spec_version
|
||||||
|
|
||||||
|
# ── spec discovery overrides (generated methods + caching) ────────
|
||||||
|
|
||||||
|
def list_node_types(self) -> NodeTypesResponse:
|
||||||
|
resp = super().list_node_types()
|
||||||
|
self._spec_version = resp.spec_version
|
||||||
|
for spec in resp.node_types:
|
||||||
|
self._spec_cache[spec.name] = spec
|
||||||
|
return resp
|
||||||
|
|
||||||
|
def get_node_type(self, name: str) -> NodeSpec:
|
||||||
|
cached = self._spec_cache.get(name)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
try:
|
||||||
|
spec = super().get_node_type(name)
|
||||||
|
except ApiError as e:
|
||||||
|
if e.status_code == 404:
|
||||||
|
raise SpecMismatchError(f"Unknown node type: {name!r}") from e
|
||||||
|
raise
|
||||||
|
self._spec_cache[name] = spec
|
||||||
|
return spec
|
||||||
|
|
||||||
|
# ── ergonomic workflow wrappers ───────────────────────────────────
|
||||||
|
|
||||||
|
def load_workflow(self, workflow_id: int) -> Workflow:
|
||||||
|
"""Fetch a workflow and hydrate it into an editable `Workflow` builder."""
|
||||||
|
resp = self.get_workflow(workflow_id)
|
||||||
|
if not resp.workflow_definition:
|
||||||
|
raise ApiError(
|
||||||
|
200,
|
||||||
|
f"Workflow {workflow_id} has no definition to load",
|
||||||
|
body=resp.model_dump(mode="json"),
|
||||||
|
)
|
||||||
|
return Workflow.from_json(
|
||||||
|
resp.workflow_definition, client=self, name=resp.name
|
||||||
|
)
|
||||||
|
|
||||||
|
def save_workflow(self, workflow_id: int, workflow: Workflow) -> WorkflowResponse:
|
||||||
|
"""Persist a `Workflow` builder back to the server as a new draft."""
|
||||||
|
return self.update_workflow(
|
||||||
|
workflow_id,
|
||||||
|
body=UpdateWorkflowRequest(
|
||||||
|
name=workflow.name,
|
||||||
|
workflow_definition=workflow.to_json(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── low-level ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
||||||
|
resp = self._http.request(method, path, **kwargs)
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
try:
|
||||||
|
body = resp.json()
|
||||||
|
if isinstance(body, dict):
|
||||||
|
message = body.get("detail") or body.get("message") or resp.text
|
||||||
|
else:
|
||||||
|
message = resp.text
|
||||||
|
except ValueError:
|
||||||
|
body = resp.text
|
||||||
|
message = resp.text
|
||||||
|
raise ApiError(resp.status_code, message, body=body)
|
||||||
|
if resp.status_code == 204 or not resp.content:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return resp.json()
|
||||||
|
except ValueError:
|
||||||
|
return resp.text
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue