mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-16 08:25:18 +02:00
feat: add google stt and tts. add folders to organize agents
This commit is contained in:
parent
21951eca18
commit
ad2fa07058
52 changed files with 3412 additions and 621 deletions
55
api/tests/test_google_stt_service_factory.py
Normal file
55
api/tests/test_google_stt_service_factory.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from pipecat.transcriptions.language import Language
|
||||
|
||||
from api.services.configuration.registry import ServiceProviders
|
||||
from api.services.pipecat.service_factory import create_stt_service
|
||||
|
||||
|
||||
def test_create_google_stt_service_uses_credentials_location_and_language():
|
||||
user_config = SimpleNamespace(
|
||||
stt=SimpleNamespace(
|
||||
provider=ServiceProviders.GOOGLE.value,
|
||||
credentials='{"project_id":"demo-project"}',
|
||||
api_key=None,
|
||||
model="latest_long",
|
||||
language="en-US",
|
||||
location="us-central1",
|
||||
)
|
||||
)
|
||||
audio_config = SimpleNamespace(transport_in_sample_rate=16000)
|
||||
|
||||
with patch("api.services.pipecat.service_factory.GoogleSTTService") as mock_service:
|
||||
create_stt_service(user_config, audio_config)
|
||||
|
||||
assert mock_service.call_count == 1
|
||||
kwargs = mock_service.call_args.kwargs
|
||||
assert kwargs["credentials"] == '{"project_id":"demo-project"}'
|
||||
assert kwargs["location"] == "us-central1"
|
||||
assert kwargs["sample_rate"] == 16000
|
||||
assert kwargs["settings"].model == "latest_long"
|
||||
assert kwargs["settings"].languages == [Language.EN_US]
|
||||
|
||||
|
||||
def test_create_google_stt_service_falls_back_to_raw_language_codes():
|
||||
user_config = SimpleNamespace(
|
||||
stt=SimpleNamespace(
|
||||
provider=ServiceProviders.GOOGLE.value,
|
||||
credentials=None,
|
||||
api_key=None,
|
||||
model="chirp_3",
|
||||
language="cmn-Hans-CN",
|
||||
location="global",
|
||||
)
|
||||
)
|
||||
audio_config = SimpleNamespace(transport_in_sample_rate=24000)
|
||||
|
||||
with patch("api.services.pipecat.service_factory.GoogleSTTService") as mock_service:
|
||||
create_stt_service(user_config, audio_config)
|
||||
|
||||
assert mock_service.call_count == 1
|
||||
kwargs = mock_service.call_args.kwargs
|
||||
assert kwargs["sample_rate"] == 24000
|
||||
assert kwargs["settings"].model == "chirp_3"
|
||||
assert kwargs["settings"].language_codes == ["cmn-Hans-CN"]
|
||||
67
api/tests/test_google_tts_service_factory.py
Normal file
67
api/tests/test_google_tts_service_factory.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from pipecat.services.settings import NOT_GIVEN
|
||||
|
||||
from api.services.configuration.registry import ServiceProviders
|
||||
from api.services.pipecat.service_factory import create_tts_service
|
||||
|
||||
|
||||
def test_create_google_tts_service_uses_credentials_location_and_settings():
|
||||
user_config = SimpleNamespace(
|
||||
tts=SimpleNamespace(
|
||||
provider=ServiceProviders.GOOGLE.value,
|
||||
credentials='{"project_id":"demo-project"}',
|
||||
api_key=None,
|
||||
model="chirp_3_hd",
|
||||
voice="en-US-Chirp3-HD-Charon",
|
||||
language="en-US",
|
||||
speed=1.15,
|
||||
location="us-central1",
|
||||
)
|
||||
)
|
||||
audio_config = SimpleNamespace(
|
||||
transport_out_sample_rate=24000,
|
||||
transport_in_sample_rate=16000,
|
||||
)
|
||||
|
||||
with patch("api.services.pipecat.service_factory.GoogleTTSService") as mock_service:
|
||||
create_tts_service(user_config, audio_config)
|
||||
|
||||
assert mock_service.call_count == 1
|
||||
kwargs = mock_service.call_args.kwargs
|
||||
assert kwargs["credentials"] == '{"project_id":"demo-project"}'
|
||||
assert kwargs["location"] == "us-central1"
|
||||
assert kwargs["settings"].model == "chirp_3_hd"
|
||||
assert kwargs["settings"].voice == "en-US-Chirp3-HD-Charon"
|
||||
assert kwargs["settings"].language == "en-US"
|
||||
assert kwargs["settings"].speaking_rate == 1.15
|
||||
|
||||
|
||||
def test_create_google_tts_service_omits_default_speed():
|
||||
user_config = SimpleNamespace(
|
||||
tts=SimpleNamespace(
|
||||
provider=ServiceProviders.GOOGLE.value,
|
||||
credentials=None,
|
||||
api_key=None,
|
||||
model="chirp_3_hd",
|
||||
voice="en-US-Chirp3-HD-Charon",
|
||||
language="sw-KE",
|
||||
speed=1.0,
|
||||
location=None,
|
||||
)
|
||||
)
|
||||
audio_config = SimpleNamespace(
|
||||
transport_out_sample_rate=24000,
|
||||
transport_in_sample_rate=16000,
|
||||
)
|
||||
|
||||
with patch("api.services.pipecat.service_factory.GoogleTTSService") as mock_service:
|
||||
create_tts_service(user_config, audio_config)
|
||||
|
||||
assert mock_service.call_count == 1
|
||||
kwargs = mock_service.call_args.kwargs
|
||||
assert kwargs["location"] is None
|
||||
assert kwargs["settings"].model == "chirp_3_hd"
|
||||
assert kwargs["settings"].language == "sw-KE"
|
||||
assert kwargs["settings"].speaking_rate is NOT_GIVEN
|
||||
|
|
@ -186,6 +186,45 @@ const n = wf.addTyped(startCall({ name: "g", prompt: "hi", promt: "typo" }));
|
|||
update_mock.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_trigger_path_surfaces_validation_error(mock_backends):
|
||||
save_mock, update_mock = mock_backends
|
||||
payload = {
|
||||
"nodes": [
|
||||
{
|
||||
"id": "trigger-1",
|
||||
"type": "trigger",
|
||||
"data": {"trigger_path": "support/west"},
|
||||
}
|
||||
],
|
||||
"edges": [],
|
||||
}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"api.mcp_server.tools.save_workflow.parse_code",
|
||||
AsyncMock(
|
||||
return_value={
|
||||
"ok": True,
|
||||
"workflowName": _FakeWorkflowModel.name,
|
||||
"workflow": payload,
|
||||
}
|
||||
),
|
||||
),
|
||||
patch(
|
||||
"api.mcp_server.tools.save_workflow.reconcile_positions",
|
||||
return_value=payload,
|
||||
),
|
||||
):
|
||||
result = await save_workflow(workflow_id=1, code="ignored")
|
||||
|
||||
assert result["saved"] is False
|
||||
assert result["error_code"] == "validation_error"
|
||||
assert "single URL path segment" in result["error"]
|
||||
save_mock.assert_not_awaited()
|
||||
update_mock.assert_not_awaited()
|
||||
|
||||
|
||||
# ─── Graph-stage rejections ──────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
191
api/tests/test_public_agent_routes.py
Normal file
191
api/tests/test_public_agent_routes.py
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from api.routes.public_agent import router
|
||||
|
||||
|
||||
def _make_test_app() -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
return app
|
||||
|
||||
|
||||
def _active_workflow(*, trigger_path: str | None = None):
|
||||
nodes = []
|
||||
if trigger_path is not None:
|
||||
nodes.append(
|
||||
{
|
||||
"type": "trigger",
|
||||
"data": {"trigger_path": trigger_path},
|
||||
}
|
||||
)
|
||||
|
||||
return SimpleNamespace(
|
||||
id=33,
|
||||
user_id=99,
|
||||
organization_id=11,
|
||||
status="active",
|
||||
workflow_uuid="workflow-uuid-123",
|
||||
released_definition=SimpleNamespace(
|
||||
workflow_json={"nodes": nodes, "edges": []}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _provider():
|
||||
return SimpleNamespace(
|
||||
PROVIDER_NAME="twilio",
|
||||
WEBHOOK_ENDPOINT="outbound",
|
||||
validate_config=Mock(return_value=True),
|
||||
initiate_call=AsyncMock(),
|
||||
)
|
||||
|
||||
|
||||
def test_trigger_route_executes_as_workflow_owner():
|
||||
app = _make_test_app()
|
||||
client = TestClient(app)
|
||||
|
||||
workflow = _active_workflow(trigger_path="trigger-uuid-123")
|
||||
provider = _provider()
|
||||
quota_mock = AsyncMock(
|
||||
return_value=SimpleNamespace(has_quota=True, error_message="")
|
||||
)
|
||||
|
||||
with (
|
||||
patch("api.routes.public_agent.db_client") as mock_db,
|
||||
patch(
|
||||
"api.routes.public_agent.check_dograh_quota_by_user_id",
|
||||
new=quota_mock,
|
||||
),
|
||||
patch(
|
||||
"api.routes.public_agent.get_default_telephony_provider",
|
||||
new=AsyncMock(return_value=provider),
|
||||
),
|
||||
patch(
|
||||
"api.routes.public_agent.get_backend_endpoints",
|
||||
new=AsyncMock(return_value=("https://api.example.com", "wss://ignored")),
|
||||
),
|
||||
):
|
||||
mock_db.validate_api_key = AsyncMock(
|
||||
return_value=SimpleNamespace(id=7, organization_id=11, created_by=22)
|
||||
)
|
||||
mock_db.get_agent_trigger_by_path = AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
workflow_id=workflow.id,
|
||||
organization_id=11,
|
||||
state="active",
|
||||
)
|
||||
)
|
||||
mock_db.get_workflow = AsyncMock(return_value=workflow)
|
||||
mock_db.get_default_telephony_configuration = AsyncMock(
|
||||
return_value=SimpleNamespace(id=55)
|
||||
)
|
||||
mock_db.create_workflow_run = AsyncMock(return_value=SimpleNamespace(id=501))
|
||||
|
||||
response = client.post(
|
||||
"/public/agent/trigger-uuid-123",
|
||||
headers={"X-API-Key": "test-api-key"},
|
||||
json={"phone_number": "+15551234567"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
quota_mock.assert_awaited_once_with(workflow.user_id, workflow_id=workflow.id)
|
||||
mock_db.get_workflow.assert_awaited_once_with(workflow.id, organization_id=11)
|
||||
|
||||
create_kwargs = mock_db.create_workflow_run.await_args.kwargs
|
||||
assert create_kwargs["workflow_id"] == workflow.id
|
||||
assert create_kwargs["user_id"] == workflow.user_id
|
||||
assert create_kwargs["organization_id"] == workflow.organization_id
|
||||
assert create_kwargs["initial_context"]["agent_uuid"] == "trigger-uuid-123"
|
||||
assert create_kwargs["initial_context"]["agent_identifier"] == "trigger-uuid-123"
|
||||
assert create_kwargs["initial_context"]["agent_identifier_type"] == "trigger_path"
|
||||
assert create_kwargs["initial_context"]["workflow_uuid"] == workflow.workflow_uuid
|
||||
assert create_kwargs["initial_context"]["api_key_id"] == 7
|
||||
assert create_kwargs["initial_context"]["api_key_created_by"] == 22
|
||||
|
||||
initiate_kwargs = provider.initiate_call.await_args.kwargs
|
||||
assert initiate_kwargs["workflow_id"] == workflow.id
|
||||
assert initiate_kwargs["user_id"] == workflow.user_id
|
||||
|
||||
|
||||
def test_workflow_uuid_route_uses_scoped_lookup_and_shared_execution():
|
||||
app = _make_test_app()
|
||||
client = TestClient(app)
|
||||
|
||||
workflow = _active_workflow()
|
||||
provider = _provider()
|
||||
quota_mock = AsyncMock(
|
||||
return_value=SimpleNamespace(has_quota=True, error_message="")
|
||||
)
|
||||
|
||||
with (
|
||||
patch("api.routes.public_agent.db_client") as mock_db,
|
||||
patch(
|
||||
"api.routes.public_agent.check_dograh_quota_by_user_id",
|
||||
new=quota_mock,
|
||||
),
|
||||
patch(
|
||||
"api.routes.public_agent.get_default_telephony_provider",
|
||||
new=AsyncMock(return_value=provider),
|
||||
),
|
||||
patch(
|
||||
"api.routes.public_agent.get_backend_endpoints",
|
||||
new=AsyncMock(return_value=("https://api.example.com", "wss://ignored")),
|
||||
),
|
||||
):
|
||||
mock_db.validate_api_key = AsyncMock(
|
||||
return_value=SimpleNamespace(id=8, organization_id=11, created_by=22)
|
||||
)
|
||||
mock_db.get_workflow_by_uuid = AsyncMock(return_value=workflow)
|
||||
mock_db.get_default_telephony_configuration = AsyncMock(
|
||||
return_value=SimpleNamespace(id=55)
|
||||
)
|
||||
mock_db.create_workflow_run = AsyncMock(return_value=SimpleNamespace(id=601))
|
||||
|
||||
response = client.post(
|
||||
f"/public/agent/workflow/{workflow.workflow_uuid}",
|
||||
headers={"X-API-Key": "test-api-key"},
|
||||
json={"phone_number": "+15551234567"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_db.get_workflow_by_uuid.assert_awaited_once_with(
|
||||
workflow.workflow_uuid,
|
||||
11,
|
||||
)
|
||||
assert not mock_db.get_agent_trigger_by_path.called
|
||||
|
||||
create_kwargs = mock_db.create_workflow_run.await_args.kwargs
|
||||
assert create_kwargs["user_id"] == workflow.user_id
|
||||
assert (
|
||||
create_kwargs["initial_context"]["agent_identifier"] == workflow.workflow_uuid
|
||||
)
|
||||
assert create_kwargs["initial_context"]["agent_identifier_type"] == "workflow_uuid"
|
||||
assert "agent_uuid" not in create_kwargs["initial_context"]
|
||||
|
||||
|
||||
def test_workflow_uuid_route_rejects_archived_workflows():
|
||||
app = _make_test_app()
|
||||
client = TestClient(app)
|
||||
|
||||
workflow = _active_workflow()
|
||||
workflow.status = "archived"
|
||||
|
||||
with patch("api.routes.public_agent.db_client") as mock_db:
|
||||
mock_db.validate_api_key = AsyncMock(
|
||||
return_value=SimpleNamespace(id=9, organization_id=11, created_by=22)
|
||||
)
|
||||
mock_db.get_workflow_by_uuid = AsyncMock(return_value=workflow)
|
||||
|
||||
response = client.post(
|
||||
f"/public/agent/workflow/{workflow.workflow_uuid}",
|
||||
headers={"X-API-Key": "test-api-key"},
|
||||
json={"phone_number": "+15551234567"},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Workflow is not active"
|
||||
assert not mock_db.create_workflow_run.called
|
||||
56
api/tests/test_trigger_path_validation.py
Normal file
56
api/tests/test_trigger_path_validation.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
from api.services.workflow.trigger_paths import (
|
||||
TRIGGER_PATH_MAX_LENGTH,
|
||||
validate_trigger_paths,
|
||||
)
|
||||
|
||||
|
||||
def test_validate_trigger_paths_rejects_invalid_path_segments():
|
||||
workflow_definition = {
|
||||
"nodes": [
|
||||
{
|
||||
"id": "trigger-1",
|
||||
"type": "trigger",
|
||||
"data": {"trigger_path": "support/west"},
|
||||
}
|
||||
],
|
||||
"edges": [],
|
||||
}
|
||||
|
||||
issues = validate_trigger_paths(workflow_definition)
|
||||
|
||||
assert len(issues) == 1
|
||||
assert issues[0].node_id == "trigger-1"
|
||||
assert "single URL path segment" in issues[0].message
|
||||
|
||||
|
||||
def test_validate_trigger_paths_rejects_long_and_duplicate_paths():
|
||||
long_path = "a" * (TRIGGER_PATH_MAX_LENGTH + 1)
|
||||
workflow_definition = {
|
||||
"nodes": [
|
||||
{
|
||||
"id": "trigger-1",
|
||||
"type": "trigger",
|
||||
"data": {"trigger_path": long_path},
|
||||
},
|
||||
{
|
||||
"id": "trigger-2",
|
||||
"type": "trigger",
|
||||
"data": {"trigger_path": "sales_agent"},
|
||||
},
|
||||
{
|
||||
"id": "trigger-3",
|
||||
"type": "trigger",
|
||||
"data": {"trigger_path": "sales_agent"},
|
||||
},
|
||||
],
|
||||
"edges": [],
|
||||
}
|
||||
|
||||
issues = validate_trigger_paths(workflow_definition)
|
||||
messages = [issue.message for issue in issues]
|
||||
|
||||
assert (
|
||||
f"Trigger path must be {TRIGGER_PATH_MAX_LENGTH} characters or fewer."
|
||||
in messages
|
||||
)
|
||||
assert "Trigger path is duplicated in this workflow." in messages
|
||||
49
api/tests/test_workflow_create_route.py
Normal file
49
api/tests/test_workflow_create_route.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from api.routes.workflow import router
|
||||
from api.services.auth.depends import get_user
|
||||
|
||||
|
||||
def _make_test_app() -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
app.dependency_overrides[get_user] = lambda: SimpleNamespace(
|
||||
id=1,
|
||||
provider_id="provider-1",
|
||||
selected_organization_id=11,
|
||||
)
|
||||
return app
|
||||
|
||||
|
||||
def test_create_workflow_rejects_invalid_trigger_path_before_db_write():
|
||||
app = _make_test_app()
|
||||
client = TestClient(app)
|
||||
|
||||
with patch("api.routes.workflow.db_client") as mock_db:
|
||||
response = client.post(
|
||||
"/workflow/create/definition",
|
||||
json={
|
||||
"name": "Support Agent",
|
||||
"workflow_definition": {
|
||||
"nodes": [
|
||||
{
|
||||
"id": "trigger-1",
|
||||
"type": "trigger",
|
||||
"data": {"trigger_path": "support/west"},
|
||||
}
|
||||
],
|
||||
"edges": [],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
detail = response.json()["detail"]
|
||||
assert detail["is_valid"] is False
|
||||
assert detail["errors"][0]["field"] == "data.trigger_path"
|
||||
assert "single URL path segment" in detail["errors"][0]["message"]
|
||||
assert mock_db.mock_calls == []
|
||||
52
api/tests/test_workflow_list_route.py
Normal file
52
api/tests/test_workflow_list_route.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from api.routes.workflow import router
|
||||
from api.services.auth.depends import get_user
|
||||
|
||||
|
||||
def _make_test_app() -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
app.dependency_overrides[get_user] = lambda: SimpleNamespace(
|
||||
id=1,
|
||||
selected_organization_id=11,
|
||||
)
|
||||
return app
|
||||
|
||||
|
||||
def test_workflow_fetch_list_includes_workflow_uuid():
|
||||
app = _make_test_app()
|
||||
client = TestClient(app)
|
||||
|
||||
workflow = SimpleNamespace(
|
||||
id=5,
|
||||
name="Sales Agent",
|
||||
status="active",
|
||||
created_at=datetime(2026, 5, 22, 10, 30, tzinfo=timezone.utc),
|
||||
folder_id=3,
|
||||
workflow_uuid="workflow-uuid-123",
|
||||
)
|
||||
|
||||
with patch("api.routes.workflow.db_client") as mock_db:
|
||||
mock_db.get_all_workflows_for_listing = AsyncMock(return_value=[workflow])
|
||||
mock_db.get_workflow_run_counts = AsyncMock(return_value={workflow.id: 9})
|
||||
|
||||
response = client.get("/workflow/fetch")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == [
|
||||
{
|
||||
"id": workflow.id,
|
||||
"name": workflow.name,
|
||||
"status": workflow.status,
|
||||
"created_at": "2026-05-22T10:30:00Z",
|
||||
"total_runs": 9,
|
||||
"folder_id": workflow.folder_id,
|
||||
"workflow_uuid": workflow.workflow_uuid,
|
||||
}
|
||||
]
|
||||
|
|
@ -182,7 +182,7 @@ class TestSaveDraft:
|
|||
workflow_definition=GRAPH_V2,
|
||||
)
|
||||
|
||||
refreshed = await db_session.get_workflow(workflow.id)
|
||||
refreshed = await db_session.get_workflow_by_id(workflow.id)
|
||||
assert refreshed.released_definition_id == original_released_id
|
||||
|
||||
async def test_save_draft_twice_updates_in_place(
|
||||
|
|
@ -264,7 +264,7 @@ class TestPublishDraft:
|
|||
|
||||
await db_session.publish_workflow_draft(workflow.id)
|
||||
|
||||
refreshed = await db_session.get_workflow(workflow.id)
|
||||
refreshed = await db_session.get_workflow_by_id(workflow.id)
|
||||
assert refreshed.released_definition_id == draft.id
|
||||
|
||||
async def test_publish_sets_published_at(self, db_session, workflow_with_v1):
|
||||
|
|
@ -346,7 +346,7 @@ class TestDiscardDraft:
|
|||
)
|
||||
await db_session.discard_workflow_draft(workflow.id)
|
||||
|
||||
refreshed = await db_session.get_workflow(workflow.id)
|
||||
refreshed = await db_session.get_workflow_by_id(workflow.id)
|
||||
assert refreshed.released_definition_id == original_released_id
|
||||
|
||||
async def test_discard_when_no_draft_raises(self, db_session, workflow_with_v1):
|
||||
|
|
@ -464,7 +464,7 @@ class TestRevert:
|
|||
|
||||
await db_session.revert_to_version(workflow.id, v1_id)
|
||||
|
||||
refreshed = await db_session.get_workflow(workflow.id)
|
||||
refreshed = await db_session.get_workflow_by_id(workflow.id)
|
||||
assert refreshed.released_definition_id == v2.id # still V2
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue