mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-22 08:38:13 +02:00
feat: agent versioning and model configurations override (#227)
* feat: add tests and migrations * feat: workflow versioning among published and draft * feat: add a new settings page to simplify workflow detail page * fix: fix tsclient generation
This commit is contained in:
parent
f5fa9ce717
commit
38d1d928b7
62 changed files with 10158 additions and 3131 deletions
83
api/services/configuration/resolve.py
Normal file
83
api/services/configuration/resolve.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"""Resolve effective config by merging per-workflow model overrides onto global config."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from api.schemas.user_configuration import UserConfiguration
|
||||
from api.services.configuration.registry import (
|
||||
REGISTRY,
|
||||
ServiceType,
|
||||
)
|
||||
|
||||
# Maps override key → (UserConfiguration field, ServiceType for registry lookup)
|
||||
_SECTION_MAP: dict[str, ServiceType] = {
|
||||
"llm": ServiceType.LLM,
|
||||
"tts": ServiceType.TTS,
|
||||
"stt": ServiceType.STT,
|
||||
"realtime": ServiceType.REALTIME,
|
||||
}
|
||||
|
||||
|
||||
def _build_section_from_override(service_type: ServiceType, override: dict):
|
||||
"""Construct a typed config object from a raw override dict using the registry."""
|
||||
provider = override.get("provider")
|
||||
if not provider:
|
||||
return None
|
||||
registry = REGISTRY.get(service_type, {})
|
||||
config_cls = registry.get(provider)
|
||||
if config_cls is None:
|
||||
return None
|
||||
return config_cls(**override)
|
||||
|
||||
|
||||
def resolve_effective_config(
|
||||
user_config: UserConfiguration,
|
||||
model_overrides: dict | None,
|
||||
) -> UserConfiguration:
|
||||
"""Deep-merge workflow model_overrides onto global user config.
|
||||
|
||||
- If model_overrides is None or empty, returns a copy of user_config unchanged.
|
||||
- For each section (llm, tts, stt, realtime), if the override contains that key:
|
||||
- If the global section is None, construct a new config from the override.
|
||||
- If the provider changes, construct a new config from the override.
|
||||
- Otherwise, merge override fields onto the existing config (model_copy).
|
||||
- is_realtime is a simple boolean override.
|
||||
- Sections not in the override are inherited from global unchanged.
|
||||
- The original user_config is never mutated.
|
||||
"""
|
||||
if not model_overrides:
|
||||
return user_config.model_copy(deep=True)
|
||||
|
||||
effective = user_config.model_copy(deep=True)
|
||||
|
||||
# Handle is_realtime boolean
|
||||
if "is_realtime" in model_overrides:
|
||||
effective.is_realtime = model_overrides["is_realtime"]
|
||||
|
||||
# Handle service sections
|
||||
for section_key, service_type in _SECTION_MAP.items():
|
||||
if section_key not in model_overrides:
|
||||
continue
|
||||
|
||||
override = model_overrides[section_key]
|
||||
base = getattr(effective, section_key)
|
||||
|
||||
if base is None:
|
||||
# No global config for this section — build from override
|
||||
setattr(
|
||||
effective,
|
||||
section_key,
|
||||
_build_section_from_override(service_type, override),
|
||||
)
|
||||
elif "provider" in override and override["provider"] != base.provider:
|
||||
# Provider changed — must construct new typed object
|
||||
setattr(
|
||||
effective,
|
||||
section_key,
|
||||
_build_section_from_override(service_type, override),
|
||||
)
|
||||
else:
|
||||
# Same provider — merge fields onto existing config
|
||||
merged = base.model_copy(update=override)
|
||||
setattr(effective, section_key, merged)
|
||||
|
||||
return effective
|
||||
|
|
@ -76,13 +76,15 @@ class LoopTalkPipelineBuilder:
|
|||
pipeline_sample_rate=16000,
|
||||
)
|
||||
|
||||
# Use published definition for graph + configs
|
||||
released_def = workflow.released_definition
|
||||
wf_json = released_def.workflow_json
|
||||
wf_configs = released_def.workflow_configurations or {}
|
||||
|
||||
# Extract keyterms from workflow configurations
|
||||
keyterms = None
|
||||
if (
|
||||
workflow.workflow_configurations
|
||||
and "dictionary" in workflow.workflow_configurations
|
||||
):
|
||||
dictionary = workflow.workflow_configurations["dictionary"]
|
||||
if wf_configs and "dictionary" in wf_configs:
|
||||
dictionary = wf_configs["dictionary"]
|
||||
if dictionary and isinstance(dictionary, str):
|
||||
keyterms = [
|
||||
term.strip() for term in dictionary.split(",") if term.strip()
|
||||
|
|
@ -90,6 +92,12 @@ class LoopTalkPipelineBuilder:
|
|||
if keyterms:
|
||||
logger.info(f"Using {len(keyterms)} keyterms for STT: {keyterms}")
|
||||
|
||||
# Resolve model overrides from the version onto global user config
|
||||
from api.services.configuration.resolve import resolve_effective_config
|
||||
|
||||
model_overrides = wf_configs.get("model_overrides")
|
||||
user_config = resolve_effective_config(user_config, model_overrides)
|
||||
|
||||
# Create services
|
||||
stt = create_stt_service(user_config, audio_config, keyterms=keyterms)
|
||||
llm = create_llm_service(user_config)
|
||||
|
|
@ -98,9 +106,7 @@ class LoopTalkPipelineBuilder:
|
|||
logger.debug(f"Created services for {role}: STT={stt}, LLM={llm}, TTS={tts}")
|
||||
|
||||
# Get workflow graph
|
||||
workflow_graph = WorkflowGraph(
|
||||
ReactFlowDTO.model_validate(workflow.workflow_definition_with_fallback)
|
||||
)
|
||||
workflow_graph = WorkflowGraph(ReactFlowDTO.model_validate(wf_json))
|
||||
|
||||
# Create engine first (needed for create_pipeline_components)
|
||||
engine = PipecatEngine(
|
||||
|
|
|
|||
|
|
@ -562,50 +562,49 @@ async def _run_pipeline(
|
|||
# Get user configuration
|
||||
user_config = await db_client.get_user_configurations(user_id)
|
||||
|
||||
# Get workflow first so we can extract configurations before creating services
|
||||
# Get workflow for metadata (name, organization_id, call_disposition_codes)
|
||||
workflow = await db_client.get_workflow(workflow_id, user_id)
|
||||
if not workflow:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
|
||||
# Extract configurations from workflow configurations
|
||||
# Use the run's pinned definition for graph + configs (not the workflow's current)
|
||||
run_definition = workflow_run.definition
|
||||
run_workflow_json = run_definition.workflow_json
|
||||
run_configs = run_definition.workflow_configurations or {}
|
||||
|
||||
# Extract configurations from the version's workflow_configurations
|
||||
max_call_duration_seconds = 300 # Default 5 minutes
|
||||
max_user_idle_timeout = 10.0 # Default 10 seconds
|
||||
smart_turn_stop_secs = 2.0 # Default 2 seconds for incomplete turn timeout
|
||||
turn_stop_strategy = "transcription" # Default to transcription-based detection
|
||||
keyterms = None # Dictionary words for STT boosting
|
||||
|
||||
if workflow.workflow_configurations:
|
||||
# Use workflow-specific max call duration if provided
|
||||
if "max_call_duration" in workflow.workflow_configurations:
|
||||
max_call_duration_seconds = workflow.workflow_configurations[
|
||||
"max_call_duration"
|
||||
]
|
||||
if run_configs:
|
||||
if "max_call_duration" in run_configs:
|
||||
max_call_duration_seconds = run_configs["max_call_duration"]
|
||||
|
||||
# Use workflow-specific max user idle timeout if provided
|
||||
if "max_user_idle_timeout" in workflow.workflow_configurations:
|
||||
max_user_idle_timeout = workflow.workflow_configurations[
|
||||
"max_user_idle_timeout"
|
||||
]
|
||||
if "max_user_idle_timeout" in run_configs:
|
||||
max_user_idle_timeout = run_configs["max_user_idle_timeout"]
|
||||
|
||||
# Use workflow-specific smart turn stop timeout if provided
|
||||
if "smart_turn_stop_secs" in workflow.workflow_configurations:
|
||||
smart_turn_stop_secs = workflow.workflow_configurations[
|
||||
"smart_turn_stop_secs"
|
||||
]
|
||||
if "smart_turn_stop_secs" in run_configs:
|
||||
smart_turn_stop_secs = run_configs["smart_turn_stop_secs"]
|
||||
|
||||
# Use workflow-specific turn stop strategy if provided
|
||||
if "turn_stop_strategy" in workflow.workflow_configurations:
|
||||
turn_stop_strategy = workflow.workflow_configurations["turn_stop_strategy"]
|
||||
if "turn_stop_strategy" in run_configs:
|
||||
turn_stop_strategy = run_configs["turn_stop_strategy"]
|
||||
|
||||
# Extract dictionary words and convert to keyterms list
|
||||
if "dictionary" in workflow.workflow_configurations:
|
||||
dictionary = workflow.workflow_configurations["dictionary"]
|
||||
if "dictionary" in run_configs:
|
||||
dictionary = run_configs["dictionary"]
|
||||
if dictionary and isinstance(dictionary, str):
|
||||
# Split by comma and strip whitespace from each term
|
||||
keyterms = [
|
||||
term.strip() for term in dictionary.split(",") if term.strip()
|
||||
]
|
||||
|
||||
# Resolve model overrides from the version onto global user config
|
||||
from api.services.configuration.resolve import resolve_effective_config
|
||||
|
||||
model_overrides = run_configs.get("model_overrides")
|
||||
user_config = resolve_effective_config(user_config, model_overrides)
|
||||
|
||||
# Detect realtime mode (speech-to-speech services like OpenAI Realtime, Gemini Live)
|
||||
is_realtime = user_config.is_realtime and user_config.realtime is not None
|
||||
|
||||
|
|
@ -619,9 +618,7 @@ async def _run_pipeline(
|
|||
tts = create_tts_service(user_config, audio_config)
|
||||
llm = create_llm_service(user_config)
|
||||
|
||||
workflow_graph = WorkflowGraph(
|
||||
ReactFlowDTO.model_validate(workflow.workflow_definition_with_fallback)
|
||||
)
|
||||
workflow_graph = WorkflowGraph(ReactFlowDTO.model_validate(run_workflow_json))
|
||||
|
||||
# Pre-call fetch: fire early so it runs concurrently with remaining setup
|
||||
pre_call_fetch_task = None
|
||||
|
|
|
|||
|
|
@ -325,8 +325,6 @@ def create_tts_service(user_config, audio_config: "AudioConfig"):
|
|||
silence_time_s=1.0,
|
||||
)
|
||||
elif user_config.tts.provider == ServiceProviders.RIME.value:
|
||||
from pipecat.transcriptions.language import Language
|
||||
|
||||
speed = getattr(user_config.tts, "speed", None)
|
||||
language_code = getattr(user_config.tts, "language", None) or "en"
|
||||
rime_language_mapping = {
|
||||
|
|
|
|||
|
|
@ -74,13 +74,17 @@ async def duplicate_workflow(
|
|||
if source is None:
|
||||
raise ValueError(f"Workflow with id {workflow_id} not found")
|
||||
|
||||
workflow_definition = copy.deepcopy(source.workflow_definition_with_fallback)
|
||||
# 2. Prefer draft over released definition (duplicate latest state)
|
||||
draft = await db_client.get_draft_version(workflow_id)
|
||||
source_def = draft if draft else source.released_definition
|
||||
|
||||
# 2. Regenerate trigger UUIDs to avoid conflicts
|
||||
workflow_definition = copy.deepcopy(source_def.workflow_json)
|
||||
|
||||
# 3. Regenerate trigger UUIDs to avoid conflicts
|
||||
if workflow_definition:
|
||||
workflow_definition = _regenerate_trigger_uuids(workflow_definition)
|
||||
|
||||
# 3. Create the new workflow
|
||||
# 4. Create the new workflow
|
||||
new_name = f"{source.name} - Duplicate"
|
||||
new_workflow = await db_client.create_workflow(
|
||||
name=new_name,
|
||||
|
|
@ -89,21 +93,20 @@ async def duplicate_workflow(
|
|||
organization_id=organization_id,
|
||||
)
|
||||
|
||||
# 4. Copy template_context_variables and workflow_configurations
|
||||
has_extra_fields = (
|
||||
source.template_context_variables or source.workflow_configurations
|
||||
)
|
||||
if has_extra_fields:
|
||||
# 5. Copy template_context_variables and workflow_configurations from source definition
|
||||
source_tcv = source_def.template_context_variables
|
||||
source_wc = source_def.workflow_configurations
|
||||
if source_tcv or source_wc:
|
||||
new_workflow = await db_client.update_workflow(
|
||||
workflow_id=new_workflow.id,
|
||||
name=None,
|
||||
workflow_definition=None,
|
||||
template_context_variables=copy.deepcopy(source.template_context_variables),
|
||||
workflow_configurations=copy.deepcopy(source.workflow_configurations),
|
||||
template_context_variables=copy.deepcopy(source_tcv),
|
||||
workflow_configurations=copy.deepcopy(source_wc),
|
||||
organization_id=organization_id,
|
||||
)
|
||||
|
||||
# 5. Copy recordings with new IDs and storage paths scoped to new workflow
|
||||
# 6. Copy recordings with new IDs and storage paths scoped to new workflow
|
||||
recording_id_map = await _duplicate_recordings(
|
||||
source_workflow_id=workflow_id,
|
||||
new_workflow_id=new_workflow.id,
|
||||
|
|
@ -111,7 +114,7 @@ async def duplicate_workflow(
|
|||
user_id=user_id,
|
||||
)
|
||||
|
||||
# 6. Replace old recording IDs with new ones in the workflow definition
|
||||
# 7. Replace old recording IDs with new ones in the workflow definition
|
||||
if recording_id_map:
|
||||
workflow_definition = _replace_recording_ids(
|
||||
workflow_definition, recording_id_map
|
||||
|
|
@ -125,7 +128,7 @@ async def duplicate_workflow(
|
|||
organization_id=organization_id,
|
||||
)
|
||||
|
||||
# 7. Sync triggers for the new workflow
|
||||
# 8. Sync triggers for the new workflow
|
||||
if workflow_definition:
|
||||
trigger_paths = _extract_trigger_paths(workflow_definition)
|
||||
if trigger_paths:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue