mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-10 08:05:22 +02:00
feat: add logs in campaigns for failure or pausing (#265)
* feat: add logs in campaigns on failure * chore: bump pipecat * chore: update format.sh * chore: fix github workflow * fix: fix formatting errors
This commit is contained in:
parent
abfb678b4d
commit
d4b6afb020
77 changed files with 1001 additions and 245 deletions
|
|
@ -1,14 +1,16 @@
|
|||
name: Docs OpenAPI drift check
|
||||
name: Pre-PR drift check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'api/**'
|
||||
- 'ui/**'
|
||||
- 'pipecat/**'
|
||||
- 'scripts/dump_docs_openapi.py'
|
||||
- 'scripts/format.sh'
|
||||
- 'docs/api-reference/openapi.json'
|
||||
- '.github/workflows/docs-openapi-drift.yml'
|
||||
- '.github/workflows/pre-pr-drift-check.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
|
@ -31,13 +33,45 @@ jobs:
|
|||
cache: pip
|
||||
cache-dependency-path: |
|
||||
api/requirements.txt
|
||||
api/requirements.dev.txt
|
||||
pipecat/pyproject.toml
|
||||
|
||||
- name: Set up Node 22
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: ui/package-lock.json
|
||||
|
||||
- name: Install api dependencies
|
||||
run: |
|
||||
pip install -r api/requirements.txt
|
||||
pip install -r api/requirements.dev.txt
|
||||
pip install './pipecat[cartesia,deepgram,openai,elevenlabs,groq,google,azure,sarvam,soundfile,silero,webrtc,speechmatics,openrouter,camb]'
|
||||
|
||||
- name: Install ui dependencies
|
||||
working-directory: ui
|
||||
run: npm ci
|
||||
|
||||
- name: Run scripts/format.sh
|
||||
run: ./scripts/format.sh
|
||||
|
||||
- name: Check for Python format/lint drift
|
||||
run: |
|
||||
if ! git diff --exit-code api; then
|
||||
echo "::error::Python files are not formatted. Run './scripts/format.sh' locally and commit the result before raising the PR."
|
||||
exit 1
|
||||
fi
|
||||
echo "Python format/lint is clean."
|
||||
|
||||
- name: Check for UI lint drift
|
||||
run: |
|
||||
if ! git diff --exit-code ui; then
|
||||
echo "::error::UI files have outstanding lint fixes. Run 'npm run fix-lint' inside ui/ locally and commit the result before raising the PR."
|
||||
exit 1
|
||||
fi
|
||||
echo "UI lint is clean."
|
||||
|
||||
- name: Dump OpenAPI spec
|
||||
env:
|
||||
DATABASE_URL: postgresql+asyncpg://dummy:dummy@localhost/dummy
|
||||
|
|
@ -47,7 +81,7 @@ jobs:
|
|||
DEPLOYMENT_MODE: oss
|
||||
run: python -u -m scripts.dump_docs_openapi
|
||||
|
||||
- name: Check for drift
|
||||
- name: Check for OpenAPI drift
|
||||
run: |
|
||||
if ! git diff --exit-code docs/api-reference/openapi.json; then
|
||||
echo "::error::docs/api-reference/openapi.json is out of sync with the FastAPI app."
|
||||
|
|
@ -64,5 +98,5 @@ jobs:
|
|||
with:
|
||||
payload: |
|
||||
{
|
||||
"text": "❌ Dograh Docs OpenAPI drift check failed on ${{ github.ref_name }} by ${{ github.actor }} - <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Logs>"
|
||||
"text": "❌ Dograh pre-PR drift check failed on ${{ github.ref_name }} by ${{ github.actor }} - <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Logs>"
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
"""add campaign logs column
|
||||
|
||||
Revision ID: 6499c608d0f6
|
||||
Revises: a2355fc6bdc1
|
||||
Create Date: 2026-05-05 17:25:49.235730
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "6499c608d0f6"
|
||||
down_revision: Union[str, None] = "a2355fc6bdc1"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column(
|
||||
"campaigns",
|
||||
sa.Column(
|
||||
"logs", sa.JSON(), server_default=sa.text("'[]'::json"), nullable=False
|
||||
),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column("campaigns", "logs")
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import json
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
|
@ -134,35 +135,6 @@ class CampaignClient(BaseDBClient):
|
|||
await session.refresh(campaign)
|
||||
return campaign
|
||||
|
||||
async def update_campaign_progress(
|
||||
self,
|
||||
campaign_id: int,
|
||||
processed_rows: int,
|
||||
failed_rows: int,
|
||||
organization_id: int,
|
||||
) -> None:
|
||||
"""Update campaign progress counters"""
|
||||
async with self.async_session() as session:
|
||||
query = select(CampaignModel).where(
|
||||
CampaignModel.id == campaign_id,
|
||||
CampaignModel.organization_id == organization_id,
|
||||
)
|
||||
result = await session.execute(query)
|
||||
campaign = result.scalar_one_or_none()
|
||||
|
||||
if not campaign:
|
||||
raise ValueError(f"Campaign {campaign_id} not found")
|
||||
|
||||
campaign.processed_rows = processed_rows
|
||||
campaign.failed_rows = failed_rows
|
||||
campaign.updated_at = datetime.now(UTC)
|
||||
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
raise e
|
||||
|
||||
async def get_campaign_runs(
|
||||
self,
|
||||
campaign_id: int,
|
||||
|
|
@ -452,6 +424,48 @@ class CampaignClient(BaseDBClient):
|
|||
await session.refresh(campaign)
|
||||
return campaign
|
||||
|
||||
async def append_campaign_log(
|
||||
self,
|
||||
campaign_id: int,
|
||||
level: str,
|
||||
event: str,
|
||||
message: str,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Append a timestamped entry to the campaign's logs JSON array.
|
||||
|
||||
Uses a SQL-side jsonb concat so concurrent writers do not clobber
|
||||
each other's entries.
|
||||
"""
|
||||
entry: Dict[str, Any] = {
|
||||
"ts": datetime.now(UTC).isoformat(),
|
||||
"level": level,
|
||||
"event": event,
|
||||
"message": message,
|
||||
}
|
||||
if details:
|
||||
entry["details"] = details
|
||||
|
||||
async with self.async_session() as session:
|
||||
await session.execute(
|
||||
text(
|
||||
"UPDATE campaigns "
|
||||
"SET logs = (logs::jsonb || CAST(:entry AS jsonb))::json, "
|
||||
" updated_at = :now "
|
||||
"WHERE id = :campaign_id"
|
||||
),
|
||||
{
|
||||
"entry": json.dumps([entry]),
|
||||
"now": datetime.now(UTC),
|
||||
"campaign_id": campaign_id,
|
||||
},
|
||||
)
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
# QueuedRun methods
|
||||
async def bulk_create_queued_runs(self, queued_runs_data: list[dict]) -> None:
|
||||
"""Bulk create queued runs"""
|
||||
|
|
|
|||
|
|
@ -683,6 +683,16 @@ class CampaignModel(Base):
|
|||
JSON, nullable=False, default=dict, server_default=text("'{}'::json")
|
||||
)
|
||||
|
||||
# Append-only timestamped log entries for state transitions, failures,
|
||||
# and circuit-breaker events. Surfaced in the UI so operators can see
|
||||
# why a campaign moved to paused/failed without digging through logs.
|
||||
logs = Column(
|
||||
JSON,
|
||||
nullable=False,
|
||||
default=list,
|
||||
server_default=text("'[]'::json"),
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
started_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import os
|
|||
import sys
|
||||
|
||||
import loguru
|
||||
from pipecat.utils.run_context import run_id_var
|
||||
|
||||
from api.constants import (
|
||||
ENVIRONMENT,
|
||||
|
|
@ -16,6 +15,7 @@ from api.constants import (
|
|||
)
|
||||
from api.enums import Environment
|
||||
from api.utils.worker import get_worker_id, is_worker_process
|
||||
from pipecat.utils.run_context import run_id_var
|
||||
|
||||
# Track if logging has been initialized
|
||||
_logging_initialized = False
|
||||
|
|
|
|||
|
|
@ -17,13 +17,13 @@ from typing import Optional
|
|||
|
||||
from fastapi import APIRouter, WebSocket
|
||||
from loguru import logger
|
||||
from pipecat.utils.run_context import set_current_org_id, set_current_run_id
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from api.db import db_client
|
||||
from api.enums import CallType, WorkflowRunState
|
||||
from api.services.quota_service import check_dograh_quota_by_user_id
|
||||
from api.services.telephony import registry as telephony_registry
|
||||
from pipecat.utils.run_context import set_current_org_id, set_current_run_id
|
||||
|
||||
router = APIRouter(prefix="/agent-stream")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import json
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
|
@ -172,6 +172,20 @@ class UpdateCampaignRequest(BaseModel):
|
|||
circuit_breaker: Optional[CircuitBreakerConfigRequest] = None
|
||||
|
||||
|
||||
class CampaignLogEntryResponse(BaseModel):
|
||||
"""A single timestamped entry from the campaign's append-only log.
|
||||
|
||||
Surfaced in the UI so operators can see why a campaign moved to
|
||||
paused / failed without digging through server logs.
|
||||
"""
|
||||
|
||||
ts: str
|
||||
level: str
|
||||
event: str
|
||||
message: str
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class CampaignResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
|
|
@ -196,6 +210,7 @@ class CampaignResponse(BaseModel):
|
|||
redialed_campaign_id: Optional[int] = None
|
||||
telephony_configuration_id: Optional[int] = None
|
||||
telephony_configuration_name: Optional[str] = None
|
||||
logs: List[CampaignLogEntryResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CampaignsResponse(BaseModel):
|
||||
|
|
@ -298,6 +313,11 @@ def _build_campaign_response(
|
|||
redialed_campaign_id=redialed_campaign_id,
|
||||
telephony_configuration_id=campaign.telephony_configuration_id,
|
||||
telephony_configuration_name=telephony_configuration_name,
|
||||
logs=[
|
||||
CampaignLogEntryResponse(**entry)
|
||||
for entry in (campaign.logs or [])
|
||||
if isinstance(entry, dict)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ from fastapi import (
|
|||
WebSocket,
|
||||
)
|
||||
from loguru import logger
|
||||
from pipecat.utils.run_context import set_current_run_id
|
||||
from pydantic import BaseModel, field_validator
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
|
|
@ -45,6 +44,7 @@ from api.utils.telephony_helper import (
|
|||
numbers_match,
|
||||
parse_webhook_request,
|
||||
)
|
||||
from pipecat.utils.run_context import set_current_run_id
|
||||
|
||||
router = APIRouter(prefix="/telephony")
|
||||
|
||||
|
|
|
|||
|
|
@ -24,8 +24,6 @@ from aiortc import RTCIceServer
|
|||
from aiortc.sdp import candidate_from_sdp
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from loguru import logger
|
||||
from pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection
|
||||
from pipecat.utils.run_context import set_current_org_id, set_current_run_id
|
||||
from starlette.websockets import WebSocketState
|
||||
|
||||
from api.constants import ENVIRONMENT
|
||||
|
|
@ -45,6 +43,8 @@ from api.services.pipecat.ws_sender_registry import (
|
|||
unregister_ws_sender,
|
||||
)
|
||||
from api.services.quota_service import check_dograh_quota
|
||||
from pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection
|
||||
from pipecat.utils.run_context import set_current_org_id, set_current_run_id
|
||||
|
||||
router = APIRouter(prefix="/ws")
|
||||
|
||||
|
|
|
|||
|
|
@ -345,7 +345,12 @@ class CampaignCallDispatcher:
|
|||
)
|
||||
|
||||
# Record call initiation failure in circuit breaker
|
||||
await circuit_breaker.record_and_evaluate(campaign.id, is_failure=True)
|
||||
await circuit_breaker.record_and_evaluate(
|
||||
campaign.id,
|
||||
is_failure=True,
|
||||
workflow_run_id=workflow_run.id,
|
||||
reason="call_initiation_failed",
|
||||
)
|
||||
|
||||
# Release concurrent slot on failure
|
||||
mapping = await rate_limiter.get_workflow_slot_mapping(workflow_run.id)
|
||||
|
|
@ -459,13 +464,18 @@ class CampaignCallDispatcher:
|
|||
await asyncio.sleep(1)
|
||||
|
||||
async def acquire_from_number(
|
||||
self, organization_id: int, timeout: float = 60
|
||||
self, organization_id: int, timeout: float = 600
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Acquire a from_number from the pool with retry.
|
||||
Waits up to timeout seconds, polling every 1s.
|
||||
|
||||
Returns the phone number or None if timeout is exceeded.
|
||||
Args:
|
||||
organization_id: ID of the organization for which to acquire the from_number.
|
||||
timeout: Maximum time in seconds to wait for a from_number before giving up.
|
||||
|
||||
Returns:
|
||||
The acquired phone number as a string, or None if timeout is exceeded.
|
||||
"""
|
||||
wait_start = time.time()
|
||||
|
||||
|
|
|
|||
|
|
@ -383,6 +383,20 @@ class CampaignOrchestrator:
|
|||
f"pausing campaign. Stats: {stats}"
|
||||
)
|
||||
await db_client.update_campaign(campaign_id=campaign_id, state="paused")
|
||||
await db_client.append_campaign_log(
|
||||
campaign_id=campaign_id,
|
||||
level="warning",
|
||||
event="circuit_breaker_tripped",
|
||||
message=(
|
||||
f"Paused at scheduling: failure rate "
|
||||
f"{stats['failure_rate']:.2%} "
|
||||
f"({stats['failure_count']}/"
|
||||
f"{stats['failure_count'] + stats['success_count']}) "
|
||||
f"exceeded threshold {stats['threshold']:.2%} "
|
||||
f"in {stats['window_seconds']}s window"
|
||||
),
|
||||
details=stats,
|
||||
)
|
||||
await self.publisher.publish_circuit_breaker_tripped(
|
||||
campaign_id=campaign_id,
|
||||
failure_rate=stats["failure_rate"],
|
||||
|
|
|
|||
|
|
@ -3,10 +3,15 @@
|
|||
Uses two Redis sorted sets (ZSETs) per campaign — one for failures, one for
|
||||
successes — as sliding windows. ZCARD gives O(1) counts without iterating
|
||||
members, keeping the Lua scripts simple.
|
||||
|
||||
A separate capped Redis list (``cb_recent_failures:{campaign_id}``) stores the
|
||||
last N failing ``{workflow_run_id, reason, ts}`` entries so the campaign log
|
||||
written when the breaker trips can show *which* calls pushed it over.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Optional, Tuple
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from loguru import logger
|
||||
|
|
@ -15,6 +20,11 @@ from api.constants import DEFAULT_CIRCUIT_BREAKER_CONFIG, REDIS_URL
|
|||
from api.db import db_client
|
||||
from api.services.campaign.campaign_event_publisher import get_campaign_event_publisher
|
||||
|
||||
# Cap on the number of recent failure entries kept per campaign — large enough
|
||||
# to be useful for debugging a trip, small enough that the JSON details stay
|
||||
# bounded.
|
||||
MAX_RECENT_FAILURES = 20
|
||||
|
||||
|
||||
class CircuitBreaker:
|
||||
"""Sliding window circuit breaker for campaign call failures."""
|
||||
|
|
@ -35,6 +45,60 @@ class CircuitBreaker:
|
|||
"""Return (failures_key, successes_key) for a campaign."""
|
||||
return f"cb_failures:{campaign_id}", f"cb_successes:{campaign_id}"
|
||||
|
||||
@staticmethod
|
||||
def _recent_failures_key(campaign_id: int) -> str:
|
||||
"""Return the Redis key used for the capped recent-failures list."""
|
||||
return f"cb_recent_failures:{campaign_id}"
|
||||
|
||||
async def _push_recent_failure(
|
||||
self,
|
||||
campaign_id: int,
|
||||
workflow_run_id: int,
|
||||
reason: Optional[str],
|
||||
) -> None:
|
||||
"""Push a failure entry onto the capped recent-failures list."""
|
||||
redis_client = await self._get_redis()
|
||||
key = self._recent_failures_key(campaign_id)
|
||||
entry = json.dumps(
|
||||
{
|
||||
"workflow_run_id": workflow_run_id,
|
||||
"reason": reason,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
try:
|
||||
await redis_client.lpush(key, entry)
|
||||
await redis_client.ltrim(key, 0, MAX_RECENT_FAILURES - 1)
|
||||
# Keep this list around as long as the sliding window plus a buffer.
|
||||
await redis_client.expire(
|
||||
key,
|
||||
DEFAULT_CIRCUIT_BREAKER_CONFIG["window_seconds"] + 60,
|
||||
)
|
||||
except Exception as e:
|
||||
# Never let recent-failure bookkeeping disrupt the call path.
|
||||
logger.error(
|
||||
f"Failed to record recent failure for campaign {campaign_id}: {e}"
|
||||
)
|
||||
|
||||
async def _get_recent_failures(self, campaign_id: int) -> List[Dict[str, Any]]:
|
||||
"""Return the recent-failures list (most-recent first)."""
|
||||
redis_client = await self._get_redis()
|
||||
key = self._recent_failures_key(campaign_id)
|
||||
try:
|
||||
entries = await redis_client.lrange(key, 0, -1)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to read recent failures for campaign {campaign_id}: {e}"
|
||||
)
|
||||
return []
|
||||
decoded: List[Dict[str, Any]] = []
|
||||
for raw in entries:
|
||||
try:
|
||||
decoded.append(json.loads(raw))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return decoded
|
||||
|
||||
async def record_call_outcome(
|
||||
self,
|
||||
campaign_id: int,
|
||||
|
|
@ -227,13 +291,25 @@ class CircuitBreaker:
|
|||
logger.error(f"Circuit breaker check error for campaign {campaign_id}: {e}")
|
||||
return False, None
|
||||
|
||||
async def record_and_evaluate(self, campaign_id: int, is_failure: bool) -> None:
|
||||
async def record_and_evaluate(
|
||||
self,
|
||||
campaign_id: int,
|
||||
is_failure: bool,
|
||||
*,
|
||||
workflow_run_id: Optional[int] = None,
|
||||
reason: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Record a call outcome, and if the breaker trips, pause the campaign.
|
||||
|
||||
This is the main entry point called from telephony status callbacks.
|
||||
It handles fetching campaign config, recording the outcome, and
|
||||
pausing + publishing an event if the breaker trips.
|
||||
|
||||
``workflow_run_id`` and ``reason`` are optional but should be supplied
|
||||
on failures: they are appended to a capped Redis list so the campaign
|
||||
log entry written on trip can name the calls that pushed the breaker
|
||||
over the threshold.
|
||||
|
||||
Exceptions are caught internally so this never disrupts the caller.
|
||||
"""
|
||||
try:
|
||||
|
|
@ -245,6 +321,13 @@ class CircuitBreaker:
|
|||
if campaign.orchestrator_metadata:
|
||||
cb_config = campaign.orchestrator_metadata.get("circuit_breaker", {})
|
||||
|
||||
if is_failure and workflow_run_id is not None:
|
||||
await self._push_recent_failure(
|
||||
campaign_id=campaign_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
tripped, stats = await self.record_call_outcome(
|
||||
campaign_id=campaign_id,
|
||||
is_failure=is_failure,
|
||||
|
|
@ -257,7 +340,22 @@ class CircuitBreaker:
|
|||
f"pausing campaign. Stats: {stats}"
|
||||
)
|
||||
|
||||
recent_failures = await self._get_recent_failures(campaign_id)
|
||||
|
||||
await db_client.update_campaign(campaign_id=campaign_id, state="paused")
|
||||
await db_client.append_campaign_log(
|
||||
campaign_id=campaign_id,
|
||||
level="warning",
|
||||
event="circuit_breaker_tripped",
|
||||
message=(
|
||||
f"Paused: failure rate {stats['failure_rate']:.2%} "
|
||||
f"({stats['failure_count']}/"
|
||||
f"{stats['failure_count'] + stats['success_count']}) "
|
||||
f"exceeded threshold {stats['threshold']:.2%} "
|
||||
f"in {stats['window_seconds']}s window"
|
||||
),
|
||||
details={**stats, "recent_failures": recent_failures},
|
||||
)
|
||||
|
||||
publisher = await get_campaign_event_publisher()
|
||||
await publisher.publish_circuit_breaker_tripped(
|
||||
|
|
@ -275,13 +373,16 @@ class CircuitBreaker:
|
|||
async def reset(self, campaign_id: int) -> bool:
|
||||
"""Reset the circuit breaker state for a campaign.
|
||||
|
||||
Called when a campaign is resumed to give it a clean slate.
|
||||
Called when a campaign is resumed to give it a clean slate. Also clears
|
||||
the recent-failures list so log entries from the next trip reference
|
||||
only post-resume failures.
|
||||
"""
|
||||
redis_client = await self._get_redis()
|
||||
fail_key, succ_key = self._keys(campaign_id)
|
||||
recent_key = self._recent_failures_key(campaign_id)
|
||||
|
||||
try:
|
||||
await redis_client.delete(fail_key, succ_key)
|
||||
await redis_client.delete(fail_key, succ_key, recent_key)
|
||||
logger.info(f"Circuit breaker reset for campaign {campaign_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import asyncio
|
|||
from typing import Dict, Set
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from pipecat.audio.utils import mix_audio
|
||||
from pipecat.frames.frames import (
|
||||
Frame,
|
||||
|
|
|
|||
|
|
@ -3,10 +3,6 @@
|
|||
from typing import Any, Dict
|
||||
|
||||
from loguru import logger
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.processors.aggregators.llm_response_universal import (
|
||||
LLMContextAggregatorPair,
|
||||
)
|
||||
|
||||
from api.db.db_client import DBClient
|
||||
from api.services.looptalk.audio_streamer import get_or_create_audio_streamer
|
||||
|
|
@ -27,6 +23,10 @@ from api.services.pipecat.service_factory import (
|
|||
from api.services.workflow.dto import ReactFlowDTO
|
||||
from api.services.workflow.pipecat_engine import PipecatEngine
|
||||
from api.services.workflow.workflow import WorkflowGraph
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.processors.aggregators.llm_response_universal import (
|
||||
LLMContextAggregatorPair,
|
||||
)
|
||||
|
||||
|
||||
class LoopTalkPipelineBuilder:
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
"""Internal frame serializer for agent-to-agent communication."""
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from pipecat.frames.frames import (
|
||||
Frame,
|
||||
InputAudioRawFrame,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import time
|
|||
from typing import Dict, Optional, Tuple
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from api.services.looptalk.internal_serializer import InternalFrameSerializer
|
||||
from pipecat.frames.frames import (
|
||||
CancelFrame,
|
||||
EndFrame,
|
||||
|
|
@ -27,8 +29,6 @@ from pipecat.transports.base_input import BaseInputTransport
|
|||
from pipecat.transports.base_output import BaseOutputTransport
|
||||
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
||||
|
||||
from api.services.looptalk.internal_serializer import InternalFrameSerializer
|
||||
|
||||
|
||||
class InternalInputTransport(BaseInputTransport):
|
||||
"""Input side of internal transport for agent-to-agent communication."""
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ from pathlib import Path
|
|||
from typing import Any, Dict, Optional
|
||||
|
||||
from loguru import logger
|
||||
from pipecat.pipeline.task import PipelineTask
|
||||
from pipecat.utils.run_context import set_current_run_id
|
||||
|
||||
from api.db.db_client import DBClient
|
||||
from api.services.looptalk.internal_transport import (
|
||||
|
|
@ -15,6 +13,8 @@ from api.services.looptalk.internal_transport import (
|
|||
InternalTransportManager,
|
||||
)
|
||||
from api.services.pipecat.transport_setup import create_internal_transport
|
||||
from pipecat.pipeline.task import PipelineTask
|
||||
from pipecat.utils.run_context import set_current_run_id
|
||||
|
||||
from .core.pipeline_builder import LoopTalkPipelineBuilder
|
||||
from .core.recording_manager import RecordingManager
|
||||
|
|
|
|||
|
|
@ -235,7 +235,10 @@ def register_event_handlers(
|
|||
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
|
||||
if workflow_run and workflow_run.campaign_id:
|
||||
await circuit_breaker.record_and_evaluate(
|
||||
campaign_id=workflow_run.campaign_id, is_failure=True
|
||||
campaign_id=workflow_run.campaign_id,
|
||||
is_failure=True,
|
||||
workflow_run_id=workflow_run_id,
|
||||
reason="pipeline_error",
|
||||
)
|
||||
asyncio.create_task(
|
||||
_capture_call_event(
|
||||
|
|
|
|||
|
|
@ -21,9 +21,10 @@ from fastapi import (
|
|||
status,
|
||||
)
|
||||
from fastapi.websockets import WebSocketState
|
||||
from pipecat.audio.turn.smart_turn.local_smart_turn_v2 import LocalSmartTurnAnalyzerV2
|
||||
from scipy.io import wavfile
|
||||
|
||||
from pipecat.audio.turn.smart_turn.local_smart_turn_v2 import LocalSmartTurnAnalyzerV2
|
||||
|
||||
LOG_LEVEL = (
|
||||
logging.DEBUG
|
||||
if os.environ.get("LOG_LEVEL", "DEBUG").lower() == "debug"
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from typing import Any, Dict, Optional
|
|||
import numpy as np
|
||||
import websockets
|
||||
from loguru import logger
|
||||
|
||||
from pipecat.audio.turn.smart_turn.base_smart_turn import (
|
||||
BaseSmartTurn,
|
||||
SmartTurnTimeoutException,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ This module contains the business logic for Asterisk ARI call operations.
|
|||
from typing import Any, Dict
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from pipecat.serializers.call_strategies import HangupStrategy, TransferStrategy
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
"""ARI (Asterisk) transport factory."""
|
||||
|
||||
from fastapi import WebSocket
|
||||
from pipecat.transports.websocket.fastapi import (
|
||||
FastAPIWebsocketParams,
|
||||
FastAPIWebsocketTransport,
|
||||
)
|
||||
|
||||
from api.services.pipecat.audio_config import AudioConfig
|
||||
from api.services.pipecat.audio_mixer import build_audio_out_mixer
|
||||
from api.services.telephony.factory import load_credentials_for_transport
|
||||
from pipecat.transports.websocket.fastapi import (
|
||||
FastAPIWebsocketParams,
|
||||
FastAPIWebsocketTransport,
|
||||
)
|
||||
|
||||
from .serializers import AsteriskFrameSerializer
|
||||
from .strategies import ARIBridgeSwapStrategy, ARIHangupStrategy
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import json
|
|||
|
||||
from fastapi import APIRouter, Request
|
||||
from loguru import logger
|
||||
from pipecat.utils.run_context import set_current_run_id
|
||||
|
||||
from api.db import db_client
|
||||
from api.services.telephony.factory import get_telephony_provider_for_run
|
||||
|
|
@ -16,6 +15,7 @@ from api.services.telephony.status_processor import (
|
|||
StatusCallbackRequest,
|
||||
_process_status_update,
|
||||
)
|
||||
from pipecat.utils.run_context import set_current_run_id
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
from typing import Any, Dict
|
||||
|
||||
from loguru import logger
|
||||
from pipecat.serializers.call_strategies import HangupStrategy
|
||||
|
||||
from api.services.telephony.providers.cloudonix.provider import CLOUDONIX_API_BASE_URL
|
||||
from pipecat.serializers.call_strategies import HangupStrategy
|
||||
|
||||
|
||||
class CloudonixHangupStrategy(HangupStrategy):
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
"""Cloudonix transport factory."""
|
||||
|
||||
from fastapi import WebSocket
|
||||
from pipecat.transports.websocket.fastapi import (
|
||||
FastAPIWebsocketParams,
|
||||
FastAPIWebsocketTransport,
|
||||
)
|
||||
|
||||
from api.services.pipecat.audio_config import AudioConfig
|
||||
from api.services.pipecat.audio_mixer import build_audio_out_mixer
|
||||
from api.services.telephony.factory import load_credentials_for_transport
|
||||
from pipecat.transports.websocket.fastapi import (
|
||||
FastAPIWebsocketParams,
|
||||
FastAPIWebsocketTransport,
|
||||
)
|
||||
|
||||
from .serializers import CloudonixFrameSerializer
|
||||
from .strategies import CloudonixHangupStrategy
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ from typing import Optional
|
|||
|
||||
from fastapi import APIRouter, Header, Request
|
||||
from loguru import logger
|
||||
from pipecat.utils.run_context import set_current_run_id
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from api.db import db_client
|
||||
|
|
@ -19,6 +18,7 @@ from api.services.telephony.status_processor import (
|
|||
_process_status_update,
|
||||
)
|
||||
from api.utils.common import get_backend_endpoints
|
||||
from pipecat.utils.run_context import set_current_run_id
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
"""Plivo transport factory."""
|
||||
|
||||
from fastapi import WebSocket
|
||||
from pipecat.transports.websocket.fastapi import (
|
||||
FastAPIWebsocketParams,
|
||||
FastAPIWebsocketTransport,
|
||||
)
|
||||
|
||||
from api.services.pipecat.audio_config import AudioConfig
|
||||
from api.services.pipecat.audio_mixer import build_audio_out_mixer
|
||||
from api.services.telephony.factory import load_credentials_for_transport
|
||||
from pipecat.transports.websocket.fastapi import (
|
||||
FastAPIWebsocketParams,
|
||||
FastAPIWebsocketTransport,
|
||||
)
|
||||
|
||||
from .serializers import PlivoFrameSerializer
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import json
|
|||
|
||||
from fastapi import APIRouter, Request
|
||||
from loguru import logger
|
||||
from pipecat.utils.run_context import set_current_run_id
|
||||
|
||||
from api.db import db_client
|
||||
from api.services.telephony.factory import get_telephony_provider_for_run
|
||||
|
|
@ -17,6 +16,7 @@ from api.services.telephony.status_processor import (
|
|||
StatusCallbackRequest,
|
||||
_process_status_update,
|
||||
)
|
||||
from pipecat.utils.run_context import set_current_run_id
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
"""Telnyx transport factory."""
|
||||
|
||||
from fastapi import WebSocket
|
||||
from pipecat.transports.websocket.fastapi import (
|
||||
FastAPIWebsocketParams,
|
||||
FastAPIWebsocketTransport,
|
||||
)
|
||||
|
||||
from api.services.pipecat.audio_config import AudioConfig
|
||||
from api.services.pipecat.audio_mixer import build_audio_out_mixer
|
||||
from api.services.telephony.factory import load_credentials_for_transport
|
||||
from pipecat.transports.websocket.fastapi import (
|
||||
FastAPIWebsocketParams,
|
||||
FastAPIWebsocketTransport,
|
||||
)
|
||||
|
||||
from .serializers import TelnyxFrameSerializer
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ from typing import Optional
|
|||
|
||||
from fastapi import APIRouter, Header, Request
|
||||
from loguru import logger
|
||||
from pipecat.utils.run_context import set_current_run_id
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from api.db import db_client
|
||||
|
|
@ -19,6 +18,7 @@ from api.services.telephony.status_processor import (
|
|||
_process_status_update,
|
||||
)
|
||||
from api.utils.common import get_backend_endpoints
|
||||
from pipecat.utils.run_context import set_current_run_id
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from typing import Any, Dict
|
|||
|
||||
import aiohttp
|
||||
from loguru import logger
|
||||
|
||||
from pipecat.serializers.call_strategies import HangupStrategy, TransferStrategy
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
"""Twilio transport factory."""
|
||||
|
||||
from fastapi import WebSocket
|
||||
from pipecat.transports.websocket.fastapi import (
|
||||
FastAPIWebsocketParams,
|
||||
FastAPIWebsocketTransport,
|
||||
)
|
||||
|
||||
from api.services.pipecat.audio_config import AudioConfig
|
||||
from api.services.pipecat.audio_mixer import build_audio_out_mixer
|
||||
from api.services.telephony.factory import load_credentials_for_transport
|
||||
from pipecat.transports.websocket.fastapi import (
|
||||
FastAPIWebsocketParams,
|
||||
FastAPIWebsocketTransport,
|
||||
)
|
||||
|
||||
from .serializers import TwilioFrameSerializer
|
||||
from .strategies import TwilioConferenceStrategy, TwilioHangupStrategy
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ from typing import Optional
|
|||
|
||||
from fastapi import APIRouter, Header, Request
|
||||
from loguru import logger
|
||||
from pipecat.utils.run_context import set_current_run_id
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from api.db import db_client
|
||||
|
|
@ -25,6 +24,7 @@ from api.utils.common import get_backend_endpoints
|
|||
from api.utils.telephony_helper import (
|
||||
parse_webhook_request,
|
||||
)
|
||||
from pipecat.utils.run_context import set_current_run_id
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
|
|||
|
|
@ -7,14 +7,14 @@ Vobiz uses Plivo-compatible WebSocket protocol:
|
|||
|
||||
from fastapi import WebSocket
|
||||
from loguru import logger
|
||||
from pipecat.transports.websocket.fastapi import (
|
||||
FastAPIWebsocketParams,
|
||||
FastAPIWebsocketTransport,
|
||||
)
|
||||
|
||||
from api.services.pipecat.audio_config import AudioConfig
|
||||
from api.services.pipecat.audio_mixer import build_audio_out_mixer
|
||||
from api.services.telephony.factory import load_credentials_for_transport
|
||||
from pipecat.transports.websocket.fastapi import (
|
||||
FastAPIWebsocketParams,
|
||||
FastAPIWebsocketTransport,
|
||||
)
|
||||
|
||||
from .serializers import VobizFrameSerializer
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ from typing import Optional
|
|||
|
||||
from fastapi import APIRouter, Request
|
||||
from loguru import logger
|
||||
from pipecat.utils.run_context import set_current_run_id
|
||||
|
||||
from api.db import db_client
|
||||
from api.services.telephony.factory import get_telephony_provider_for_run
|
||||
|
|
@ -17,6 +16,7 @@ from api.services.telephony.status_processor import (
|
|||
StatusCallbackRequest,
|
||||
_process_status_update,
|
||||
)
|
||||
from pipecat.utils.run_context import set_current_run_id
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
"""Vonage transport factory."""
|
||||
|
||||
from pipecat.transports.websocket.fastapi import (
|
||||
FastAPIWebsocketParams,
|
||||
FastAPIWebsocketTransport,
|
||||
)
|
||||
|
||||
from api.services.pipecat.audio_config import AudioConfig
|
||||
from api.services.pipecat.audio_mixer import build_audio_out_mixer
|
||||
from api.services.telephony.factory import load_credentials_for_transport
|
||||
from pipecat.transports.websocket.fastapi import (
|
||||
FastAPIWebsocketParams,
|
||||
FastAPIWebsocketTransport,
|
||||
)
|
||||
|
||||
from .serializers import VonageFrameSerializer
|
||||
|
||||
|
|
|
|||
|
|
@ -179,9 +179,12 @@ async def _process_status_update(workflow_run_id: int, status: StatusCallbackReq
|
|||
|
||||
if workflow_run.campaign_id:
|
||||
await campaign_call_dispatcher.release_call_slot(workflow_run_id)
|
||||
is_failure = status.status in ("error", "failed")
|
||||
await circuit_breaker.record_and_evaluate(
|
||||
workflow_run.campaign_id,
|
||||
is_failure=status.status in ("error", "failed"),
|
||||
is_failure=is_failure,
|
||||
workflow_run_id=workflow_run_id if is_failure else None,
|
||||
reason=status.status if is_failure else None,
|
||||
)
|
||||
|
||||
if status.status in ["busy", "no-answer"] and workflow_run.campaign_id:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
from typing import TYPE_CHECKING, Awaitable, Callable, Optional, Union
|
||||
|
||||
from api.db import db_client
|
||||
from api.services.pipecat.audio_playback import play_audio
|
||||
from api.services.workflow.disposition_mapper import apply_disposition_mapping
|
||||
from api.services.workflow.workflow import Node, WorkflowGraph
|
||||
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
||||
from pipecat.frames.frames import (
|
||||
BotStartedSpeakingFrame,
|
||||
|
|
@ -15,11 +19,6 @@ from pipecat.services.llm_service import FunctionCallParams
|
|||
from pipecat.services.settings import LLMSettings
|
||||
from pipecat.utils.enums import EndTaskReason
|
||||
|
||||
from api.db import db_client
|
||||
from api.services.pipecat.audio_playback import play_audio
|
||||
from api.services.workflow.disposition_mapper import apply_disposition_mapping
|
||||
from api.services.workflow.workflow import Node, WorkflowGraph
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pipecat.frames.frames import Frame
|
||||
from pipecat.services.anthropic.llm import AnthropicLLMService
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import re
|
|||
from typing import TYPE_CHECKING
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from pipecat.frames.frames import (
|
||||
LLMMessagesAppendFrame,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ from typing import TYPE_CHECKING, Optional
|
|||
|
||||
from loguru import logger
|
||||
from opentelemetry import trace
|
||||
|
||||
from api.services.pipecat.tracing_config import ensure_tracing
|
||||
from pipecat.frames.frames import LLMContextSummaryRequestFrame
|
||||
from pipecat.utils.context.llm_context_summarization import (
|
||||
LLMContextSummarizationUtil,
|
||||
|
|
@ -13,8 +15,6 @@ from pipecat.utils.context.llm_context_summarization import (
|
|||
)
|
||||
from pipecat.utils.tracing.service_attributes import add_llm_span_attributes
|
||||
|
||||
from api.services.pipecat.tracing_config import ensure_tracing
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from api.services.workflow.pipecat_engine import PipecatEngine
|
||||
|
||||
|
|
|
|||
|
|
@ -13,13 +13,6 @@ import uuid
|
|||
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
||||
|
||||
from loguru import logger
|
||||
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
||||
from pipecat.frames.frames import (
|
||||
FunctionCallResultProperties,
|
||||
TTSSpeakFrame,
|
||||
)
|
||||
from pipecat.services.llm_service import FunctionCallParams
|
||||
from pipecat.utils.enums import EndTaskReason
|
||||
|
||||
from api.db import db_client
|
||||
from api.enums import ToolCategory, WorkflowRunMode
|
||||
|
|
@ -32,6 +25,13 @@ from api.services.workflow.tools.custom_tool import (
|
|||
execute_http_tool,
|
||||
tool_to_function_schema,
|
||||
)
|
||||
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
||||
from pipecat.frames.frames import (
|
||||
FunctionCallResultProperties,
|
||||
TTSSpeakFrame,
|
||||
)
|
||||
from pipecat.services.llm_service import FunctionCallParams
|
||||
from pipecat.utils.enums import EndTaskReason
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from api.services.workflow.pipecat_engine import PipecatEngine
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@ from typing import TYPE_CHECKING, Any, List
|
|||
|
||||
from loguru import logger
|
||||
from opentelemetry import trace
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.utils.tracing.service_attributes import add_llm_span_attributes
|
||||
|
||||
from api.services.gen_ai.json_parser import parse_llm_json
|
||||
from api.services.pipecat.tracing_config import ensure_tracing
|
||||
from api.services.workflow.dto import ExtractionVariableDTO
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.utils.tracing.service_attributes import add_llm_span_attributes
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from api.services.workflow.pipecat_engine import PipecatEngine
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import json
|
|||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
|
||||
from api.db.models import WorkflowRunModel
|
||||
from api.services.gen_ai.json_parser import parse_llm_json
|
||||
|
|
@ -27,6 +26,7 @@ from api.services.workflow.qa.tracing import (
|
|||
setup_langfuse_parent_context,
|
||||
)
|
||||
from api.utils.template_renderer import render_template
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
|
||||
|
||||
async def _run_llm_inference(
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
|
||||
from api.db import db_client
|
||||
from api.db.models import WorkflowRunModel
|
||||
|
|
@ -11,6 +10,7 @@ from api.services.pipecat.service_factory import create_llm_service_from_provide
|
|||
from api.services.workflow.dto import NodeType, QANodeData
|
||||
from api.services.workflow.qa.llm_config import resolve_llm_config
|
||||
from api.services.workflow.qa.tracing import create_node_summary_trace
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
|
||||
NODE_SUMMARY_SYSTEM_PROMPT = (
|
||||
"You are analyzing a voice AI agent script. This is only a part of a larger script. "
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ def add_qa_span_to_trace(
|
|||
return
|
||||
try:
|
||||
from opentelemetry import trace as otel_trace
|
||||
|
||||
from pipecat.utils.tracing.service_attributes import add_llm_span_attributes
|
||||
|
||||
tracer = otel_trace.get_tracer("pipecat")
|
||||
|
|
@ -121,9 +122,9 @@ def create_node_summary_trace(
|
|||
try:
|
||||
from opentelemetry import trace as otel_trace
|
||||
from opentelemetry.context import Context
|
||||
from pipecat.utils.tracing.service_attributes import add_llm_span_attributes
|
||||
|
||||
from api.services.pipecat.tracing_config import ensure_tracing
|
||||
from pipecat.utils.tracing.service_attributes import add_llm_span_attributes
|
||||
|
||||
if not ensure_tracing():
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ from api.services.campaign.campaign_call_dispatcher import campaign_call_dispatc
|
|||
from api.services.campaign.campaign_event_publisher import (
|
||||
get_campaign_event_publisher,
|
||||
)
|
||||
from api.services.campaign.errors import ConcurrentSlotAcquisitionError
|
||||
from api.services.campaign.errors import (
|
||||
ConcurrentSlotAcquisitionError,
|
||||
PhoneNumberPoolExhaustedError,
|
||||
)
|
||||
from api.services.campaign.source_sync_factory import get_sync_service
|
||||
|
||||
|
||||
|
|
@ -80,6 +83,13 @@ async def sync_campaign_source(ctx: Dict, campaign_id: int) -> None:
|
|||
source_sync_status="failed",
|
||||
source_sync_error=str(e),
|
||||
)
|
||||
await db_client.append_campaign_log(
|
||||
campaign_id=campaign_id,
|
||||
level="error",
|
||||
event="source_sync_failed",
|
||||
message=f"Source sync failed: {e}",
|
||||
details={"error": str(e)},
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
|
|
@ -137,6 +147,39 @@ async def process_campaign_batch(
|
|||
|
||||
# Update campaign state to failed
|
||||
await db_client.update_campaign(campaign_id=campaign_id, state="failed")
|
||||
await db_client.append_campaign_log(
|
||||
campaign_id=campaign_id,
|
||||
level="error",
|
||||
event="batch_failed",
|
||||
message=f"Concurrent slot acquisition timeout: {e}",
|
||||
details={"error": str(e), "reason": "concurrent_slot_timeout"},
|
||||
)
|
||||
raise
|
||||
|
||||
except PhoneNumberPoolExhaustedError as e:
|
||||
logger.warning(f"Phone number pool exhausted for campaign {campaign_id}: {e}")
|
||||
|
||||
publisher = await get_campaign_event_publisher()
|
||||
await publisher.publish_batch_failed(
|
||||
campaign_id=campaign_id,
|
||||
error=f"Phone number pool exhausted: {e}",
|
||||
processed_count=0,
|
||||
)
|
||||
|
||||
await db_client.update_campaign(campaign_id=campaign_id, state="failed")
|
||||
await db_client.append_campaign_log(
|
||||
campaign_id=campaign_id,
|
||||
level="error",
|
||||
event="phone_number_pool_exhausted",
|
||||
message=(
|
||||
f"Phone number pool exhausted for org {e.organization_id}: "
|
||||
"no free from_number available to dispatch outbound calls"
|
||||
),
|
||||
details={
|
||||
"error": str(e),
|
||||
"organization_id": e.organization_id,
|
||||
},
|
||||
)
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -152,4 +195,11 @@ async def process_campaign_batch(
|
|||
|
||||
# Update campaign state to failed
|
||||
await db_client.update_campaign(campaign_id=campaign_id, state="failed")
|
||||
await db_client.append_campaign_log(
|
||||
campaign_id=campaign_id,
|
||||
level="error",
|
||||
event="batch_failed",
|
||||
message=f"Batch processing failed: {e}",
|
||||
details={"error": str(e)},
|
||||
)
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@ from typing import Any, Dict, Optional
|
|||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
from pipecat.utils.enums import EndTaskReason
|
||||
from pipecat.utils.run_context import set_current_org_id, set_current_run_id
|
||||
from pydantic import ValidationError
|
||||
|
||||
from api.constants import BACKEND_API_ENDPOINT
|
||||
|
|
@ -23,6 +21,8 @@ from api.services.workflow.dto import (
|
|||
from api.services.workflow.qa import run_per_node_qa_analysis
|
||||
from api.utils.credential_auth import build_auth_header
|
||||
from api.utils.template_renderer import render_template
|
||||
from pipecat.utils.enums import EndTaskReason
|
||||
from pipecat.utils.run_context import set_current_org_id, set_current_run_id
|
||||
|
||||
|
||||
def _should_skip_qa(
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ import os
|
|||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
from pipecat.utils.run_context import set_current_run_id
|
||||
|
||||
from api.db import db_client
|
||||
from api.services.pricing.workflow_run_cost import calculate_workflow_run_cost
|
||||
from api.services.storage import get_current_storage_backend, storage_fs
|
||||
from api.tasks.run_integrations import run_integrations_post_workflow_run
|
||||
from pipecat.utils.run_context import set_current_run_id
|
||||
|
||||
|
||||
async def upload_voicemail_audio_to_s3(
|
||||
|
|
|
|||
|
|
@ -29,12 +29,11 @@ from contextlib import ExitStack, contextmanager
|
|||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from api.db.models import OrganizationModel, UserModel
|
||||
from api.enums import WorkflowRunMode
|
||||
from pipecat.frames.frames import Frame
|
||||
from pipecat.observers.base_observer import BaseObserver
|
||||
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
||||
|
||||
from api.db.models import OrganizationModel, UserModel
|
||||
from api.enums import WorkflowRunMode
|
||||
from pipecat.tests import MockLLMService, MockTTSService
|
||||
|
||||
USER_CONFIGURATION: dict[str, Any] = {
|
||||
|
|
|
|||
|
|
@ -17,8 +17,6 @@ completion flag, and ``gathered_context`` entries.
|
|||
import asyncio
|
||||
|
||||
import pytest
|
||||
from pipecat.tests.mock_transport import MockTransport
|
||||
from pipecat.transports.base_transport import TransportParams
|
||||
|
||||
from api.enums import WorkflowRunMode, WorkflowRunState
|
||||
from api.services.pipecat.audio_config import create_audio_config
|
||||
|
|
@ -27,6 +25,8 @@ from api.tests.integrations._run_pipeline_helpers import (
|
|||
create_workflow_run_rows,
|
||||
patch_run_pipeline_externals,
|
||||
)
|
||||
from pipecat.tests.mock_transport import MockTransport
|
||||
from pipecat.transports.base_transport import TransportParams
|
||||
|
||||
WORKFLOW_DEFINITION = {
|
||||
"nodes": [
|
||||
|
|
|
|||
|
|
@ -28,10 +28,6 @@ deterministic and the synthesised audio length is short.
|
|||
import asyncio
|
||||
|
||||
import pytest
|
||||
from pipecat.frames.frames import TranscriptionFrame
|
||||
from pipecat.tests.mock_transport import MockTransport
|
||||
from pipecat.transports.base_transport import TransportParams
|
||||
from pipecat.utils.time import time_now_iso8601
|
||||
|
||||
from api.enums import WorkflowRunMode, WorkflowRunState
|
||||
from api.services.pipecat.audio_config import create_audio_config
|
||||
|
|
@ -40,7 +36,11 @@ from api.tests.integrations._run_pipeline_helpers import (
|
|||
create_workflow_run_rows,
|
||||
patch_run_pipeline_externals,
|
||||
)
|
||||
from pipecat.frames.frames import TranscriptionFrame
|
||||
from pipecat.tests import MockLLMService, MockTTSService
|
||||
from pipecat.tests.mock_transport import MockTransport
|
||||
from pipecat.transports.base_transport import TransportParams
|
||||
from pipecat.utils.time import time_now_iso8601
|
||||
|
||||
GREETING_TEXT = (
|
||||
"Thanks for calling Happy Feet, this is Sarah. How can I help you today?"
|
||||
|
|
|
|||
87
api/tests/test_campaign_tasks.py
Normal file
87
api/tests/test_campaign_tasks.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""
|
||||
Tests for api.tasks.campaign_tasks failure handling.
|
||||
|
||||
Specifically: each kind of failure that pauses or fails a campaign should
|
||||
write a specific, identifiable entry into the campaign log so operators
|
||||
can tell at a glance why a campaign stopped.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from api.services.campaign.errors import (
|
||||
ConcurrentSlotAcquisitionError,
|
||||
PhoneNumberPoolExhaustedError,
|
||||
)
|
||||
from api.tasks.campaign_tasks import process_campaign_batch
|
||||
|
||||
|
||||
class TestProcessCampaignBatchFailureLogs:
|
||||
"""``process_campaign_batch`` should log a *specific* event for each
|
||||
distinct failure mode, not collapse them all into a generic
|
||||
``batch_failed`` entry."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_phone_number_pool_exhausted_logs_specific_event(self):
|
||||
"""When PhoneNumberPoolExhaustedError propagates from process_batch,
|
||||
the campaign log entry should use event='phone_number_pool_exhausted'
|
||||
with a clear message — not the generic 'batch_failed' bucket."""
|
||||
with (
|
||||
patch("api.tasks.campaign_tasks.campaign_call_dispatcher") as mock_disp,
|
||||
patch("api.tasks.campaign_tasks.db_client") as mock_db,
|
||||
patch(
|
||||
"api.tasks.campaign_tasks.get_campaign_event_publisher"
|
||||
) as mock_get_pub,
|
||||
):
|
||||
mock_disp.process_batch = AsyncMock(
|
||||
side_effect=PhoneNumberPoolExhaustedError(organization_id=7)
|
||||
)
|
||||
mock_db.update_campaign = AsyncMock()
|
||||
mock_db.append_campaign_log = AsyncMock()
|
||||
mock_pub = AsyncMock()
|
||||
mock_get_pub.return_value = mock_pub
|
||||
|
||||
with pytest.raises(PhoneNumberPoolExhaustedError):
|
||||
await process_campaign_batch({}, campaign_id=42)
|
||||
|
||||
mock_db.update_campaign.assert_called_once_with(
|
||||
campaign_id=42, state="failed"
|
||||
)
|
||||
|
||||
mock_db.append_campaign_log.assert_called_once()
|
||||
kwargs = mock_db.append_campaign_log.call_args.kwargs
|
||||
assert kwargs["campaign_id"] == 42
|
||||
assert kwargs["event"] == "phone_number_pool_exhausted"
|
||||
assert kwargs["level"] == "error"
|
||||
assert "phone number" in kwargs["message"].lower()
|
||||
assert kwargs["details"]["organization_id"] == 7
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_slot_timeout_still_logs_specific_event(self):
|
||||
"""Regression guard: the existing ConcurrentSlotAcquisitionError branch
|
||||
should keep logging its specific reason."""
|
||||
with (
|
||||
patch("api.tasks.campaign_tasks.campaign_call_dispatcher") as mock_disp,
|
||||
patch("api.tasks.campaign_tasks.db_client") as mock_db,
|
||||
patch(
|
||||
"api.tasks.campaign_tasks.get_campaign_event_publisher"
|
||||
) as mock_get_pub,
|
||||
):
|
||||
mock_disp.process_batch = AsyncMock(
|
||||
side_effect=ConcurrentSlotAcquisitionError(
|
||||
organization_id=7, campaign_id=42, wait_time=30.0
|
||||
)
|
||||
)
|
||||
mock_db.update_campaign = AsyncMock()
|
||||
mock_db.append_campaign_log = AsyncMock()
|
||||
mock_pub = AsyncMock()
|
||||
mock_get_pub.return_value = mock_pub
|
||||
|
||||
with pytest.raises(ConcurrentSlotAcquisitionError):
|
||||
await process_campaign_batch({}, campaign_id=42)
|
||||
|
||||
mock_db.append_campaign_log.assert_called_once()
|
||||
kwargs = mock_db.append_campaign_log.call_args.kwargs
|
||||
assert kwargs["event"] == "batch_failed"
|
||||
assert kwargs["details"]["reason"] == "concurrent_slot_timeout"
|
||||
|
|
@ -198,7 +198,9 @@ class TestCircuitBreakerReset:
|
|||
result = await cb.reset(campaign_id=42)
|
||||
|
||||
assert result is True
|
||||
mock_redis.delete.assert_called_once_with("cb_failures:42", "cb_successes:42")
|
||||
mock_redis.delete.assert_called_once_with(
|
||||
"cb_failures:42", "cb_successes:42", "cb_recent_failures:42"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_on_redis_error(self):
|
||||
|
|
@ -253,6 +255,7 @@ class TestRecordAndEvaluate:
|
|||
):
|
||||
mock_db.get_campaign_by_id = AsyncMock(return_value=mock_campaign)
|
||||
mock_db.update_campaign = AsyncMock()
|
||||
mock_db.append_campaign_log = AsyncMock()
|
||||
|
||||
mock_publisher = AsyncMock()
|
||||
mock_get_publisher.return_value = mock_publisher
|
||||
|
|
@ -352,6 +355,206 @@ class TestRecordAndEvaluate:
|
|||
await cb.record_and_evaluate(campaign_id=42, is_failure=True)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for recent-failures tracking (workflow_run_id + reason)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCircuitBreakerRecentFailures:
|
||||
"""When a call fails, the circuit breaker should remember the workflow_run_id
|
||||
and reason in a capped Redis list, and surface those entries in the campaign
|
||||
log entry written when the breaker trips."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failure_pushes_recent_failure_entry(self):
|
||||
"""is_failure=True with run id + reason should push to recent-failures list."""
|
||||
from api.services.campaign.circuit_breaker import CircuitBreaker
|
||||
|
||||
cb = CircuitBreaker()
|
||||
|
||||
mock_campaign = MagicMock()
|
||||
mock_campaign.id = 42
|
||||
mock_campaign.state = "running"
|
||||
mock_campaign.orchestrator_metadata = {}
|
||||
|
||||
with patch("api.services.campaign.circuit_breaker.db_client") as mock_db:
|
||||
mock_db.get_campaign_by_id = AsyncMock(return_value=mock_campaign)
|
||||
mock_db.append_campaign_log = AsyncMock()
|
||||
cb.record_call_outcome = AsyncMock(return_value=(False, None))
|
||||
cb._push_recent_failure = AsyncMock()
|
||||
cb._get_recent_failures = AsyncMock(return_value=[])
|
||||
|
||||
await cb.record_and_evaluate(
|
||||
campaign_id=42,
|
||||
is_failure=True,
|
||||
workflow_run_id=100,
|
||||
reason="failed",
|
||||
)
|
||||
|
||||
cb._push_recent_failure.assert_called_once_with(
|
||||
campaign_id=42, workflow_run_id=100, reason="failed"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_does_not_push_recent_failure(self):
|
||||
"""is_failure=False must not push to the recent-failures list."""
|
||||
from api.services.campaign.circuit_breaker import CircuitBreaker
|
||||
|
||||
cb = CircuitBreaker()
|
||||
|
||||
mock_campaign = MagicMock()
|
||||
mock_campaign.id = 42
|
||||
mock_campaign.state = "running"
|
||||
mock_campaign.orchestrator_metadata = {}
|
||||
|
||||
with patch("api.services.campaign.circuit_breaker.db_client") as mock_db:
|
||||
mock_db.get_campaign_by_id = AsyncMock(return_value=mock_campaign)
|
||||
cb.record_call_outcome = AsyncMock(return_value=(False, None))
|
||||
cb._push_recent_failure = AsyncMock()
|
||||
cb._get_recent_failures = AsyncMock(return_value=[])
|
||||
|
||||
await cb.record_and_evaluate(
|
||||
campaign_id=42,
|
||||
is_failure=False,
|
||||
workflow_run_id=100,
|
||||
reason=None,
|
||||
)
|
||||
|
||||
cb._push_recent_failure.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trip_log_includes_recent_failures_in_details(self):
|
||||
"""When the breaker trips, the campaign log entry's details should include
|
||||
recent_failures fetched from the Redis list."""
|
||||
from api.services.campaign.circuit_breaker import CircuitBreaker
|
||||
|
||||
cb = CircuitBreaker()
|
||||
|
||||
mock_campaign = MagicMock()
|
||||
mock_campaign.id = 42
|
||||
mock_campaign.state = "running"
|
||||
mock_campaign.orchestrator_metadata = {}
|
||||
|
||||
stats = {
|
||||
"failure_rate": 0.6,
|
||||
"failure_count": 6,
|
||||
"success_count": 4,
|
||||
"threshold": 0.5,
|
||||
"window_seconds": 120,
|
||||
}
|
||||
|
||||
recent = [
|
||||
{"workflow_run_id": 100, "reason": "failed", "ts": 1700000010.0},
|
||||
{"workflow_run_id": 99, "reason": "error", "ts": 1700000000.0},
|
||||
]
|
||||
|
||||
with (
|
||||
patch("api.services.campaign.circuit_breaker.db_client") as mock_db,
|
||||
patch(
|
||||
"api.services.campaign.circuit_breaker.get_campaign_event_publisher"
|
||||
) as mock_get_publisher,
|
||||
):
|
||||
mock_db.get_campaign_by_id = AsyncMock(return_value=mock_campaign)
|
||||
mock_db.update_campaign = AsyncMock()
|
||||
mock_db.append_campaign_log = AsyncMock()
|
||||
|
||||
mock_publisher = AsyncMock()
|
||||
mock_get_publisher.return_value = mock_publisher
|
||||
|
||||
cb.record_call_outcome = AsyncMock(return_value=(True, stats))
|
||||
cb._push_recent_failure = AsyncMock()
|
||||
cb._get_recent_failures = AsyncMock(return_value=recent)
|
||||
|
||||
await cb.record_and_evaluate(
|
||||
campaign_id=42,
|
||||
is_failure=True,
|
||||
workflow_run_id=100,
|
||||
reason="failed",
|
||||
)
|
||||
|
||||
mock_db.append_campaign_log.assert_called_once()
|
||||
kwargs = mock_db.append_campaign_log.call_args.kwargs
|
||||
assert kwargs["campaign_id"] == 42
|
||||
assert kwargs["event"] == "circuit_breaker_tripped"
|
||||
assert kwargs["details"]["recent_failures"] == recent
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_push_recent_failure_uses_lpush_and_ltrim(self):
|
||||
"""_push_recent_failure should LPUSH a JSON entry and LTRIM the list
|
||||
to keep only the most recent N (default 20)."""
|
||||
import json
|
||||
|
||||
from api.services.campaign.circuit_breaker import CircuitBreaker
|
||||
|
||||
cb = CircuitBreaker()
|
||||
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.lpush = AsyncMock(return_value=1)
|
||||
mock_redis.ltrim = AsyncMock(return_value=True)
|
||||
mock_redis.expire = AsyncMock(return_value=True)
|
||||
cb.redis_client = mock_redis
|
||||
|
||||
await cb._push_recent_failure(
|
||||
campaign_id=42, workflow_run_id=100, reason="failed"
|
||||
)
|
||||
|
||||
# Verify the key used
|
||||
mock_redis.lpush.assert_called_once()
|
||||
push_args = mock_redis.lpush.call_args.args
|
||||
assert push_args[0] == "cb_recent_failures:42"
|
||||
|
||||
# Verify the payload includes the run id + reason
|
||||
entry = json.loads(push_args[1])
|
||||
assert entry["workflow_run_id"] == 100
|
||||
assert entry["reason"] == "failed"
|
||||
assert "ts" in entry
|
||||
|
||||
# Verify the cap (LTRIM 0 19 keeps 20 entries)
|
||||
mock_redis.ltrim.assert_called_once_with("cb_recent_failures:42", 0, 19)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_recent_failures_decodes_lrange(self):
|
||||
"""_get_recent_failures should LRANGE the list and JSON-decode entries."""
|
||||
import json
|
||||
|
||||
from api.services.campaign.circuit_breaker import CircuitBreaker
|
||||
|
||||
cb = CircuitBreaker()
|
||||
|
||||
mock_redis = AsyncMock()
|
||||
entries = [
|
||||
json.dumps({"workflow_run_id": 100, "reason": "failed", "ts": 1.0}),
|
||||
json.dumps({"workflow_run_id": 99, "reason": "error", "ts": 0.5}),
|
||||
]
|
||||
mock_redis.lrange = AsyncMock(return_value=entries)
|
||||
cb.redis_client = mock_redis
|
||||
|
||||
result = await cb._get_recent_failures(campaign_id=42)
|
||||
|
||||
mock_redis.lrange.assert_called_once_with("cb_recent_failures:42", 0, -1)
|
||||
assert result == [
|
||||
{"workflow_run_id": 100, "reason": "failed", "ts": 1.0},
|
||||
{"workflow_run_id": 99, "reason": "error", "ts": 0.5},
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_clears_recent_failures_key(self):
|
||||
"""reset() must also delete cb_recent_failures:{campaign_id}."""
|
||||
from api.services.campaign.circuit_breaker import CircuitBreaker
|
||||
|
||||
cb = CircuitBreaker()
|
||||
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.delete = AsyncMock(return_value=3)
|
||||
cb.redis_client = mock_redis
|
||||
|
||||
await cb.reset(campaign_id=42)
|
||||
|
||||
mock_redis.delete.assert_called_once_with(
|
||||
"cb_failures:42", "cb_successes:42", "cb_recent_failures:42"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Integration tests: _process_status_update calls circuit_breaker
|
||||
# =============================================================================
|
||||
|
|
@ -405,7 +608,12 @@ class TestProcessStatusUpdateCircuitBreaker:
|
|||
|
||||
await _process_status_update(100, status)
|
||||
|
||||
mock_cb.record_and_evaluate.assert_called_once_with(42, is_failure=True)
|
||||
mock_cb.record_and_evaluate.assert_called_once_with(
|
||||
42,
|
||||
is_failure=True,
|
||||
workflow_run_id=100,
|
||||
reason="failed",
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_status_calls_record_and_evaluate(self):
|
||||
|
|
|
|||
|
|
@ -12,6 +12,12 @@ from typing import Any, Dict
|
|||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from api.services.workflow.pipecat_engine_custom_tools import get_function_schema
|
||||
from api.services.workflow.tools.custom_tool import (
|
||||
execute_http_tool,
|
||||
tool_to_function_schema,
|
||||
)
|
||||
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
||||
from pipecat.frames.frames import (
|
||||
FunctionCallInProgressFrame,
|
||||
|
|
@ -25,12 +31,6 @@ from pipecat.frames.frames import (
|
|||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.services.llm_service import FunctionCallParams
|
||||
|
||||
from api.services.workflow.pipecat_engine_custom_tools import get_function_schema
|
||||
from api.services.workflow.tools.custom_tool import (
|
||||
execute_http_tool,
|
||||
tool_to_function_schema,
|
||||
)
|
||||
from pipecat.tests import MockLLMService, run_test
|
||||
|
||||
|
||||
|
|
@ -720,11 +720,10 @@ class TestCustomToolManagerUnit:
|
|||
@pytest.mark.asyncio
|
||||
async def test_get_tool_schemas_returns_correct_format(self):
|
||||
"""Test that get_tool_schemas returns FunctionSchema objects."""
|
||||
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
||||
|
||||
# Create a mock engine
|
||||
from api.services.workflow.pipecat_engine import PipecatEngine
|
||||
from api.services.workflow.pipecat_engine_custom_tools import CustomToolManager
|
||||
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
||||
|
||||
mock_engine = Mock()
|
||||
mock_engine._workflow_run_id = 1
|
||||
|
|
|
|||
|
|
@ -9,15 +9,15 @@ This module tests the full flow of:
|
|||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
||||
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
|
||||
from api.services.workflow.pipecat_engine_custom_tools import (
|
||||
CustomToolManager,
|
||||
get_function_schema,
|
||||
)
|
||||
from api.tests.conftest import MockToolModel
|
||||
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
||||
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
|
||||
|
||||
def _update_llm_context(context, system_message, functions):
|
||||
|
|
|
|||
|
|
@ -18,6 +18,14 @@ from typing import List
|
|||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from api.services.workflow.pipecat_engine import PipecatEngine
|
||||
from api.services.workflow.workflow import WorkflowGraph
|
||||
from api.tests.conftest import (
|
||||
AGENT_SYSTEM_PROMPT,
|
||||
END_CALL_SYSTEM_PROMPT,
|
||||
START_CALL_SYSTEM_PROMPT,
|
||||
)
|
||||
from pipecat.frames.frames import LLMContextFrame
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.runner import PipelineRunner
|
||||
|
|
@ -27,21 +35,13 @@ from pipecat.processors.aggregators.llm_response_universal import (
|
|||
LLMAssistantAggregatorParams,
|
||||
LLMContextAggregatorPair,
|
||||
)
|
||||
from pipecat.tests.mock_transport import MockTransport
|
||||
from pipecat.transports.base_transport import TransportParams
|
||||
|
||||
from api.services.workflow.pipecat_engine import PipecatEngine
|
||||
from api.services.workflow.workflow import WorkflowGraph
|
||||
from api.tests.conftest import (
|
||||
AGENT_SYSTEM_PROMPT,
|
||||
END_CALL_SYSTEM_PROMPT,
|
||||
START_CALL_SYSTEM_PROMPT,
|
||||
)
|
||||
from pipecat.tests import (
|
||||
ContextCapturingMockLLM,
|
||||
MockLLMService,
|
||||
MockTTSService,
|
||||
)
|
||||
from pipecat.tests.mock_transport import MockTransport
|
||||
from pipecat.transports.base_transport import TransportParams
|
||||
|
||||
|
||||
async def run_pipeline_and_capture_context(
|
||||
|
|
|
|||
|
|
@ -23,23 +23,6 @@ from typing import Any, Dict, List
|
|||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from pipecat.frames.frames import Frame, LLMContextFrame
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.runner import PipelineRunner
|
||||
from pipecat.pipeline.task import PipelineParams, PipelineTask
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.processors.aggregators.llm_response_universal import (
|
||||
LLMAssistantAggregatorParams,
|
||||
LLMContextAggregatorPair,
|
||||
LLMUserAggregatorParams,
|
||||
)
|
||||
from pipecat.tests.mock_transport import MockTransport
|
||||
from pipecat.transports.base_transport import TransportParams
|
||||
from pipecat.turns.user_mute import (
|
||||
CallbackUserMuteStrategy,
|
||||
MuteUntilFirstBotCompleteUserMuteStrategy,
|
||||
)
|
||||
from pipecat.utils.enums import EndTaskReason
|
||||
|
||||
from api.enums import ToolCategory
|
||||
from api.services.workflow.dto import (
|
||||
|
|
@ -59,7 +42,24 @@ from api.services.workflow.pipecat_engine_variable_extractor import (
|
|||
)
|
||||
from api.services.workflow.workflow import WorkflowGraph
|
||||
from api.tests.conftest import END_CALL_SYSTEM_PROMPT, START_CALL_SYSTEM_PROMPT
|
||||
from pipecat.frames.frames import Frame, LLMContextFrame
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.runner import PipelineRunner
|
||||
from pipecat.pipeline.task import PipelineParams, PipelineTask
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.processors.aggregators.llm_response_universal import (
|
||||
LLMAssistantAggregatorParams,
|
||||
LLMContextAggregatorPair,
|
||||
LLMUserAggregatorParams,
|
||||
)
|
||||
from pipecat.tests import MockLLMService, MockTTSService
|
||||
from pipecat.tests.mock_transport import MockTransport
|
||||
from pipecat.transports.base_transport import TransportParams
|
||||
from pipecat.turns.user_mute import (
|
||||
CallbackUserMuteStrategy,
|
||||
MuteUntilFirstBotCompleteUserMuteStrategy,
|
||||
)
|
||||
from pipecat.utils.enums import EndTaskReason
|
||||
|
||||
|
||||
class EndCallTestHelper:
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ import asyncio
|
|||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from api.services.workflow.pipecat_engine import PipecatEngine
|
||||
from api.services.workflow.workflow import WorkflowGraph
|
||||
from pipecat.frames.frames import (
|
||||
Frame,
|
||||
FunctionCallResultFrame,
|
||||
|
|
@ -33,6 +36,7 @@ from pipecat.processors.aggregators.llm_response_universal import (
|
|||
LLMUserAggregatorParams,
|
||||
)
|
||||
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
||||
from pipecat.tests import MockLLMService, MockTTSService
|
||||
from pipecat.tests.mock_transport import MockTransport
|
||||
from pipecat.transports.base_transport import TransportParams
|
||||
from pipecat.turns.user_mute import (
|
||||
|
|
@ -48,10 +52,6 @@ from pipecat.turns.user_stop import (
|
|||
from pipecat.turns.user_turn_strategies import UserTurnStrategies
|
||||
from pipecat.utils.time import time_now_iso8601
|
||||
|
||||
from api.services.workflow.pipecat_engine import PipecatEngine
|
||||
from api.services.workflow.workflow import WorkflowGraph
|
||||
from pipecat.tests import MockLLMService, MockTTSService
|
||||
|
||||
|
||||
class UserSpeechInjector(FrameProcessor):
|
||||
"""Processor that injects user speaking frames on FunctionCallResultFrame.
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@ from typing import Any, Dict, List
|
|||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from api.services.workflow.pipecat_engine import PipecatEngine
|
||||
from api.services.workflow.workflow import WorkflowGraph
|
||||
from api.tests.conftest import END_CALL_SYSTEM_PROMPT
|
||||
from pipecat.frames.frames import LLMContextFrame
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.runner import PipelineRunner
|
||||
|
|
@ -18,14 +22,10 @@ from pipecat.processors.aggregators.llm_response_universal import (
|
|||
LLMAssistantAggregatorParams,
|
||||
LLMContextAggregatorPair,
|
||||
)
|
||||
from pipecat.tests import MockLLMService, MockTTSService
|
||||
from pipecat.tests.mock_transport import MockTransport
|
||||
from pipecat.transports.base_transport import TransportParams
|
||||
|
||||
from api.services.workflow.pipecat_engine import PipecatEngine
|
||||
from api.services.workflow.workflow import WorkflowGraph
|
||||
from api.tests.conftest import END_CALL_SYSTEM_PROMPT
|
||||
from pipecat.tests import MockLLMService, MockTTSService
|
||||
|
||||
|
||||
async def run_pipeline_with_tool_calls(
|
||||
workflow: WorkflowGraph,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,12 @@ import asyncio
|
|||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from api.services.workflow.pipecat_engine import PipecatEngine
|
||||
from api.services.workflow.pipecat_engine_variable_extractor import (
|
||||
VariableExtractionManager,
|
||||
)
|
||||
from api.services.workflow.workflow import WorkflowGraph
|
||||
from pipecat.frames.frames import LLMContextFrame
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.runner import PipelineRunner
|
||||
|
|
@ -23,6 +29,7 @@ from pipecat.processors.aggregators.llm_response_universal import (
|
|||
LLMContextAggregatorPair,
|
||||
LLMUserAggregatorParams,
|
||||
)
|
||||
from pipecat.tests import MockLLMService, MockTTSService
|
||||
from pipecat.tests.mock_transport import MockTransport
|
||||
from pipecat.transports.base_transport import TransportParams
|
||||
from pipecat.turns.user_mute import (
|
||||
|
|
@ -31,13 +38,6 @@ from pipecat.turns.user_mute import (
|
|||
MuteUntilFirstBotCompleteUserMuteStrategy,
|
||||
)
|
||||
|
||||
from api.services.workflow.pipecat_engine import PipecatEngine
|
||||
from api.services.workflow.pipecat_engine_variable_extractor import (
|
||||
VariableExtractionManager,
|
||||
)
|
||||
from api.services.workflow.workflow import WorkflowGraph
|
||||
from pipecat.tests import MockLLMService, MockTTSService
|
||||
|
||||
|
||||
async def _build_engine_and_pipeline(
|
||||
workflow: WorkflowGraph,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@ from typing import Any, Dict, List
|
|||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from api.services.workflow.pipecat_engine import PipecatEngine
|
||||
from api.services.workflow.pipecat_engine_variable_extractor import (
|
||||
VariableExtractionManager,
|
||||
)
|
||||
from api.services.workflow.workflow import WorkflowGraph
|
||||
from pipecat.frames.frames import LLMContextFrame
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.runner import PipelineRunner
|
||||
|
|
@ -25,16 +31,10 @@ from pipecat.processors.aggregators.llm_response_universal import (
|
|||
LLMAssistantAggregatorParams,
|
||||
LLMContextAggregatorPair,
|
||||
)
|
||||
from pipecat.tests import MockLLMService, MockTTSService
|
||||
from pipecat.tests.mock_transport import MockTransport
|
||||
from pipecat.transports.base_transport import TransportParams
|
||||
|
||||
from api.services.workflow.pipecat_engine import PipecatEngine
|
||||
from api.services.workflow.pipecat_engine_variable_extractor import (
|
||||
VariableExtractionManager,
|
||||
)
|
||||
from api.services.workflow.workflow import WorkflowGraph
|
||||
from pipecat.tests import MockLLMService, MockTTSService
|
||||
|
||||
|
||||
class TestVariableExtractionDuringTransitions:
|
||||
"""Test that variable extraction is triggered for the correct node during transitions."""
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import asyncio
|
|||
|
||||
import pytest
|
||||
from loguru import logger
|
||||
|
||||
from pipecat.frames.frames import (
|
||||
EndTaskFrame,
|
||||
Frame,
|
||||
|
|
|
|||
|
|
@ -12,14 +12,6 @@ and inspect what arrives downstream.
|
|||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
from pipecat.frames.frames import (
|
||||
LLMFullResponseEndFrame,
|
||||
LLMTextFrame,
|
||||
TTSAudioRawFrame,
|
||||
TTSStartedFrame,
|
||||
TTSStoppedFrame,
|
||||
TTSTextFrame,
|
||||
)
|
||||
|
||||
from api.services.pipecat.recording_audio_cache import RecordingAudio
|
||||
from api.services.pipecat.recording_router_processor import (
|
||||
|
|
@ -29,6 +21,14 @@ from api.services.workflow.pipecat_engine_context_composer import (
|
|||
RECORDING_MARKER,
|
||||
TTS_MARKER,
|
||||
)
|
||||
from pipecat.frames.frames import (
|
||||
LLMFullResponseEndFrame,
|
||||
LLMTextFrame,
|
||||
TTSAudioRawFrame,
|
||||
TTSStartedFrame,
|
||||
TTSStoppedFrame,
|
||||
TTSTextFrame,
|
||||
)
|
||||
from pipecat.tests import run_test
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -11,6 +11,21 @@ from typing import Any, Dict, List
|
|||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from api.services.pipecat.recording_audio_cache import RecordingAudio
|
||||
from api.services.workflow.dto import (
|
||||
EdgeDataDTO,
|
||||
EndCallNodeData,
|
||||
EndCallRFNode,
|
||||
Position,
|
||||
ReactFlowDTO,
|
||||
RFEdgeDTO,
|
||||
StartCallNodeData,
|
||||
StartCallRFNode,
|
||||
)
|
||||
from api.services.workflow.pipecat_engine import PipecatEngine
|
||||
from api.services.workflow.pipecat_engine_custom_tools import CustomToolManager
|
||||
from api.services.workflow.workflow import WorkflowGraph
|
||||
from pipecat.frames.frames import (
|
||||
Frame,
|
||||
LLMContextFrame,
|
||||
|
|
@ -27,25 +42,10 @@ from pipecat.processors.aggregators.llm_response_universal import (
|
|||
LLMAssistantAggregatorParams,
|
||||
LLMContextAggregatorPair,
|
||||
)
|
||||
from pipecat.tests import MockLLMService, MockTTSService
|
||||
from pipecat.tests.mock_transport import MockTransport
|
||||
from pipecat.transports.base_transport import TransportParams
|
||||
|
||||
from api.services.pipecat.recording_audio_cache import RecordingAudio
|
||||
from api.services.workflow.dto import (
|
||||
EdgeDataDTO,
|
||||
EndCallNodeData,
|
||||
EndCallRFNode,
|
||||
Position,
|
||||
ReactFlowDTO,
|
||||
RFEdgeDTO,
|
||||
StartCallNodeData,
|
||||
StartCallRFNode,
|
||||
)
|
||||
from api.services.workflow.pipecat_engine import PipecatEngine
|
||||
from api.services.workflow.pipecat_engine_custom_tools import CustomToolManager
|
||||
from api.services.workflow.workflow import WorkflowGraph
|
||||
from pipecat.tests import MockLLMService, MockTTSService
|
||||
|
||||
# ─── Constants ──────────────────────────────────────────────────
|
||||
|
||||
START_PROMPT = "Start Call System Prompt"
|
||||
|
|
|
|||
|
|
@ -32,6 +32,12 @@ import asyncio
|
|||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from api.services.workflow.pipecat_engine import PipecatEngine
|
||||
from api.services.workflow.pipecat_engine_variable_extractor import (
|
||||
VariableExtractionManager,
|
||||
)
|
||||
from api.services.workflow.workflow import WorkflowGraph
|
||||
from pipecat.frames.frames import LLMContextFrame
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.runner import PipelineRunner
|
||||
|
|
@ -42,6 +48,7 @@ from pipecat.processors.aggregators.llm_response_universal import (
|
|||
LLMContextAggregatorPair,
|
||||
LLMUserAggregatorParams,
|
||||
)
|
||||
from pipecat.tests import MockLLMService, MockTTSService
|
||||
from pipecat.tests.mock_transport import MockTransport
|
||||
from pipecat.transports.base_transport import TransportParams
|
||||
from pipecat.turns.user_mute import (
|
||||
|
|
@ -50,13 +57,6 @@ from pipecat.turns.user_mute import (
|
|||
)
|
||||
from pipecat.utils.enums import EndTaskReason
|
||||
|
||||
from api.services.workflow.pipecat_engine import PipecatEngine
|
||||
from api.services.workflow.pipecat_engine_variable_extractor import (
|
||||
VariableExtractionManager,
|
||||
)
|
||||
from api.services.workflow.workflow import WorkflowGraph
|
||||
from pipecat.tests import MockLLMService, MockTTSService
|
||||
|
||||
|
||||
async def create_test_pipeline_with_failing_transport(
|
||||
workflow: WorkflowGraph,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"""Tests for LLM behavior when calling an unregistered function."""
|
||||
|
||||
import pytest
|
||||
|
||||
from pipecat.frames.frames import (
|
||||
FunctionCallInProgressFrame,
|
||||
FunctionCallResultFrame,
|
||||
|
|
@ -12,7 +13,6 @@ from pipecat.frames.frames import (
|
|||
)
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
|
||||
from pipecat.tests import MockLLMService, run_test
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ import asyncio
|
|||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from api.services.workflow.pipecat_engine import PipecatEngine
|
||||
from api.services.workflow.workflow import WorkflowGraph
|
||||
from pipecat.frames.frames import (
|
||||
BotStoppedSpeakingFrame,
|
||||
Frame,
|
||||
|
|
@ -32,6 +35,7 @@ from pipecat.processors.aggregators.llm_response_universal import (
|
|||
LLMUserAggregatorParams,
|
||||
)
|
||||
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
||||
from pipecat.tests import MockLLMService, MockTTSService
|
||||
from pipecat.tests.mock_transport import MockTransport
|
||||
from pipecat.transports.base_transport import TransportParams
|
||||
from pipecat.turns.user_mute import (
|
||||
|
|
@ -43,10 +47,6 @@ from pipecat.turns.user_stop import ExternalUserTurnStopStrategy
|
|||
from pipecat.turns.user_turn_strategies import UserTurnStrategies
|
||||
from pipecat.utils.time import time_now_iso8601
|
||||
|
||||
from api.services.workflow.pipecat_engine import PipecatEngine
|
||||
from api.services.workflow.workflow import WorkflowGraph
|
||||
from pipecat.tests import MockLLMService, MockTTSService
|
||||
|
||||
|
||||
class UserSpeechInjector(FrameProcessor):
|
||||
"""Processor that injects user speaking frames after the bot finishes speaking.
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@ from typing import List
|
|||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from api.services.workflow.pipecat_engine import PipecatEngine
|
||||
from api.services.workflow.pipecat_engine_variable_extractor import (
|
||||
VariableExtractionManager,
|
||||
)
|
||||
from api.services.workflow.workflow import WorkflowGraph
|
||||
from pipecat.frames.frames import (
|
||||
BotStartedSpeakingFrame,
|
||||
BotStoppedSpeakingFrame,
|
||||
|
|
@ -35,6 +41,7 @@ from pipecat.processors.aggregators.llm_response_universal import (
|
|||
LLMUserAggregatorParams,
|
||||
)
|
||||
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
||||
from pipecat.tests import MockLLMService, MockTTSService
|
||||
from pipecat.tests.mock_transport import MockTransport
|
||||
from pipecat.transports.base_transport import TransportParams
|
||||
from pipecat.turns.user_mute import (
|
||||
|
|
@ -44,13 +51,6 @@ from pipecat.turns.user_mute import (
|
|||
from pipecat.turns.user_turn_strategies import ExternalUserTurnStrategies
|
||||
from pipecat.utils.time import time_now_iso8601
|
||||
|
||||
from api.services.workflow.pipecat_engine import PipecatEngine
|
||||
from api.services.workflow.pipecat_engine_variable_extractor import (
|
||||
VariableExtractionManager,
|
||||
)
|
||||
from api.services.workflow.workflow import WorkflowGraph
|
||||
from pipecat.tests import MockLLMService, MockTTSService
|
||||
|
||||
|
||||
class BotSpeakingObserverProcessor(FrameProcessor):
|
||||
"""Observer that records mute status when bot speaking events flow upstream.
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ incoming speech as CONVERSATION or VOICEMAIL and how the main LLM responds.
|
|||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from pipecat.extensions.voicemail.voicemail_detector import VoicemailDetector
|
||||
from pipecat.frames.frames import (
|
||||
EndTaskFrame,
|
||||
|
|
@ -26,6 +27,7 @@ from pipecat.processors.aggregators.llm_response_universal import (
|
|||
LLMUserAggregatorParams,
|
||||
)
|
||||
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
||||
from pipecat.tests import MockLLMService
|
||||
from pipecat.turns.user_start import (
|
||||
TranscriptionUserTurnStartStrategy,
|
||||
VADUserTurnStartStrategy,
|
||||
|
|
@ -36,8 +38,6 @@ from pipecat.turns.user_stop import (
|
|||
from pipecat.turns.user_turn_strategies import UserTurnStrategies
|
||||
from pipecat.utils.time import time_now_iso8601
|
||||
|
||||
from pipecat.tests import MockLLMService
|
||||
|
||||
|
||||
class FrameInjector(FrameProcessor):
|
||||
"""Simple processor that can inject frames into the pipeline."""
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
2
pipecat
2
pipecat
|
|
@ -1 +1 @@
|
|||
Subproject commit 95f03dc647d60f505057db033161f609d9edf79d
|
||||
Subproject commit 05e06e14c317841b68d35f70055882cd5e24623d
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
#!/bin/sh -e
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ruff check api --select I --select F401 --fix
|
||||
ruff format api
|
||||
|
||||
(cd ui && npm run fix-lint)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# generated by datamodel-codegen:
|
||||
# filename: dograh-openapi-XXXXXX.json.YApLaGcbbM
|
||||
# timestamp: 2026-05-04T09:31:31+00:00
|
||||
# filename: dograh-openapi-XXXXXX.json.r9ocBCl93O
|
||||
# timestamp: 2026-05-05T13:22:04+00:00
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { format } from 'date-fns';
|
||||
import { ArrowLeft, CalendarIcon, Check, Clock, Download, Pause, Pencil, Phone, Play, RefreshCw, X } from 'lucide-react';
|
||||
import { AlertCircle, AlertTriangle, ArrowLeft, CalendarIcon, Check, Clock, Download, Info, Pause, Pencil, Phone, Play, RefreshCw, X } from 'lucide-react';
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
|
@ -381,6 +381,38 @@ export default function CampaignDetailPage() {
|
|||
|
||||
const canEdit = campaign && ['created', 'running', 'paused'].includes(campaign.state);
|
||||
|
||||
// Newest entries first. The backend appends chronologically; the UI is more
|
||||
// useful when the most recent failure / pause is at the top.
|
||||
const sortedLogs = (campaign?.logs ?? []).slice().reverse();
|
||||
|
||||
const getLogIcon = (level: string) => {
|
||||
switch (level) {
|
||||
case 'error':
|
||||
return <AlertCircle className="h-4 w-4 text-destructive" />;
|
||||
case 'warning':
|
||||
return <AlertTriangle className="h-4 w-4 text-amber-500" />;
|
||||
default:
|
||||
return <Info className="h-4 w-4 text-blue-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getLogBadgeVariant = (level: string): 'destructive' | 'secondary' | 'outline' => {
|
||||
switch (level) {
|
||||
case 'error':
|
||||
return 'destructive';
|
||||
case 'warning':
|
||||
return 'outline';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
};
|
||||
|
||||
const formatLogTimestamp = (ts: string) => {
|
||||
const d = new Date(ts);
|
||||
if (isNaN(d.getTime())) return ts;
|
||||
return d.toLocaleString();
|
||||
};
|
||||
|
||||
// Render action button based on state
|
||||
const renderActionButton = () => {
|
||||
if (!campaign || isExecutingAction) return null;
|
||||
|
|
@ -796,6 +828,56 @@ export default function CampaignDetailPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Activity Log */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Activity Log</CardTitle>
|
||||
<CardDescription>
|
||||
Recent state transitions and failures. Newest first.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{sortedLogs.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No events recorded yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{sortedLogs.map((entry, idx) => (
|
||||
<li
|
||||
key={`${entry.ts}-${idx}`}
|
||||
className="flex gap-3 border-b last:border-b-0 pb-3 last:pb-0"
|
||||
>
|
||||
<div className="mt-0.5">{getLogIcon(entry.level)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={getLogBadgeVariant(entry.level)} className="text-xs">
|
||||
{entry.level}
|
||||
</Badge>
|
||||
<code className="text-xs text-muted-foreground">
|
||||
{entry.event}
|
||||
</code>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatLogTimestamp(entry.ts)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm mt-1 break-words">{entry.message}</p>
|
||||
{entry.details && Object.keys(entry.details).length > 0 && (
|
||||
<details className="mt-1.5">
|
||||
<summary className="text-xs text-muted-foreground cursor-pointer hover:text-foreground">
|
||||
Details
|
||||
</summary>
|
||||
<pre className="mt-1.5 text-xs bg-muted rounded p-2 overflow-x-auto whitespace-pre-wrap break-words">
|
||||
{JSON.stringify(entry.details, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Workflow Runs */}
|
||||
<CampaignRuns
|
||||
campaignId={campaignId}
|
||||
|
|
|
|||
|
|
@ -8,9 +8,10 @@ import { useEffect, useState } from "react";
|
|||
import { PhoneInput } from 'react-international-phone';
|
||||
|
||||
import {
|
||||
getTelephonyConfigurationApiV1OrganizationsTelephonyConfigGet,
|
||||
initiateCallApiV1TelephonyInitiateCallPost
|
||||
initiateCallApiV1TelephonyInitiateCallPost,
|
||||
listTelephonyConfigurationsApiV1OrganizationsTelephonyConfigsGet
|
||||
} from '@/client/sdk.gen';
|
||||
import type { TelephonyConfigurationListItem } from '@/client/types.gen';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -22,6 +23,14 @@ import {
|
|||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useUserConfig } from "@/context/UserConfigContext";
|
||||
|
||||
interface PhoneCallDialogProps {
|
||||
|
|
@ -47,6 +56,8 @@ export const PhoneCallDialog = ({
|
|||
const [checkingConfig, setCheckingConfig] = useState(false);
|
||||
const [needsConfiguration, setNeedsConfiguration] = useState<boolean | null>(null);
|
||||
const [sipMode, setSipMode] = useState(() => /^(PJSIP|SIP)\//i.test(userConfig?.test_phone_number || ""));
|
||||
const [telephonyConfigs, setTelephonyConfigs] = useState<TelephonyConfigurationListItem[]>([]);
|
||||
const [selectedConfigId, setSelectedConfigId] = useState<string>("");
|
||||
|
||||
// Check telephony configuration when dialog opens
|
||||
useEffect(() => {
|
||||
|
|
@ -55,16 +66,25 @@ export const PhoneCallDialog = ({
|
|||
|
||||
setCheckingConfig(true);
|
||||
try {
|
||||
const configResponse = await getTelephonyConfigurationApiV1OrganizationsTelephonyConfigGet({});
|
||||
const configResponse = await listTelephonyConfigurationsApiV1OrganizationsTelephonyConfigsGet({});
|
||||
|
||||
if (configResponse.error || (!configResponse.data?.twilio && !configResponse.data?.vonage && !configResponse.data?.vobiz && !configResponse.data?.cloudonix && !configResponse.data?.ari && !configResponse.data?.telnyx && !configResponse.data?.plivo)) {
|
||||
const configurations = configResponse.data?.configurations ?? [];
|
||||
if (configResponse.error || configurations.length === 0) {
|
||||
setNeedsConfiguration(true);
|
||||
setTelephonyConfigs([]);
|
||||
setSelectedConfigId("");
|
||||
} else {
|
||||
setNeedsConfiguration(false);
|
||||
setTelephonyConfigs(configurations);
|
||||
const defaultConfig =
|
||||
configurations.find((c) => c.is_default_outbound) ?? configurations[0];
|
||||
setSelectedConfigId(String(defaultConfig.id));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to check telephony config:", err);
|
||||
setNeedsConfiguration(false);
|
||||
setTelephonyConfigs([]);
|
||||
setSelectedConfigId("");
|
||||
} finally {
|
||||
setCheckingConfig(false);
|
||||
}
|
||||
|
|
@ -80,6 +100,8 @@ export const PhoneCallDialog = ({
|
|||
setCallSuccessMsg(null);
|
||||
setCallLoading(false);
|
||||
setNeedsConfiguration(null);
|
||||
setTelephonyConfigs([]);
|
||||
setSelectedConfigId("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
|
|
@ -124,7 +146,8 @@ export const PhoneCallDialog = ({
|
|||
const response = await initiateCallApiV1TelephonyInitiateCallPost({
|
||||
body: {
|
||||
workflow_id: workflowId,
|
||||
phone_number: phoneNumber
|
||||
phone_number: phoneNumber,
|
||||
telephony_configuration_id: selectedConfigId ? Number(selectedConfigId) : null,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -189,6 +212,24 @@ export const PhoneCallDialog = ({
|
|||
Enter the phone number or SIP endpoint to call. The number will be saved automatically.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{telephonyConfigs.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="telephony-config">Telephony configuration</Label>
|
||||
<Select value={selectedConfigId} onValueChange={setSelectedConfigId}>
|
||||
<SelectTrigger id="telephony-config" className="w-full">
|
||||
<SelectValue placeholder="Select a configuration" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{telephonyConfigs.map((config) => (
|
||||
<SelectItem key={config.id} value={String(config.id)}>
|
||||
{config.name} ({config.provider})
|
||||
{config.is_default_outbound ? " — default" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{sipMode ? (
|
||||
<Input
|
||||
value={phoneNumber}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -346,6 +346,39 @@ export type CampaignDefaultsResponse = {
|
|||
last_campaign_settings?: LastCampaignSettingsResponse | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* CampaignLogEntryResponse
|
||||
*
|
||||
* A single timestamped entry from the campaign's append-only log.
|
||||
*
|
||||
* Surfaced in the UI so operators can see why a campaign moved to
|
||||
* paused / failed without digging through server logs.
|
||||
*/
|
||||
export type CampaignLogEntryResponse = {
|
||||
/**
|
||||
* Ts
|
||||
*/
|
||||
ts: string;
|
||||
/**
|
||||
* Level
|
||||
*/
|
||||
level: string;
|
||||
/**
|
||||
* Event
|
||||
*/
|
||||
event: string;
|
||||
/**
|
||||
* Message
|
||||
*/
|
||||
message: string;
|
||||
/**
|
||||
* Details
|
||||
*/
|
||||
details?: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* CampaignProgressResponse
|
||||
*/
|
||||
|
|
@ -481,6 +514,10 @@ export type CampaignResponse = {
|
|||
* Telephony Configuration Name
|
||||
*/
|
||||
telephony_configuration_name?: string | null;
|
||||
/**
|
||||
* Logs
|
||||
*/
|
||||
logs?: Array<CampaignLogEntryResponse>;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue