feat: add recording audio option in tool and node transitions (#232)

* feat: allow uploading recording as part of node transition

* feat: allow recordings in tool transitions

* chore: fix tests
This commit is contained in:
Abhishek 2026-04-10 17:53:42 +05:30 committed by GitHub
parent 3f19a16e7f
commit 7c245051d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 3575 additions and 640 deletions

View file

@ -1005,7 +1005,7 @@ class KnowledgeBaseDocumentModel(Base):
class WorkflowRecordingModel(Base):
"""Model for storing audio recordings scoped to a workflow and TTS configuration.
"""Model for storing audio recordings scoped to an organization.
Recordings are used in hybrid prompts where parts of the output are pre-recorded
audio rather than dynamically generated TTS.
@ -1015,21 +1015,21 @@ class WorkflowRecordingModel(Base):
id = Column(Integer, primary_key=True, index=True)
# Short globally unique ID (e.g. "xbhfha3k") used in prompts
recording_id = Column(String(16), unique=True, nullable=False, index=True)
# Descriptive ID used in prompts (unique per organization)
recording_id = Column(String(64), nullable=False, index=True)
# Scoping
workflow_id = Column(
Integer, ForeignKey("workflows.id", ondelete="CASCADE"), nullable=False
Integer, ForeignKey("workflows.id", ondelete="CASCADE"), nullable=True
)
organization_id = Column(
Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
)
# TTS configuration scope
tts_provider = Column(String, nullable=False)
tts_model = Column(String, nullable=False)
tts_voice_id = Column(String, nullable=False)
# TTS configuration metadata (optional, legacy)
tts_provider = Column(String, nullable=True)
tts_model = Column(String, nullable=True)
tts_voice_id = Column(String, nullable=True)
# Content
transcript = Column(Text, nullable=False)
@ -1062,16 +1062,14 @@ class WorkflowRecordingModel(Base):
# Indexes
__table_args__ = (
UniqueConstraint(
"recording_id",
"organization_id",
name="uq_workflow_recordings_recording_id_org",
),
Index("ix_workflow_recordings_workflow_id", "workflow_id"),
Index("ix_workflow_recordings_org_id", "organization_id"),
Index("ix_workflow_recordings_recording_id", "recording_id"),
Index(
"ix_workflow_recordings_tts_scope",
"workflow_id",
"tts_provider",
"tts_model",
"tts_voice_id",
),
)

View file

@ -5,7 +5,7 @@ import string
from typing import List, Optional
from loguru import logger
from sqlalchemy import func, select
from sqlalchemy import func, select, text
from api.db.base_client import BaseDBClient
from api.db.models import WorkflowRecordingModel
@ -23,30 +23,30 @@ class WorkflowRecordingClient(BaseDBClient):
async def create_recording(
self,
recording_id: str,
workflow_id: int,
organization_id: int,
tts_provider: str,
tts_model: str,
tts_voice_id: str,
transcript: str,
storage_key: str,
storage_backend: str,
created_by: int,
workflow_id: Optional[int] = None,
tts_provider: Optional[str] = None,
tts_model: Optional[str] = None,
tts_voice_id: Optional[str] = None,
metadata: Optional[dict] = None,
) -> WorkflowRecordingModel:
"""Create a new workflow recording record.
Args:
recording_id: Short unique recording identifier
workflow_id: ID of the workflow
organization_id: ID of the organization
tts_provider: TTS provider name
tts_model: TTS model name
tts_voice_id: TTS voice identifier
transcript: User-provided transcript
storage_key: S3/MinIO storage key
storage_backend: Storage backend (s3 or minio)
created_by: ID of the user
workflow_id: Optional workflow ID (legacy)
tts_provider: Optional TTS provider name
tts_model: Optional TTS model name
tts_voice_id: Optional TTS voice identifier
metadata: Optional extra metadata
Returns:
@ -71,25 +71,22 @@ class WorkflowRecordingClient(BaseDBClient):
await session.commit()
await session.refresh(recording)
logger.info(
f"Created recording {recording_id} for workflow {workflow_id}, "
f"org {organization_id}"
)
logger.info(f"Created recording {recording_id} for org {organization_id}")
return recording
async def get_recordings_for_workflow(
async def get_recordings(
self,
workflow_id: int,
organization_id: int,
workflow_id: Optional[int] = None,
tts_provider: Optional[str] = None,
tts_model: Optional[str] = None,
tts_voice_id: Optional[str] = None,
) -> List[WorkflowRecordingModel]:
"""Get recordings for a workflow, optionally filtered by TTS config.
"""Get recordings for an organization, optionally filtered.
Args:
workflow_id: ID of the workflow
organization_id: ID of the organization
workflow_id: Optional workflow ID filter
tts_provider: Optional TTS provider filter
tts_model: Optional TTS model filter
tts_voice_id: Optional TTS voice ID filter
@ -99,11 +96,12 @@ class WorkflowRecordingClient(BaseDBClient):
"""
async with self.async_session() as session:
query = select(WorkflowRecordingModel).where(
WorkflowRecordingModel.workflow_id == workflow_id,
WorkflowRecordingModel.organization_id == organization_id,
WorkflowRecordingModel.is_active == True,
)
if workflow_id is not None:
query = query.where(WorkflowRecordingModel.workflow_id == workflow_id)
if tts_provider:
query = query.where(WorkflowRecordingModel.tts_provider == tts_provider)
if tts_model:
@ -140,15 +138,37 @@ class WorkflowRecordingClient(BaseDBClient):
result = await session.execute(query)
return result.scalar_one_or_none()
async def has_active_recordings(
async def get_recording_by_id(
self,
workflow_id: int,
id: int,
organization_id: int,
) -> bool:
"""Check if a workflow has any active recordings.
) -> Optional[WorkflowRecordingModel]:
"""Get a recording by its integer primary key.
Args:
id: The primary key ID
organization_id: ID of the organization
Returns:
WorkflowRecordingModel if found, None otherwise
"""
async with self.async_session() as session:
query = select(WorkflowRecordingModel).where(
WorkflowRecordingModel.id == id,
WorkflowRecordingModel.organization_id == organization_id,
WorkflowRecordingModel.is_active == True,
)
result = await session.execute(query)
return result.scalar_one_or_none()
async def has_active_recordings(
self,
organization_id: int,
) -> bool:
"""Check if an organization has any active recordings.
Args:
workflow_id: ID of the workflow
organization_id: ID of the organization
Returns:
@ -159,7 +179,6 @@ class WorkflowRecordingClient(BaseDBClient):
select(func.count())
.select_from(WorkflowRecordingModel)
.where(
WorkflowRecordingModel.workflow_id == workflow_id,
WorkflowRecordingModel.organization_id == organization_id,
WorkflowRecordingModel.is_active == True,
)
@ -167,11 +186,14 @@ class WorkflowRecordingClient(BaseDBClient):
result = await session.execute(query)
return result.scalar_one() > 0
async def check_recording_id_exists(self, recording_id: str) -> bool:
"""Check if a recording ID already exists globally.
async def check_recording_id_exists(
self, recording_id: str, organization_id: int
) -> bool:
"""Check if a recording ID already exists within an organization.
Args:
recording_id: The short recording ID to check
recording_id: The recording ID to check
organization_id: ID of the organization
Returns:
True if exists, False otherwise
@ -179,10 +201,125 @@ class WorkflowRecordingClient(BaseDBClient):
async with self.async_session() as session:
query = select(WorkflowRecordingModel.id).where(
WorkflowRecordingModel.recording_id == recording_id,
WorkflowRecordingModel.organization_id == organization_id,
WorkflowRecordingModel.is_active == True,
)
result = await session.execute(query)
return result.scalar_one_or_none() is not None
async def update_recording_id(
self,
id: int,
new_recording_id: str,
organization_id: int,
) -> Optional[WorkflowRecordingModel]:
"""Update the recording_id of a recording.
Args:
id: Primary key ID of the recording
new_recording_id: New recording ID
organization_id: ID of the organization
Returns:
Updated WorkflowRecordingModel if found, None otherwise
"""
async with self.async_session() as session:
query = select(WorkflowRecordingModel).where(
WorkflowRecordingModel.id == id,
WorkflowRecordingModel.organization_id == organization_id,
WorkflowRecordingModel.is_active == True,
)
result = await session.execute(query)
recording = result.scalar_one_or_none()
if not recording:
return None
old_id = recording.recording_id
recording.recording_id = new_recording_id
await session.commit()
await session.refresh(recording)
logger.info(
f"Updated recording ID {old_id} -> {new_recording_id}, "
f"org {organization_id}"
)
return recording
async def replace_recording_id_in_workflows(
self,
old_id: str,
new_id: str,
organization_id: int,
) -> int:
"""Replace all occurrences of a recording ID in workflow definitions.
Updates both draft definitions (workflows.workflow_definition) and
versioned definitions (workflow_definitions.workflow_json), skipping
workflow_definitions with status 'legacy'.
Args:
old_id: The old recording ID to find
new_id: The new recording ID to replace with
organization_id: ID of the organization (scopes to org workflows)
Returns:
Total number of rows updated across both tables
"""
# Match the exact pattern used in prompts: "RECORDING_ID: <id>"
old_pattern = f"RECORDING_ID: {old_id}"
new_pattern = f"RECORDING_ID: {new_id}"
total = 0
async with self.async_session() as session:
# Update workflows.workflow_definition (draft definitions)
result = await session.execute(
text("""
UPDATE workflows
SET workflow_definition =
REPLACE(workflow_definition::text, :old_pat, :new_pat)::json
WHERE organization_id = :org_id
AND workflow_definition::text LIKE '%%' || :old_pat || '%%'
"""),
{
"old_pat": old_pattern,
"new_pat": new_pattern,
"org_id": organization_id,
},
)
total += result.rowcount
# Update workflow_definitions.workflow_json (versioned definitions)
# Skip legacy definitions
result = await session.execute(
text("""
UPDATE workflow_definitions wd
SET workflow_json =
REPLACE(wd.workflow_json::text, :old_pat, :new_pat)::json
FROM workflows w
WHERE wd.workflow_id = w.id
AND w.organization_id = :org_id
AND wd.status != 'legacy'
AND wd.workflow_json::text LIKE '%%' || :old_pat || '%%'
"""),
{
"old_pat": old_pattern,
"new_pat": new_pattern,
"org_id": organization_id,
},
)
total += result.rowcount
await session.commit()
if total > 0:
logger.info(
f"Replaced recording ID '{old_id}' -> '{new_id}' "
f"in {total} workflow definition(s), org {organization_id}"
)
return total
async def delete_recording(
self,
recording_id: str,