Merge stages 2-6 into main (completes the stacked PR chain #2-#6)
PRs #2-#6 each merged into their stacked base branch rather than main; feature/stage5-data-layer ended up holding the full accepted stack. This merge lands that exact tree on main — no new content. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,9 @@ DATABASE_URL=postgresql+asyncpg://holdslayer:<db-password>@localhost:5432/holdsl
|
||||
API_TOKEN=
|
||||
|
||||
# --- SIP Trunk ---
|
||||
# The mock engine must be requested explicitly; an unconfigured trunk
|
||||
# without USE_MOCK_SIP=true refuses to start.
|
||||
USE_MOCK_SIP=false
|
||||
SIP_TRUNK_HOST=sip.yourprovider.com
|
||||
SIP_TRUNK_PORT=5060
|
||||
SIP_TRUNK_USERNAME=your_sip_username
|
||||
|
||||
25
README.md
25
README.md
@@ -21,7 +21,7 @@ You give it a phone number and an intent ("dispute a charge on my December state
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌───────────┐ ┌──────────────┐ │
|
||||
│ │ REST API │ │WebSocket │ │MCP Server │ │ Dashboard │ │
|
||||
│ │ /api/* │ │ /ws/* │ │ (SSE) │ │ /dashboard │ │
|
||||
│ │ /api/* │ │ /ws/* │ │ (HTTP) │ │ /dashboard │ │
|
||||
│ └────┬─────┘ └────┬─────┘ └─────┬─────┘ └──────────────┘ │
|
||||
│ │ │ │ │
|
||||
│ ┌────┴──────────────┴──────────────┴────┐ │
|
||||
@@ -53,7 +53,7 @@ You give it a phone number and an intent ("dispute a charge on my December state
|
||||
|
||||
### Core Engine
|
||||
- **Sippy B2BUA Engine** (`core/sippy_engine.py`) — SIP call control, DTMF, bridging, conference, trunk registration
|
||||
- **PJSUA2 Media Pipeline** (`core/media_pipeline.py`) — Audio routing, recording ports, conference bridge, WAV playback
|
||||
- **PJSUA2 Media Pipeline** (`core/media_pipeline.py`) — Audio routing, recording ports, conference bridge, WAV playback (stub mode until the `pjsua2` bindings are installed — see note below)
|
||||
- **Call Manager** (`core/call_manager.py`) — Active call state tracking, lifecycle management
|
||||
- **Event Bus** (`core/event_bus.py`) — Async pub/sub with per-subscriber queues, type filtering, history
|
||||
|
||||
@@ -78,7 +78,7 @@ You give it a phone number and an intent ("dispute a charge on my December state
|
||||
### API Surface
|
||||
- **REST API** — Call management, call history, transcripts, recordings, routing rules, device DND, call flow CRUD
|
||||
- **WebSocket** — Real-time call events, transcripts, classification updates, receptionist state transitions
|
||||
- **MCP Server** — 14 tools + 3 resources for AI assistant integration (make calls, send DTMF, get transcripts, manage flows), served over streamable HTTP at `/mcp/`
|
||||
- **MCP Server** — 15 tools + 3 resources for AI assistant integration (make calls, send DTMF, get transcripts, manage flows), served over streamable HTTP at `/mcp/`
|
||||
- **Dashboard** — SvelteKit UI served at `/dashboard` with live monitor, call history with transcript playback, and a routing-rules editor
|
||||
|
||||
### Data Models
|
||||
@@ -130,7 +130,7 @@ hold-slayer/
|
||||
│ ├── calls/[call_id]/ # Detail page + transcript playback
|
||||
│ └── routing/ # Rules editor + DND toggles
|
||||
├── mcp_server/
|
||||
│ └── server.py # MCP tools + resources (10 tools)
|
||||
│ └── server.py # MCP tools + resources (15 tools)
|
||||
├── models/
|
||||
│ ├── call.py # Call state models
|
||||
│ ├── call_flow.py # IVR tree models
|
||||
@@ -139,7 +139,7 @@ hold-slayer/
|
||||
│ ├── device.py # Device models
|
||||
│ └── contact.py # Contact models
|
||||
├── db/
|
||||
│ └── database.py # SQLAlchemy async (PostgreSQL/SQLite)
|
||||
│ └── database.py # SQLAlchemy async (PostgreSQL + Alembic)
|
||||
└── tests/
|
||||
├── test_audio_classifier.py # 18 tests — waveform analysis
|
||||
├── test_call_flows.py # 10 tests — call flow models
|
||||
@@ -283,7 +283,7 @@ claude mcp add hold-slayer --transport http http://localhost:8000/mcp/ \
|
||||
--header "Authorization: Bearer $API_TOKEN"
|
||||
```
|
||||
|
||||
It exposes 14 tools and 3 resources (`gateway://status`,
|
||||
It exposes 15 tools and 3 resources (`gateway://status`,
|
||||
`gateway://call-flows`, `gateway://active-calls`):
|
||||
|
||||
| Tool | Description |
|
||||
@@ -302,6 +302,7 @@ It exposes 14 tools and 3 resources (`gateway://status`,
|
||||
| `create_call_flow` | Store a new IVR call flow |
|
||||
| `get_call_summary` | Stored summary and action items for a call |
|
||||
| `search_call_history` | Search past calls by number or intent |
|
||||
| `learn_call_flow` | Build/refine a reusable IVR flow from an exploration call |
|
||||
|
||||
## How It Works
|
||||
|
||||
@@ -352,7 +353,7 @@ All configuration is via environment variables (see `.env.example`):
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Python 3.13** + **asyncio** — Single-process async architecture
|
||||
- **Python 3.12+** + **asyncio** — Single-process async architecture
|
||||
- **FastAPI** — REST API + WebSocket server
|
||||
- **SvelteKit** — Dashboard UI (built static, served by FastAPI at `/dashboard`)
|
||||
- **Sippy B2BUA** — SIP call control and DTMF
|
||||
@@ -360,7 +361,7 @@ All configuration is via environment variables (see `.env.example`):
|
||||
- **Speaches** (Whisper) — Speech-to-text
|
||||
- **Rhema** (Kokoro) — Text-to-speech (OpenAI-compatible `/v1/audio/speech`)
|
||||
- **Ollama / vLLM / OpenAI** — LLM for IVR menu analysis and receptionist intent capture
|
||||
- **SQLAlchemy** — Async database (PostgreSQL or SQLite)
|
||||
- **SQLAlchemy + Alembic** — Async database (PostgreSQL; schema managed by migrations)
|
||||
- **MCP (Model Context Protocol)** — AI assistant integration
|
||||
|
||||
## Documentation
|
||||
@@ -384,7 +385,7 @@ Full documentation is in [`/docs`](docs/README.md):
|
||||
|
||||
- [x] Extract EventBus to dedicated module with typed filtering
|
||||
- [x] Implement Sippy B2BUA SIP engine (signaling, DTMF, bridging)
|
||||
- [x] Implement PJSUA2 media pipeline (conference bridge, audio tapping, recording)
|
||||
- [x] PJSUA2 media pipeline contract (conference bridge, audio tapping, recording) — runs in stub mode until `pjsua2` bindings are installed
|
||||
- [x] Call manager with active call state tracking
|
||||
- [x] Gateway orchestrator wiring all components
|
||||
|
||||
@@ -400,18 +401,18 @@ Full documentation is in [`/docs`](docs/README.md):
|
||||
|
||||
- [x] REST API — calls, call flows, devices, DTMF
|
||||
- [x] WebSocket real-time event streaming
|
||||
- [x] MCP server with 14 tools + 3 resources, mounted at `/mcp/` (streamable HTTP)
|
||||
- [x] MCP server with 15 tools + 3 resources, mounted at `/mcp/` (streamable HTTP)
|
||||
- [x] Notification service (WebSocket + SMS)
|
||||
- [x] Service wiring in main.py lifespan
|
||||
|
||||
### Phase 4: Production Hardening 🚧
|
||||
|
||||
- [ ] Alembic database migrations
|
||||
- [x] Alembic database migrations (baseline + upgrade-on-boot)
|
||||
- [x] API authentication — static bearer token across REST/WS/MCP
|
||||
- [x] Emergency-number guard + concurrent-call cap on outbound calls
|
||||
- [ ] Rate limiting on API endpoints
|
||||
- [ ] Structured JSON logging
|
||||
- [ ] Health check endpoints for all dependencies
|
||||
- [x] Honest /health — engine mode, DB ping, trunk registration, STT/TTS availability
|
||||
- [ ] Graceful degradation (classifier works without STT, etc.)
|
||||
- [ ] Docker Compose (Hold Slayer + PostgreSQL)
|
||||
|
||||
|
||||
42
alembic.ini
Normal file
42
alembic.ini
Normal file
@@ -0,0 +1,42 @@
|
||||
# Alembic configuration. The database URL is not set here — env.py
|
||||
# reads it from config.Settings (environment / .env), so CLI runs and
|
||||
# app startup migrate the same database the app uses.
|
||||
|
||||
[alembic]
|
||||
script_location = db/migrations
|
||||
prepend_sys_path = .
|
||||
path_separator = os
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -2,26 +2,21 @@
|
||||
Call Flows API — Store and manage IVR navigation trees.
|
||||
|
||||
The system gets smarter every time you call somewhere.
|
||||
Thin HTTP layer over the shared data functions in call_persistence.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from slugify import slugify
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.deps import get_gateway
|
||||
from core.gateway import AIPSTNGateway
|
||||
from db.database import StoredCallFlow, get_db
|
||||
from db.database import get_db
|
||||
from models.call_flow import (
|
||||
CallFlow,
|
||||
CallFlowCreate,
|
||||
CallFlowStep,
|
||||
CallFlowSummary,
|
||||
CallFlowUpdate,
|
||||
)
|
||||
from services import call_persistence as store
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -34,39 +29,23 @@ async def create_call_flow(
|
||||
"""Store a new call flow for a phone number."""
|
||||
flow_id = slugify(flow.name)
|
||||
|
||||
# Check if ID already exists
|
||||
existing = await db.execute(
|
||||
select(StoredCallFlow).where(StoredCallFlow.id == flow_id)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
if await store.get_flow(db, flow_id):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Call flow '{flow_id}' already exists. Use PUT to update.",
|
||||
)
|
||||
|
||||
db_flow = StoredCallFlow(
|
||||
id=flow_id,
|
||||
row = await store.create_flow(
|
||||
db,
|
||||
flow_id=flow_id,
|
||||
name=flow.name,
|
||||
phone_number=flow.phone_number,
|
||||
description=flow.description,
|
||||
steps=[s.model_dump() for s in flow.steps],
|
||||
tags=flow.tags,
|
||||
notes=flow.notes,
|
||||
last_verified=datetime.now(),
|
||||
)
|
||||
db.add(db_flow)
|
||||
await db.flush()
|
||||
|
||||
return CallFlow(
|
||||
id=flow_id,
|
||||
name=flow.name,
|
||||
phone_number=flow.phone_number,
|
||||
description=flow.description,
|
||||
steps=flow.steps,
|
||||
tags=flow.tags,
|
||||
notes=flow.notes,
|
||||
last_verified=datetime.now(),
|
||||
)
|
||||
return store.flow_to_model(row)
|
||||
|
||||
|
||||
@router.get("/", response_model=list[CallFlowSummary])
|
||||
@@ -74,9 +53,7 @@ async def list_call_flows(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all stored call flows."""
|
||||
result = await db.execute(select(StoredCallFlow))
|
||||
rows = result.scalars().all()
|
||||
|
||||
rows = await store.list_flows(db)
|
||||
return [
|
||||
CallFlowSummary(
|
||||
id=row.id,
|
||||
@@ -100,26 +77,10 @@ async def get_call_flow(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get a stored call flow by ID."""
|
||||
result = await db.execute(
|
||||
select(StoredCallFlow).where(StoredCallFlow.id == flow_id)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
row = await store.get_flow(db, flow_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail=f"Call flow '{flow_id}' not found")
|
||||
|
||||
return CallFlow(
|
||||
id=row.id,
|
||||
name=row.name,
|
||||
phone_number=row.phone_number,
|
||||
description=row.description or "",
|
||||
steps=[CallFlowStep(**s) for s in row.steps],
|
||||
tags=row.tags or [],
|
||||
notes=row.notes,
|
||||
avg_hold_time=row.avg_hold_time,
|
||||
success_rate=row.success_rate,
|
||||
last_used=row.last_used,
|
||||
times_used=row.times_used or 0,
|
||||
)
|
||||
return store.flow_to_model(row)
|
||||
|
||||
|
||||
@router.get("/by-number/{phone_number}", response_model=CallFlow)
|
||||
@@ -128,29 +89,13 @@ async def get_flow_for_number(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Look up stored call flow by phone number."""
|
||||
result = await db.execute(
|
||||
select(StoredCallFlow).where(StoredCallFlow.phone_number == phone_number)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
row = await store.get_flow_by_number(db, phone_number)
|
||||
if not row:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No call flow found for {phone_number}",
|
||||
)
|
||||
|
||||
return CallFlow(
|
||||
id=row.id,
|
||||
name=row.name,
|
||||
phone_number=row.phone_number,
|
||||
description=row.description or "",
|
||||
steps=[CallFlowStep(**s) for s in row.steps],
|
||||
tags=row.tags or [],
|
||||
notes=row.notes,
|
||||
avg_hold_time=row.avg_hold_time,
|
||||
success_rate=row.success_rate,
|
||||
last_used=row.last_used,
|
||||
times_used=row.times_used or 0,
|
||||
)
|
||||
return store.flow_to_model(row)
|
||||
|
||||
|
||||
@router.put("/{flow_id}", response_model=CallFlow)
|
||||
@@ -160,10 +105,7 @@ async def update_call_flow(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update an existing call flow."""
|
||||
result = await db.execute(
|
||||
select(StoredCallFlow).where(StoredCallFlow.id == flow_id)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
row = await store.get_flow(db, flow_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail=f"Call flow '{flow_id}' not found")
|
||||
|
||||
@@ -181,20 +123,7 @@ async def update_call_flow(
|
||||
row.last_verified = update.last_verified
|
||||
|
||||
await db.flush()
|
||||
|
||||
return CallFlow(
|
||||
id=row.id,
|
||||
name=row.name,
|
||||
phone_number=row.phone_number,
|
||||
description=row.description or "",
|
||||
steps=[CallFlowStep(**s) for s in row.steps],
|
||||
tags=row.tags or [],
|
||||
notes=row.notes,
|
||||
avg_hold_time=row.avg_hold_time,
|
||||
success_rate=row.success_rate,
|
||||
last_used=row.last_used,
|
||||
times_used=row.times_used or 0,
|
||||
)
|
||||
return store.flow_to_model(row)
|
||||
|
||||
|
||||
@router.delete("/{flow_id}")
|
||||
@@ -203,10 +132,7 @@ async def delete_call_flow(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a stored call flow."""
|
||||
result = await db.execute(
|
||||
select(StoredCallFlow).where(StoredCallFlow.id == flow_id)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
row = await store.get_flow(db, flow_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail=f"Call flow '{flow_id}' not found")
|
||||
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
"""
|
||||
Call History API — Read-only access to persisted call records,
|
||||
transcript chunks, and recording files for the dashboard.
|
||||
Thin HTTP layer over the shared data functions in call_persistence.
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy import desc, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db.database import (
|
||||
CallRecord,
|
||||
RecordingRecord,
|
||||
TranscriptChunk,
|
||||
get_db,
|
||||
)
|
||||
from db.database import get_db
|
||||
from services import call_persistence as store
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -25,102 +21,47 @@ router = APIRouter()
|
||||
async def list_history(
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
number: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
since: Optional[datetime] = None,
|
||||
until: Optional[datetime] = None,
|
||||
number: str | None = None,
|
||||
status: str | None = None,
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Paged list of past calls, newest first."""
|
||||
stmt = select(CallRecord).order_by(desc(CallRecord.started_at))
|
||||
if number:
|
||||
stmt = stmt.where(CallRecord.remote_number == number)
|
||||
if status:
|
||||
stmt = stmt.where(CallRecord.status == status)
|
||||
if since:
|
||||
stmt = stmt.where(CallRecord.started_at >= since)
|
||||
if until:
|
||||
stmt = stmt.where(CallRecord.started_at <= until)
|
||||
|
||||
rows = (await db.execute(stmt.offset(offset).limit(limit))).scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"direction": r.direction,
|
||||
"remote_number": r.remote_number,
|
||||
"status": r.status,
|
||||
"mode": r.mode,
|
||||
"intent": r.intent,
|
||||
"started_at": r.started_at.isoformat() if r.started_at else None,
|
||||
"ended_at": r.ended_at.isoformat() if r.ended_at else None,
|
||||
"duration": r.duration,
|
||||
"hold_time": r.hold_time,
|
||||
"device_used": r.device_used,
|
||||
"summary": r.summary,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
rows = await store.search_history(
|
||||
db,
|
||||
number=number,
|
||||
status=status,
|
||||
since=since,
|
||||
until=until,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return [store.record_summary(r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/{call_id}/record")
|
||||
async def get_record(call_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Full CallRecord with classification_timeline."""
|
||||
row = (await db.execute(
|
||||
select(CallRecord).where(CallRecord.id == call_id)
|
||||
)).scalar_one_or_none()
|
||||
row = await store.get_record(db, call_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail=f"Call {call_id} not found")
|
||||
return {
|
||||
"id": row.id,
|
||||
"direction": row.direction,
|
||||
"remote_number": row.remote_number,
|
||||
"status": row.status,
|
||||
"mode": row.mode,
|
||||
"intent": row.intent,
|
||||
"started_at": row.started_at.isoformat() if row.started_at else None,
|
||||
"ended_at": row.ended_at.isoformat() if row.ended_at else None,
|
||||
"duration": row.duration,
|
||||
"hold_time": row.hold_time,
|
||||
"device_used": row.device_used,
|
||||
"summary": row.summary,
|
||||
"action_items": row.action_items,
|
||||
"sentiment": row.sentiment,
|
||||
"call_flow_id": row.call_flow_id,
|
||||
"classification_timeline": row.classification_timeline,
|
||||
}
|
||||
return store.record_detail(row)
|
||||
|
||||
|
||||
@router.get("/{call_id}/transcript")
|
||||
async def get_transcript(call_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Ordered transcript chunks for a call."""
|
||||
rows = (await db.execute(
|
||||
select(TranscriptChunk)
|
||||
.where(TranscriptChunk.call_id == call_id)
|
||||
.order_by(TranscriptChunk.seq)
|
||||
)).scalars().all()
|
||||
return [
|
||||
{
|
||||
"seq": c.seq,
|
||||
"t_offset_ms": c.t_offset_ms,
|
||||
"speaker": c.speaker,
|
||||
"text": c.text,
|
||||
"confidence": c.confidence,
|
||||
}
|
||||
for c in rows
|
||||
]
|
||||
rows = await store.get_transcript_chunks(db, call_id)
|
||||
return [store.chunk_to_dict(c) for c in rows]
|
||||
|
||||
|
||||
@router.get("/{call_id}/recording")
|
||||
async def get_recording(call_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Stream the WAV recording for a call."""
|
||||
row = (await db.execute(
|
||||
select(RecordingRecord)
|
||||
.where(RecordingRecord.call_id == call_id)
|
||||
.order_by(desc(RecordingRecord.started_at))
|
||||
)).scalar_one_or_none()
|
||||
row = await store.latest_recording(db, call_id)
|
||||
if not row or not row.path:
|
||||
raise HTTPException(status_code=404, detail="Recording not found")
|
||||
import os
|
||||
if not os.path.exists(row.path):
|
||||
raise HTTPException(status_code=404, detail="Recording file missing on disk")
|
||||
return FileResponse(row.path, media_type="audio/wav", filename=os.path.basename(row.path))
|
||||
|
||||
41
api/calls.py
41
api/calls.py
@@ -40,12 +40,7 @@ async def make_call(
|
||||
call_flow_id=request.call_flow_id,
|
||||
services=request.services,
|
||||
)
|
||||
return CallResponse(
|
||||
call_id=call.id,
|
||||
status=call.status.value,
|
||||
number=request.number,
|
||||
mode=request.mode.value,
|
||||
)
|
||||
return CallResponse.from_call(call)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
@@ -81,11 +76,8 @@ async def hold_slayer(
|
||||
call_flow_id=request.call_flow_id,
|
||||
device=request.transfer_to,
|
||||
)
|
||||
return CallResponse(
|
||||
call_id=call.id,
|
||||
status="navigating_ivr",
|
||||
number=request.number,
|
||||
mode="hold_slayer",
|
||||
return CallResponse.from_call(
|
||||
call,
|
||||
message="Hold Slayer activated. I'll ring you when a human picks up. ☕",
|
||||
)
|
||||
except ValueError as e:
|
||||
@@ -113,21 +105,7 @@ async def get_call(
|
||||
if not call:
|
||||
raise HTTPException(status_code=404, detail=f"Call {call_id} not found")
|
||||
|
||||
return CallStatusResponse(
|
||||
call_id=call.id,
|
||||
status=call.status.value,
|
||||
direction=call.direction,
|
||||
remote_number=call.remote_number,
|
||||
mode=call.mode.value,
|
||||
duration=call.duration,
|
||||
hold_time=call.hold_time,
|
||||
audio_type=call.current_classification.value,
|
||||
intent=call.intent,
|
||||
transcript_excerpt=call.transcript[-500:] if call.transcript else None,
|
||||
classification_history=call.classification_history[-50:],
|
||||
current_step=call.current_step_id,
|
||||
services=call.services,
|
||||
)
|
||||
return CallStatusResponse.from_call(call)
|
||||
|
||||
|
||||
@router.post("/{call_id}/transfer")
|
||||
@@ -172,10 +150,9 @@ async def send_dtmf(
|
||||
if not call:
|
||||
raise HTTPException(status_code=404, detail=f"Call {call_id} not found")
|
||||
|
||||
# Find the PSTN leg for this call
|
||||
for leg_id, cid in gateway.call_manager._call_legs.items():
|
||||
if cid == call_id:
|
||||
await gateway.sip_engine.send_dtmf(leg_id, digits)
|
||||
return {"status": "sent", "digits": digits}
|
||||
legs = gateway.call_manager.legs_for_call(call_id)
|
||||
if not legs:
|
||||
raise HTTPException(status_code=409, detail="No active SIP leg found for this call")
|
||||
|
||||
raise HTTPException(status_code=500, detail="No active SIP leg found for this call")
|
||||
await gateway.sip_engine.send_dtmf(legs[0], digits)
|
||||
return {"status": "sent", "digits": digits}
|
||||
|
||||
@@ -18,6 +18,14 @@ def get_gateway(request: Request) -> AIPSTNGateway:
|
||||
return gateway
|
||||
|
||||
|
||||
def get_routing_service(request: Request):
|
||||
"""Get the routing service from app state."""
|
||||
routing = getattr(request.app.state, "routing_service", None)
|
||||
if routing is None:
|
||||
raise HTTPException(status_code=503, detail="Routing service not ready")
|
||||
return routing
|
||||
|
||||
|
||||
def require_token(authorization: str | None = Header(default=None)) -> None:
|
||||
"""
|
||||
Enforce the static bearer token (API_TOKEN) on REST routes.
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
"""
|
||||
Device Management API — Register and manage phones/softphones.
|
||||
Row mapping lives in call_persistence; this layer works with the
|
||||
Device domain model only.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.deps import get_gateway
|
||||
from core.gateway import AIPSTNGateway
|
||||
from db.database import Device as DeviceDB
|
||||
from db.database import get_db
|
||||
from models.device import Device, DeviceCreate, DeviceStatus, DeviceUpdate
|
||||
from models.device import Device, DeviceCreate, DeviceUpdate
|
||||
from services import call_persistence as store
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -25,45 +25,18 @@ async def register_device(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Register a new device with the gateway."""
|
||||
device_id = f"dev_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Save to DB
|
||||
db_device = DeviceDB(
|
||||
id=device_id,
|
||||
name=device.name,
|
||||
type=device.type.value,
|
||||
sip_uri=device.sip_uri,
|
||||
phone_number=device.phone_number,
|
||||
priority=device.priority,
|
||||
capabilities=device.capabilities,
|
||||
is_online="false",
|
||||
)
|
||||
db.add(db_device)
|
||||
await db.flush()
|
||||
|
||||
# Register with gateway
|
||||
dev = Device(id=device_id, **device.model_dump())
|
||||
dev = Device(id=f"dev_{uuid.uuid4().hex[:8]}", **device.model_dump())
|
||||
await store.create_device_row(db, dev)
|
||||
gateway.register_device(dev)
|
||||
|
||||
return dev
|
||||
|
||||
|
||||
@router.get("/", response_model=list[DeviceStatus])
|
||||
@router.get("/", response_model=list[Device])
|
||||
async def list_devices(
|
||||
gateway: AIPSTNGateway = Depends(get_gateway),
|
||||
):
|
||||
"""List all registered devices and their status."""
|
||||
return [
|
||||
DeviceStatus(
|
||||
id=d.id,
|
||||
name=d.name,
|
||||
type=d.type,
|
||||
is_online=d.is_online,
|
||||
last_seen=d.last_seen,
|
||||
can_receive_call=d.can_receive_call,
|
||||
)
|
||||
for d in gateway.devices.values()
|
||||
]
|
||||
return list(gateway.devices.values())
|
||||
|
||||
|
||||
@router.get("/{device_id}", response_model=Device)
|
||||
@@ -90,22 +63,11 @@ async def update_device(
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
||||
|
||||
# Update in-memory
|
||||
update_data = update.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(device, key, value)
|
||||
|
||||
# Update in DB
|
||||
result = await db.execute(
|
||||
select(DeviceDB).where(DeviceDB.id == device_id)
|
||||
)
|
||||
db_device = result.scalar_one_or_none()
|
||||
if db_device:
|
||||
for key, value in update_data.items():
|
||||
if key == "type" and value is not None:
|
||||
value = value.value if hasattr(value, "value") else value
|
||||
setattr(db_device, key, value)
|
||||
|
||||
await store.update_device_row(db, device_id, update_data)
|
||||
return device
|
||||
|
||||
|
||||
@@ -120,12 +82,5 @@ async def unregister_device(
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
||||
|
||||
gateway.unregister_device(device_id)
|
||||
|
||||
result = await db.execute(
|
||||
select(DeviceDB).where(DeviceDB.id == device_id)
|
||||
)
|
||||
db_device = result.scalar_one_or_none()
|
||||
if db_device:
|
||||
await db.delete(db_device)
|
||||
|
||||
await store.delete_device_row(db, device_id)
|
||||
return {"status": "unregistered", "device_id": device_id}
|
||||
|
||||
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.deps import get_gateway
|
||||
from api.deps import get_gateway, get_routing_service
|
||||
from core.gateway import AIPSTNGateway
|
||||
from db.database import Device as DeviceDB
|
||||
from db.database import get_db
|
||||
@@ -15,36 +15,31 @@ from models.routing import (
|
||||
RoutingRuleCreate,
|
||||
RoutingRuleUpdate,
|
||||
)
|
||||
from services.routing import RoutingService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/rules", response_model=list[RoutingRule])
|
||||
async def list_rules(gateway: AIPSTNGateway = Depends(get_gateway)):
|
||||
if gateway._routing is None:
|
||||
raise HTTPException(status_code=503, detail="Routing service not ready")
|
||||
return sorted(gateway._routing.rules, key=lambda r: (r.priority, r.id))
|
||||
async def list_rules(routing: RoutingService = Depends(get_routing_service)):
|
||||
return sorted(routing.rules, key=lambda r: (r.priority, r.id))
|
||||
|
||||
|
||||
@router.post("/rules", response_model=RoutingRule, status_code=201)
|
||||
async def create_rule(
|
||||
payload: RoutingRuleCreate,
|
||||
gateway: AIPSTNGateway = Depends(get_gateway),
|
||||
routing: RoutingService = Depends(get_routing_service),
|
||||
):
|
||||
if gateway._routing is None:
|
||||
raise HTTPException(status_code=503, detail="Routing service not ready")
|
||||
return await gateway._routing.create_rule(payload)
|
||||
return await routing.create_rule(payload)
|
||||
|
||||
|
||||
@router.put("/rules/{rule_id}", response_model=RoutingRule)
|
||||
async def update_rule(
|
||||
rule_id: str,
|
||||
payload: RoutingRuleUpdate,
|
||||
gateway: AIPSTNGateway = Depends(get_gateway),
|
||||
routing: RoutingService = Depends(get_routing_service),
|
||||
):
|
||||
if gateway._routing is None:
|
||||
raise HTTPException(status_code=503, detail="Routing service not ready")
|
||||
rule = await gateway._routing.update_rule(rule_id, payload)
|
||||
rule = await routing.update_rule(rule_id, payload)
|
||||
if rule is None:
|
||||
raise HTTPException(status_code=404, detail=f"Rule {rule_id} not found")
|
||||
return rule
|
||||
@@ -53,11 +48,9 @@ async def update_rule(
|
||||
@router.delete("/rules/{rule_id}")
|
||||
async def delete_rule(
|
||||
rule_id: str,
|
||||
gateway: AIPSTNGateway = Depends(get_gateway),
|
||||
routing: RoutingService = Depends(get_routing_service),
|
||||
):
|
||||
if gateway._routing is None:
|
||||
raise HTTPException(status_code=503, detail="Routing service not ready")
|
||||
ok = await gateway._routing.delete_rule(rule_id)
|
||||
ok = await routing.delete_rule(rule_id)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail=f"Rule {rule_id} not found")
|
||||
return {"status": "deleted", "rule_id": rule_id}
|
||||
|
||||
@@ -94,7 +94,7 @@ async def event_stream(websocket: WebSocket):
|
||||
# Immediately push current trunk status so the dashboard doesn't start blank
|
||||
await _send_trunk_status(websocket, gateway)
|
||||
|
||||
subscription = gateway.event_bus.subscribe()
|
||||
subscription = gateway.event_bus.subscribe(replay_last=25)
|
||||
|
||||
try:
|
||||
async for event in subscription:
|
||||
|
||||
21
config.py
21
config.py
@@ -11,7 +11,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
class SIPTrunkSettings(BaseSettings):
|
||||
"""SIP trunk provider configuration."""
|
||||
|
||||
model_config = SettingsConfigDict(env_prefix="SIP_TRUNK_")
|
||||
model_config = SettingsConfigDict(env_prefix="SIP_TRUNK_", env_file=".env", extra="ignore")
|
||||
|
||||
host: str = "sip.provider.com"
|
||||
port: int = 5060
|
||||
@@ -24,7 +24,7 @@ class SIPTrunkSettings(BaseSettings):
|
||||
class GatewaySIPSettings(BaseSettings):
|
||||
"""Gateway SIP listener for device registration."""
|
||||
|
||||
model_config = SettingsConfigDict(env_prefix="GATEWAY_SIP_")
|
||||
model_config = SettingsConfigDict(env_prefix="GATEWAY_SIP_", env_file=".env", extra="ignore")
|
||||
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 5060
|
||||
@@ -34,7 +34,7 @@ class GatewaySIPSettings(BaseSettings):
|
||||
class SpeachesSettings(BaseSettings):
|
||||
"""Speaches STT service configuration."""
|
||||
|
||||
model_config = SettingsConfigDict(env_prefix="SPEACHES_")
|
||||
model_config = SettingsConfigDict(env_prefix="SPEACHES_", env_file=".env", extra="ignore")
|
||||
|
||||
url: str = "http://localhost:22070"
|
||||
model: str = "whisper-large-v3"
|
||||
@@ -43,7 +43,7 @@ class SpeachesSettings(BaseSettings):
|
||||
class ClassifierSettings(BaseSettings):
|
||||
"""Audio classifier thresholds."""
|
||||
|
||||
model_config = SettingsConfigDict(env_prefix="CLASSIFIER_")
|
||||
model_config = SettingsConfigDict(env_prefix="CLASSIFIER_", env_file=".env", extra="ignore")
|
||||
|
||||
music_threshold: float = 0.7
|
||||
speech_threshold: float = 0.6
|
||||
@@ -54,7 +54,7 @@ class ClassifierSettings(BaseSettings):
|
||||
class LLMSettings(BaseSettings):
|
||||
"""LLM service configuration (OpenAI-compatible API)."""
|
||||
|
||||
model_config = SettingsConfigDict(env_prefix="LLM_")
|
||||
model_config = SettingsConfigDict(env_prefix="LLM_", env_file=".env", extra="ignore")
|
||||
|
||||
base_url: str = "http://localhost:11434/v1"
|
||||
model: str = "llama3"
|
||||
@@ -67,7 +67,7 @@ class LLMSettings(BaseSettings):
|
||||
class HoldSlayerSettings(BaseSettings):
|
||||
"""Hold Slayer behavior settings."""
|
||||
|
||||
model_config = SettingsConfigDict(env_prefix="HOLD_SLAYER_", env_prefix_allow_empty=True)
|
||||
model_config = SettingsConfigDict(env_prefix="HOLD_SLAYER_", env_prefix_allow_empty=True, env_file=".env", extra="ignore")
|
||||
|
||||
default_transfer_device: str = Field(
|
||||
default="sip_phone", validation_alias="DEFAULT_TRANSFER_DEVICE"
|
||||
@@ -79,7 +79,7 @@ class HoldSlayerSettings(BaseSettings):
|
||||
class TTSSettings(BaseSettings):
|
||||
"""Rhema TTS service configuration (OpenAI-compatible /v1/audio/speech)."""
|
||||
|
||||
model_config = SettingsConfigDict(env_prefix="TTS_")
|
||||
model_config = SettingsConfigDict(env_prefix="TTS_", env_file=".env", extra="ignore")
|
||||
|
||||
base_url: str = "http://localhost:8000"
|
||||
model: str = "speaches-ai/Kokoro-82M-v1.0-ONNX"
|
||||
@@ -92,7 +92,7 @@ class TTSSettings(BaseSettings):
|
||||
class ReceptionistSettings(BaseSettings):
|
||||
"""AI Receptionist behavior settings."""
|
||||
|
||||
model_config = SettingsConfigDict(env_prefix="RECEPTIONIST_")
|
||||
model_config = SettingsConfigDict(env_prefix="RECEPTIONIST_", env_file=".env", extra="ignore")
|
||||
|
||||
enabled: bool = True
|
||||
greeting_template: str = (
|
||||
@@ -133,6 +133,11 @@ class Settings(BaseSettings):
|
||||
# Outbound-call safety cap (REST + MCP make_call)
|
||||
max_concurrent_calls: int = 4
|
||||
|
||||
# Explicit engine mode — the mock engine must be asked for. An
|
||||
# unconfigured trunk without this flag fails startup instead of
|
||||
# silently degrading to a gateway that can't place real calls.
|
||||
use_mock_sip: bool = False
|
||||
|
||||
# Notifications
|
||||
notify_sms_number: str = ""
|
||||
|
||||
|
||||
@@ -5,15 +5,19 @@ Central nervous system of the gateway. Tracks all active calls,
|
||||
publishes events, and coordinates between SIP engine and services.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from core.event_bus import EventBus, EventSubscription
|
||||
from models.call import ActiveCall, AudioClassification, CallMode, CallStatus, ClassificationResult
|
||||
from core.event_bus import EventBus
|
||||
from models.call import (
|
||||
ActiveCall,
|
||||
CallMode,
|
||||
CallStatus,
|
||||
ClassificationResult,
|
||||
TranscriptEntry,
|
||||
)
|
||||
from models.events import EventType, GatewayEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -26,11 +30,12 @@ class CallManager:
|
||||
The single source of truth for what's happening on the gateway.
|
||||
"""
|
||||
|
||||
def __init__(self, event_bus: EventBus):
|
||||
def __init__(self, event_bus: EventBus, on_call_created=None, on_call_ended=None):
|
||||
self.event_bus = event_bus
|
||||
self._active_calls: dict[str, ActiveCall] = {}
|
||||
self._call_legs: dict[str, str] = {} # SIP leg ID -> call ID mapping
|
||||
self._on_call_ended = None # async callback(call: ActiveCall, final_status)
|
||||
self._on_call_created = on_call_created # async callback(call)
|
||||
self._on_call_ended = on_call_ended # async callback(call, final_status)
|
||||
|
||||
# ================================================================
|
||||
# Call Lifecycle
|
||||
@@ -67,6 +72,14 @@ class CallManager:
|
||||
message=f"📞 Calling {remote_number} ({mode.value})",
|
||||
))
|
||||
|
||||
# Durable in_progress row — a crash mid-call must not erase the
|
||||
# call from history. The hook does its own retrying/logging.
|
||||
if self._on_call_created is not None:
|
||||
try:
|
||||
await self._on_call_created(call)
|
||||
except Exception as e:
|
||||
logger.warning(f"on_call_created hook failed for {call_id}: {e}")
|
||||
|
||||
return call
|
||||
|
||||
async def update_status(self, call_id: str, status: CallStatus) -> None:
|
||||
@@ -135,18 +148,26 @@ class CallManager:
|
||||
message=f"🎵 Audio: {result.audio_type.value} ({result.confidence:.0%})",
|
||||
))
|
||||
|
||||
async def add_transcript(self, call_id: str, text: str) -> None:
|
||||
"""Add a transcript chunk to a call."""
|
||||
async def add_transcript(
|
||||
self, call_id: str, text: str, speaker: str = "unknown"
|
||||
) -> None:
|
||||
"""Add a transcript entry to a call, stamped with its offset."""
|
||||
call = self._active_calls.get(call_id)
|
||||
if not call:
|
||||
return
|
||||
|
||||
call.transcript_chunks.append(text)
|
||||
anchor = call.connected_at or call.started_at
|
||||
entry = TranscriptEntry(
|
||||
t_offset_ms=int((datetime.now() - anchor).total_seconds() * 1000),
|
||||
speaker=speaker,
|
||||
text=text,
|
||||
)
|
||||
call.transcript_chunks.append(entry)
|
||||
|
||||
await self.event_bus.publish(GatewayEvent(
|
||||
type=EventType.TRANSCRIPT_CHUNK,
|
||||
call_id=call_id,
|
||||
data={"text": text},
|
||||
data={"text": text, "speaker": speaker, "t_offset_ms": entry.t_offset_ms},
|
||||
message=f"📝 '{text[:80]}...' " if len(text) > 80 else f"📝 '{text}'",
|
||||
))
|
||||
|
||||
@@ -180,6 +201,14 @@ class CallManager:
|
||||
"""Map a SIP leg ID to a call ID."""
|
||||
self._call_legs[sip_leg_id] = call_id
|
||||
|
||||
def unmap_leg(self, sip_leg_id: str) -> None:
|
||||
"""Remove a SIP leg mapping (leg terminated)."""
|
||||
self._call_legs.pop(sip_leg_id, None)
|
||||
|
||||
def legs_for_call(self, call_id: str) -> list[str]:
|
||||
"""All SIP leg IDs currently mapped to a call."""
|
||||
return [leg for leg, cid in self._call_legs.items() if cid == call_id]
|
||||
|
||||
def get_call_for_leg(self, sip_leg_id: str) -> Optional[ActiveCall]:
|
||||
"""Look up which call a SIP leg belongs to."""
|
||||
call_id = self._call_legs.get(sip_leg_id)
|
||||
|
||||
@@ -20,63 +20,70 @@ class EventBus:
|
||||
|
||||
Features:
|
||||
- Non-blocking publish (put_nowait)
|
||||
- Automatic dead-subscriber cleanup (full queues are removed)
|
||||
- Event history (last N events for late joiners)
|
||||
- Slow subscribers lose their oldest event, never their subscription
|
||||
- Event history (last N events, replayable to late joiners)
|
||||
- Typed event filtering on subscriptions
|
||||
- Async iteration via EventSubscription
|
||||
"""
|
||||
|
||||
def __init__(self, max_history: int = 1000):
|
||||
self._subscribers: list[tuple[asyncio.Queue[GatewayEvent], Optional[set[EventType]]]] = []
|
||||
self._subscribers: list[EventSubscription] = []
|
||||
self._history: list[GatewayEvent] = []
|
||||
self._max_history = max_history
|
||||
|
||||
async def publish(self, event: GatewayEvent) -> None:
|
||||
"""Publish an event to all subscribers."""
|
||||
"""Publish an event to all subscribers.
|
||||
|
||||
A full subscriber queue drops its oldest event (counted on the
|
||||
subscription) — a slow dashboard must never be silently
|
||||
unsubscribed while its socket stays open.
|
||||
"""
|
||||
self._history.append(event)
|
||||
if len(self._history) > self._max_history:
|
||||
self._history = self._history[-self._max_history :]
|
||||
|
||||
logger.info(f"📡 Event: {event.type.value} | {event.message or ''}")
|
||||
|
||||
dead_queues = []
|
||||
for queue, type_filter in self._subscribers:
|
||||
# Skip if subscriber has a type filter and this event doesn't match
|
||||
if type_filter and event.type not in type_filter:
|
||||
for sub in self._subscribers:
|
||||
if sub.type_filter and event.type not in sub.type_filter:
|
||||
continue
|
||||
try:
|
||||
queue.put_nowait(event)
|
||||
except asyncio.QueueFull:
|
||||
dead_queues.append((queue, type_filter))
|
||||
|
||||
for entry in dead_queues:
|
||||
self._subscribers.remove(entry)
|
||||
sub.deliver(event)
|
||||
|
||||
def subscribe(
|
||||
self,
|
||||
max_size: int = 100,
|
||||
event_types: Optional[set[EventType]] = None,
|
||||
replay_last: int = 0,
|
||||
) -> "EventSubscription":
|
||||
"""
|
||||
Create a new subscription.
|
||||
|
||||
Args:
|
||||
max_size: Queue depth before subscriber is considered dead.
|
||||
max_size: Queue depth; overflow drops the oldest event.
|
||||
event_types: Optional filter — only receive these event types.
|
||||
None means receive everything.
|
||||
replay_last: Seed the queue with up to N most recent
|
||||
history events (post-filter) before live ones.
|
||||
|
||||
Returns:
|
||||
An async iterator of GatewayEvents.
|
||||
"""
|
||||
queue: asyncio.Queue[GatewayEvent] = asyncio.Queue(maxsize=max_size)
|
||||
entry = (queue, event_types)
|
||||
self._subscribers.append(entry)
|
||||
return EventSubscription(queue, self, entry)
|
||||
sub = EventSubscription(queue, self, event_types)
|
||||
if replay_last > 0:
|
||||
replayable = [
|
||||
e for e in self._history
|
||||
if not event_types or e.type in event_types
|
||||
]
|
||||
for event in replayable[-replay_last:]:
|
||||
sub.deliver(event)
|
||||
self._subscribers.append(sub)
|
||||
return sub
|
||||
|
||||
def unsubscribe(self, entry: tuple) -> None:
|
||||
def unsubscribe(self, sub: "EventSubscription") -> None:
|
||||
"""Remove a subscriber."""
|
||||
if entry in self._subscribers:
|
||||
self._subscribers.remove(entry)
|
||||
if sub in self._subscribers:
|
||||
self._subscribers.remove(sub)
|
||||
|
||||
@property
|
||||
def recent_events(self) -> list[GatewayEvent]:
|
||||
@@ -95,11 +102,28 @@ class EventSubscription:
|
||||
self,
|
||||
queue: asyncio.Queue[GatewayEvent],
|
||||
bus: EventBus,
|
||||
entry: tuple,
|
||||
type_filter: Optional[set[EventType]] = None,
|
||||
):
|
||||
self._queue = queue
|
||||
self._bus = bus
|
||||
self._entry = entry
|
||||
self.type_filter = type_filter
|
||||
self.dropped = 0 # events lost to queue overflow
|
||||
|
||||
def deliver(self, event: GatewayEvent) -> None:
|
||||
"""Enqueue an event, dropping the oldest on overflow."""
|
||||
try:
|
||||
self._queue.put_nowait(event)
|
||||
except asyncio.QueueFull:
|
||||
try:
|
||||
self._queue.get_nowait()
|
||||
self._queue.put_nowait(event)
|
||||
except (asyncio.QueueEmpty, asyncio.QueueFull):
|
||||
pass
|
||||
self.dropped += 1
|
||||
if self.dropped in (1, 10, 100) or self.dropped % 1000 == 0:
|
||||
logger.warning(
|
||||
f"📡 Slow subscriber: {self.dropped} events dropped"
|
||||
)
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
@@ -108,7 +132,7 @@ class EventSubscription:
|
||||
try:
|
||||
return await self._queue.get()
|
||||
except asyncio.CancelledError:
|
||||
self._bus.unsubscribe(self._entry)
|
||||
self._bus.unsubscribe(self)
|
||||
raise
|
||||
|
||||
async def get(self, timeout: Optional[float] = None) -> GatewayEvent:
|
||||
@@ -117,4 +141,4 @@ class EventSubscription:
|
||||
|
||||
def close(self):
|
||||
"""Unsubscribe from the event bus."""
|
||||
self._bus.unsubscribe(self._entry)
|
||||
self._bus.unsubscribe(self)
|
||||
|
||||
308
core/gateway.py
308
core/gateway.py
@@ -1,15 +1,18 @@
|
||||
"""
|
||||
AI PSTN Gateway — The main orchestrator.
|
||||
AI PSTN Gateway — call operations and device registry.
|
||||
|
||||
Ties together SIP engine, call manager, event bus, and all services.
|
||||
This is the top-level object that FastAPI and MCP talk to.
|
||||
The application service that FastAPI and MCP talk to for live-call
|
||||
work. Composition happens in main.py's lifespan: services are built
|
||||
there and attached; this module never imports from services/.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from config import Settings, get_settings
|
||||
from config import Settings
|
||||
from core.call_manager import CallManager
|
||||
from core.dial_plan import is_emergency_number, next_extension
|
||||
from core.event_bus import EventBus
|
||||
@@ -17,110 +20,122 @@ from core.media_pipeline import MediaPipeline
|
||||
from core.sip_engine import MockSIPEngine, SIPEngine
|
||||
from core.sippy_engine import SippyEngine
|
||||
from models.call import ActiveCall, CallMode, CallStatus
|
||||
from models.call_flow import CallFlow
|
||||
from models.device import Device, DeviceType
|
||||
from models.events import EventType, GatewayEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _extract_number(sip_uri: str) -> str:
|
||||
"""Pull the user part out of a SIP URI (sip:+15551212@host → +15551212)."""
|
||||
if not sip_uri:
|
||||
return ""
|
||||
s = sip_uri.strip()
|
||||
if s.startswith("<") and ">" in s:
|
||||
s = s[1:s.index(">")]
|
||||
if s.startswith("sip:"):
|
||||
s = s[4:]
|
||||
if "@" in s:
|
||||
s = s.split("@", 1)[0]
|
||||
return s
|
||||
def build_sip_engine(
|
||||
settings: Settings,
|
||||
media_pipeline: MediaPipeline,
|
||||
on_leg_state_change: Callable,
|
||||
on_device_registered: Callable,
|
||||
on_incoming_call: Callable,
|
||||
) -> SIPEngine:
|
||||
"""
|
||||
Build the SIP engine from config.
|
||||
|
||||
The mock engine must be requested explicitly (USE_MOCK_SIP=true).
|
||||
An unconfigured trunk or a failed SippyEngine construction raises —
|
||||
the caller fails startup rather than running a gateway that can't
|
||||
place real calls while reporting healthy.
|
||||
"""
|
||||
if settings.use_mock_sip:
|
||||
logger.warning("🧪 USE_MOCK_SIP=true — SIP engine is a mock, no real calls")
|
||||
return MockSIPEngine()
|
||||
|
||||
def _build_sip_engine(settings: Settings, gateway: "AIPSTNGateway") -> SIPEngine:
|
||||
"""Build the appropriate SIP engine from config."""
|
||||
trunk = settings.sip_trunk
|
||||
gw_sip = settings.gateway_sip
|
||||
|
||||
if trunk.host and trunk.host != "sip.provider.com":
|
||||
# Real trunk configured — use Sippy B2BUA
|
||||
try:
|
||||
return SippyEngine(
|
||||
sip_address=gw_sip.host,
|
||||
sip_port=gw_sip.port,
|
||||
trunk_host=trunk.host,
|
||||
trunk_port=trunk.port,
|
||||
trunk_username=trunk.username,
|
||||
trunk_password=trunk.password.get_secret_value(),
|
||||
trunk_transport=trunk.transport,
|
||||
domain=gw_sip.domain,
|
||||
did=trunk.did,
|
||||
media_pipeline=gateway.media_pipeline,
|
||||
on_device_registered=gateway._on_sip_device_registered,
|
||||
on_incoming_call=gateway._on_sip_incoming_call,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not create SippyEngine: {e} — using mock")
|
||||
if not trunk.host or trunk.host in ("sip.provider.com", "sip.yourprovider.com"):
|
||||
raise RuntimeError(
|
||||
"SIP trunk is not configured (SIP_TRUNK_HOST is unset or a "
|
||||
"placeholder). Set SIP_TRUNK_* in .env, or set USE_MOCK_SIP=true "
|
||||
"for development without a trunk."
|
||||
)
|
||||
|
||||
return MockSIPEngine()
|
||||
return SippyEngine(
|
||||
sip_address=gw_sip.host,
|
||||
sip_port=gw_sip.port,
|
||||
trunk_host=trunk.host,
|
||||
trunk_port=trunk.port,
|
||||
trunk_username=trunk.username,
|
||||
trunk_password=trunk.password.get_secret_value(),
|
||||
trunk_transport=trunk.transport,
|
||||
domain=gw_sip.domain,
|
||||
did=trunk.did,
|
||||
media_pipeline=media_pipeline,
|
||||
on_leg_state_change=on_leg_state_change,
|
||||
on_device_registered=on_device_registered,
|
||||
on_incoming_call=on_incoming_call,
|
||||
)
|
||||
|
||||
|
||||
class AIPSTNGateway:
|
||||
"""
|
||||
The AI PSTN Gateway.
|
||||
|
||||
Central coordination point for:
|
||||
- SIP engine (signaling + media)
|
||||
- Call manager (state + events)
|
||||
- Hold Slayer service
|
||||
- Audio classifier
|
||||
- Transcription service
|
||||
- Device management
|
||||
Owns live-call operations (make/transfer/hangup), the device
|
||||
registry, and per-call background tasks. Services are attached by
|
||||
the composition root; mode handlers launch per-call services
|
||||
(hold slayer) without the gateway knowing their types.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
sip_engine: Optional[SIPEngine] = None,
|
||||
on_call_created=None,
|
||||
on_call_ended=None,
|
||||
):
|
||||
self.settings = settings
|
||||
self.event_bus = EventBus()
|
||||
self.call_manager = CallManager(self.event_bus)
|
||||
self.call_manager = CallManager(
|
||||
self.event_bus,
|
||||
on_call_created=on_call_created,
|
||||
on_call_ended=on_call_ended,
|
||||
)
|
||||
self.media_pipeline = MediaPipeline(sample_rate=16000)
|
||||
self.sip_engine: SIPEngine = sip_engine or MockSIPEngine()
|
||||
|
||||
# Services (initialized in start())
|
||||
self._hold_slayer = None
|
||||
self._audio_classifier = None
|
||||
self._transcription = None
|
||||
# Attached by the composition root (attach_services)
|
||||
self._tts = None
|
||||
self._routing = None
|
||||
self._receptionist = None
|
||||
|
||||
# Device registry (loaded from DB on start)
|
||||
# Per-call-mode launchers registered by the composition root
|
||||
self._mode_handlers: dict[CallMode, Callable] = {}
|
||||
|
||||
# Device registry
|
||||
self._devices: dict[str, Device] = {}
|
||||
|
||||
# Background tasks (per-call services, receptionist sessions) —
|
||||
# tracked so shutdown can cancel them and GC can't drop them
|
||||
self._tasks: set[asyncio.Task] = set()
|
||||
|
||||
# Startup time
|
||||
self._started_at: Optional[datetime] = None
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, sip_engine: Optional[SIPEngine] = None) -> "AIPSTNGateway":
|
||||
"""Create gateway from environment config."""
|
||||
settings = get_settings()
|
||||
gw = cls(settings=settings)
|
||||
if sip_engine is not None:
|
||||
gw.sip_engine = sip_engine
|
||||
else:
|
||||
gw.sip_engine = _build_sip_engine(settings, gw)
|
||||
return gw
|
||||
def spawn(self, coro, name: str) -> asyncio.Task:
|
||||
"""Launch a tracked background task."""
|
||||
task = asyncio.get_running_loop().create_task(coro, name=name)
|
||||
self._tasks.add(task)
|
||||
task.add_done_callback(self._tasks.discard)
|
||||
return task
|
||||
|
||||
def attach_services(self, tts=None) -> None:
|
||||
"""Attach shared services the gateway must manage on shutdown."""
|
||||
self._tts = tts
|
||||
|
||||
def register_mode_handler(self, mode: CallMode, handler: Callable) -> None:
|
||||
"""Register a launcher called as handler(call, sip_leg_id, call_flow_id)."""
|
||||
self._mode_handlers[mode] = handler
|
||||
|
||||
# ================================================================
|
||||
# Lifecycle
|
||||
# ================================================================
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Boot the gateway — start SIP engine and services."""
|
||||
"""Boot the gateway — start media pipeline and SIP engine."""
|
||||
logger.info("🔥 Starting AI PSTN Gateway...")
|
||||
|
||||
# Start media pipeline first so SIP engine can hand it RTP streams
|
||||
@@ -128,26 +143,7 @@ class AIPSTNGateway:
|
||||
|
||||
# Start SIP engine
|
||||
await self.sip_engine.start()
|
||||
logger.info(f" SIP Engine: ready")
|
||||
|
||||
# Import services here to avoid circular imports
|
||||
from services.audio_classifier import AudioClassifier
|
||||
from services.transcription import TranscriptionService
|
||||
from services.tts import TTSService
|
||||
from services.routing import RoutingService
|
||||
from services.receptionist import ReceptionistService
|
||||
|
||||
self._audio_classifier = AudioClassifier(self.settings.classifier)
|
||||
self._transcription = TranscriptionService(self.settings.speaches)
|
||||
self._tts = TTSService(self.settings.tts)
|
||||
self._routing = RoutingService(self)
|
||||
await self._routing.start()
|
||||
self._receptionist = ReceptionistService(self)
|
||||
|
||||
# Persist completed calls to the database for history/playback.
|
||||
from services.call_persistence import persist_call_on_end
|
||||
|
||||
self.call_manager._on_call_ended = persist_call_on_end
|
||||
logger.info(" SIP Engine: ready")
|
||||
|
||||
self._started_at = datetime.now()
|
||||
|
||||
@@ -176,6 +172,12 @@ class AIPSTNGateway:
|
||||
"""Gracefully shut down."""
|
||||
logger.info("Shutting down AI PSTN Gateway...")
|
||||
|
||||
# Cancel per-call background tasks before tearing down their deps
|
||||
for task in list(self._tasks):
|
||||
task.cancel()
|
||||
if self._tasks:
|
||||
await asyncio.gather(*self._tasks, return_exceptions=True)
|
||||
|
||||
# End all active calls
|
||||
for call_id in list(self.call_manager.active_calls.keys()):
|
||||
call = self.call_manager.get_call(call_id)
|
||||
@@ -264,25 +266,10 @@ class AIPSTNGateway:
|
||||
await self.call_manager.update_status(call.id, CallStatus.FAILED)
|
||||
raise
|
||||
|
||||
# If hold_slayer mode, launch the Hold Slayer service
|
||||
if mode == CallMode.HOLD_SLAYER:
|
||||
from services.hold_slayer import HoldSlayerService
|
||||
|
||||
hold_slayer = HoldSlayerService(
|
||||
gateway=self,
|
||||
call_manager=self.call_manager,
|
||||
sip_engine=self.sip_engine,
|
||||
classifier=self._audio_classifier,
|
||||
transcription=self._transcription,
|
||||
settings=self.settings,
|
||||
tts=self._tts,
|
||||
)
|
||||
# Launch as background task — don't block
|
||||
import asyncio
|
||||
asyncio.create_task(
|
||||
hold_slayer.run(call, sip_leg_id, call_flow_id),
|
||||
name=f"holdslayer_{call.id}",
|
||||
)
|
||||
# Hand off to the registered per-mode launcher (e.g. hold slayer)
|
||||
handler = self._mode_handlers.get(mode)
|
||||
if handler is not None:
|
||||
handler(call, sip_leg_id, call_flow_id)
|
||||
|
||||
return call
|
||||
|
||||
@@ -303,11 +290,14 @@ class AIPSTNGateway:
|
||||
self.call_manager.map_leg(device_leg_id, call_id)
|
||||
|
||||
# Get the original PSTN leg
|
||||
pstn_leg_id = None
|
||||
for leg_id, cid in self.call_manager._call_legs.items():
|
||||
if cid == call_id and leg_id != device_leg_id:
|
||||
pstn_leg_id = leg_id
|
||||
break
|
||||
pstn_leg_id = next(
|
||||
(
|
||||
leg_id
|
||||
for leg_id in self.call_manager.legs_for_call(call_id)
|
||||
if leg_id != device_leg_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if pstn_leg_id:
|
||||
# Bridge the PSTN leg and device leg
|
||||
@@ -324,9 +314,8 @@ class AIPSTNGateway:
|
||||
raise ValueError(f"Call {call_id} not found")
|
||||
|
||||
# Hang up all legs associated with this call
|
||||
for leg_id, cid in list(self.call_manager._call_legs.items()):
|
||||
if cid == call_id:
|
||||
await self.sip_engine.hangup(leg_id)
|
||||
for leg_id in self.call_manager.legs_for_call(call_id):
|
||||
await self.sip_engine.hangup(leg_id)
|
||||
|
||||
await self.call_manager.end_call(call_id)
|
||||
|
||||
@@ -366,6 +355,33 @@ class AIPSTNGateway:
|
||||
if device:
|
||||
logger.info(f"📱 Device unregistered: {device.name}")
|
||||
|
||||
async def _on_sip_leg_state(self, leg_id: str, state: str) -> None:
|
||||
"""
|
||||
SIP leg state change from the engine (already on the loop).
|
||||
|
||||
Maps leg transitions onto call status. Status only moves
|
||||
forward from the dialing phase — hold-slayer/receptionist
|
||||
states (ON_HOLD, NAVIGATING_IVR, …) are never stomped by a
|
||||
late ringing/connected signal from a second leg.
|
||||
"""
|
||||
call = self.call_manager.get_call_for_leg(leg_id)
|
||||
if call is None:
|
||||
return
|
||||
|
||||
if state == "ringing" and call.status == CallStatus.INITIATING:
|
||||
await self.call_manager.update_status(call.id, CallStatus.RINGING)
|
||||
elif state == "connected" and call.status in (
|
||||
CallStatus.INITIATING,
|
||||
CallStatus.RINGING,
|
||||
):
|
||||
await self.call_manager.update_status(call.id, CallStatus.CONNECTED)
|
||||
elif state == "terminated":
|
||||
self.call_manager.unmap_leg(leg_id)
|
||||
# End the call only when its last leg is gone (a transfer
|
||||
# keeps the call alive on the device leg)
|
||||
if not self.call_manager.legs_for_call(call.id):
|
||||
await self.call_manager.end_call(call.id)
|
||||
|
||||
async def _on_sip_device_registered(
|
||||
self, aor: str, contact: str, expires: int
|
||||
) -> None:
|
||||
@@ -425,74 +441,6 @@ class AIPSTNGateway:
|
||||
},
|
||||
))
|
||||
|
||||
async def _on_sip_incoming_call(
|
||||
self, from_uri: str, to_uri: str, leg_id: str
|
||||
) -> None:
|
||||
"""
|
||||
Called by SippyEngine when an inbound INVITE arrives.
|
||||
|
||||
Evaluates routing rules, then either:
|
||||
- Rejects (rule says reject/DND)
|
||||
- Answers + hands off to the AI Receptionist
|
||||
"""
|
||||
import uuid as _uuid
|
||||
from models.call import CallMode, CallStatus
|
||||
from models.routing import RoutingActionType
|
||||
|
||||
caller_number = _extract_number(from_uri)
|
||||
dnis = _extract_number(to_uri)
|
||||
|
||||
# Create a call record so the dashboard sees the ringing call.
|
||||
call = await self.call_manager.create_call(
|
||||
remote_number=caller_number,
|
||||
mode=CallMode.RECEPTIONIST,
|
||||
intent=None,
|
||||
call_flow_id=None,
|
||||
device=None,
|
||||
)
|
||||
# Mark inbound
|
||||
call.direction = "inbound"
|
||||
self.call_manager.map_leg(leg_id, call.id)
|
||||
await self.call_manager.update_status(call.id, CallStatus.RINGING)
|
||||
|
||||
decision = (
|
||||
await self._routing.evaluate(caller_number, dnis)
|
||||
if self._routing is not None
|
||||
else None
|
||||
)
|
||||
|
||||
if decision is not None:
|
||||
await self.event_bus.publish(GatewayEvent(
|
||||
type=EventType.ROUTING_RULE_MATCHED,
|
||||
call_id=call.id,
|
||||
data={
|
||||
"matched_rule_id": decision.matched_rule_id,
|
||||
"matched_rule_name": decision.matched_rule_name,
|
||||
"action": decision.action.type.value,
|
||||
"reason": decision.reason,
|
||||
},
|
||||
message=decision.reason,
|
||||
))
|
||||
|
||||
if decision.action.type in (RoutingActionType.REJECT, RoutingActionType.DND):
|
||||
if hasattr(self.sip_engine, "reject_inbound"):
|
||||
await self.sip_engine.reject_inbound(leg_id)
|
||||
await self.call_manager.end_call(call.id, CallStatus.COMPLETED)
|
||||
return
|
||||
|
||||
# Answer the leg
|
||||
if hasattr(self.sip_engine, "accept_inbound"):
|
||||
await self.sip_engine.accept_inbound(leg_id)
|
||||
await self.call_manager.update_status(call.id, CallStatus.CONNECTED)
|
||||
|
||||
# Hand off to the AI Receptionist
|
||||
if self._receptionist is not None and self.settings.receptionist.enabled:
|
||||
import asyncio as _asyncio
|
||||
_asyncio.create_task(
|
||||
self._receptionist.handle(call, leg_id, decision),
|
||||
name=f"receptionist_{call.id}",
|
||||
)
|
||||
|
||||
def preferred_device(self) -> Optional[Device]:
|
||||
"""Get the highest-priority online device."""
|
||||
online_devices = [
|
||||
|
||||
@@ -52,11 +52,23 @@ class AudioTap:
|
||||
self._buffer: asyncio.Queue[bytes] = asyncio.Queue(maxsize=500)
|
||||
self._active = True
|
||||
self._pjsua2_port = None # PJSUA2 AudioMediaPort for tapping
|
||||
# asyncio.Queue is not thread-safe; feed() hops onto this loop
|
||||
try:
|
||||
self._loop: Optional[asyncio.AbstractEventLoop] = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
self._loop = None
|
||||
|
||||
def feed(self, pcm_data: bytes) -> None:
|
||||
"""Feed PCM audio data into the tap (called from PJSUA2 thread)."""
|
||||
"""Feed PCM audio data into the tap (called from the PJSUA2 thread)."""
|
||||
if not self._active:
|
||||
return
|
||||
if self._loop is not None:
|
||||
self._loop.call_soon_threadsafe(self._enqueue, pcm_data)
|
||||
else:
|
||||
self._enqueue(pcm_data)
|
||||
|
||||
def _enqueue(self, pcm_data: bytes) -> None:
|
||||
"""Queue a frame on the owning loop, dropping oldest on overflow."""
|
||||
try:
|
||||
self._buffer.put_nowait(pcm_data)
|
||||
except asyncio.QueueFull:
|
||||
|
||||
@@ -9,11 +9,22 @@ Architecture:
|
||||
Sippy B2BUA → SIP signaling (call control, registration, DTMF)
|
||||
PJSUA2 → Media anchor (conference bridge, audio tapping, recording)
|
||||
|
||||
Sippy B2BUA runs in its own thread (it has its own event loop).
|
||||
We bridge async/sync via run_in_executor.
|
||||
Thread-ownership rule:
|
||||
- The asyncio loop owns all application-visible state: `_legs`,
|
||||
`_bridges`, `_registered_devices`, `_trunk_registered`, and the
|
||||
media pipeline. The ONLY place that state is mutated is
|
||||
`_on_engine_event`, which runs on the loop.
|
||||
- The Sippy ED thread owns every sippy object (UAs, transactions)
|
||||
plus the `_ed_*` maps. Sippy objects are only touched by code
|
||||
scheduled onto that thread via `_run_on_sippy`.
|
||||
- `leg_id` strings are the only tokens that cross the boundary,
|
||||
carried by `_post_from_ed` (Sippy → loop, via
|
||||
run_coroutine_threadsafe) and `_run_on_sippy` (loop → Sippy, via
|
||||
ED2.callFromThread).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import logging
|
||||
import threading
|
||||
import uuid
|
||||
@@ -30,16 +41,15 @@ logger = logging.getLogger(__name__)
|
||||
# ================================================================
|
||||
|
||||
class SipCallLeg:
|
||||
"""Tracks a single SIP call leg managed by Sippy."""
|
||||
"""Tracks a single SIP call leg. Owned by the asyncio loop."""
|
||||
|
||||
def __init__(self, leg_id: str, direction: str, remote_uri: str):
|
||||
self.leg_id = leg_id
|
||||
self.direction = direction # "outbound" or "inbound"
|
||||
self.remote_uri = remote_uri
|
||||
self.state = "init" # init, trying, ringing, connected, terminated
|
||||
self.sippy_ua = None # Sippy UA object reference
|
||||
self.media_port: Optional[int] = None # PJSUA2 conf bridge port
|
||||
self.dtmf_buffer: list[str] = []
|
||||
self.pending_sdp: Optional[str] = None # inbound INVITE SDP, until answered
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SipCallLeg {self.leg_id} {self.direction} {self.state} → {self.remote_uri}>"
|
||||
@@ -65,75 +75,44 @@ class SippyCallController:
|
||||
"""
|
||||
Handles Sippy B2BUA callbacks for a single call leg.
|
||||
|
||||
Sippy B2BUA uses a callback model — when SIP events happen
|
||||
(180 Ringing, 200 OK, BYE, etc.), the corresponding method
|
||||
is called on this controller.
|
||||
Runs entirely on the Sippy ED thread. It holds only the leg_id
|
||||
token and forwards every state change to the asyncio loop via
|
||||
the engine's event funnel — it never touches loop-owned state.
|
||||
"""
|
||||
|
||||
def __init__(self, leg: SipCallLeg, engine: "SippyEngine"):
|
||||
self.leg = leg
|
||||
def __init__(self, leg_id: str, engine: "SippyEngine"):
|
||||
self.leg_id = leg_id
|
||||
self.engine = engine
|
||||
|
||||
def on_trying(self):
|
||||
"""100 Trying received."""
|
||||
self.leg.state = "trying"
|
||||
logger.debug(f" {self.leg.leg_id}: 100 Trying")
|
||||
logger.debug(f" {self.leg_id}: 100 Trying")
|
||||
self.engine._post_from_ed("leg_state", {"leg_id": self.leg_id, "state": "trying"})
|
||||
|
||||
def on_ringing(self, ringing_code: int = 180):
|
||||
"""180 Ringing / 183 Session Progress received."""
|
||||
self.leg.state = "ringing"
|
||||
logger.info(f" {self.leg.leg_id}: {ringing_code} Ringing")
|
||||
if self.engine._on_leg_state_change:
|
||||
self.engine._loop.call_soon_threadsafe(
|
||||
self.engine._on_leg_state_change, self.leg.leg_id, "ringing"
|
||||
)
|
||||
logger.info(f" {self.leg_id}: {ringing_code} Ringing")
|
||||
self.engine._post_from_ed("leg_state", {"leg_id": self.leg_id, "state": "ringing"})
|
||||
|
||||
def on_connected(self, sdp_body: Optional[str] = None):
|
||||
"""200 OK — call connected, media negotiated."""
|
||||
self.leg.state = "connected"
|
||||
logger.info(f" {self.leg.leg_id}: Connected")
|
||||
|
||||
# Extract remote RTP endpoint from SDP for PJSUA2 media bridge
|
||||
if sdp_body and self.engine.media_pipeline:
|
||||
try:
|
||||
remote_rtp = self.engine._parse_sdp_rtp_endpoint(sdp_body)
|
||||
if remote_rtp:
|
||||
port = self.engine.media_pipeline.add_remote_stream(
|
||||
self.leg.leg_id,
|
||||
remote_rtp["host"],
|
||||
remote_rtp["port"],
|
||||
remote_rtp["codec"],
|
||||
)
|
||||
self.leg.media_port = port
|
||||
except Exception as e:
|
||||
logger.error(f" Failed to set up media for {self.leg.leg_id}: {e}")
|
||||
|
||||
if self.engine._on_leg_state_change:
|
||||
self.engine._loop.call_soon_threadsafe(
|
||||
self.engine._on_leg_state_change, self.leg.leg_id, "connected"
|
||||
)
|
||||
logger.info(f" {self.leg_id}: Connected")
|
||||
self.engine._post_from_ed(
|
||||
"leg_state", {"leg_id": self.leg_id, "state": "connected", "sdp": sdp_body}
|
||||
)
|
||||
|
||||
def on_disconnected(self, reason: str = ""):
|
||||
"""BYE received or call terminated."""
|
||||
self.leg.state = "terminated"
|
||||
logger.info(f" {self.leg.leg_id}: Disconnected ({reason})")
|
||||
|
||||
# Clean up media
|
||||
if self.engine.media_pipeline and self.leg.media_port is not None:
|
||||
try:
|
||||
self.engine.media_pipeline.remove_stream(self.leg.leg_id)
|
||||
except Exception as e:
|
||||
logger.error(f" Failed to clean up media for {self.leg.leg_id}: {e}")
|
||||
|
||||
if self.engine._on_leg_state_change:
|
||||
self.engine._loop.call_soon_threadsafe(
|
||||
self.engine._on_leg_state_change, self.leg.leg_id, "terminated"
|
||||
)
|
||||
logger.info(f" {self.leg_id}: Disconnected ({reason})")
|
||||
self.engine._ed_forget_leg(self.leg_id)
|
||||
self.engine._post_from_ed(
|
||||
"leg_state", {"leg_id": self.leg_id, "state": "terminated", "reason": reason}
|
||||
)
|
||||
|
||||
def on_dtmf(self, digit: str):
|
||||
"""DTMF digit received (RFC 2833 or SIP INFO)."""
|
||||
self.leg.dtmf_buffer.append(digit)
|
||||
logger.debug(f" {self.leg.leg_id}: DTMF '{digit}'")
|
||||
logger.debug(f" {self.leg_id}: DTMF '{digit}'")
|
||||
self.engine._post_from_ed("dtmf", {"leg_id": self.leg_id, "digit": digit})
|
||||
|
||||
|
||||
# ================================================================
|
||||
@@ -190,17 +169,139 @@ class SippyEngine(SIPEngine):
|
||||
self._on_incoming_call = on_incoming_call
|
||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
|
||||
# State
|
||||
# Loop-owned state (mutated only in _on_engine_event and the
|
||||
# async methods below, all of which run on the loop)
|
||||
self._ready = False
|
||||
self._trunk_registered = False
|
||||
self._legs: dict[str, SipCallLeg] = {}
|
||||
self._bridges: dict[str, SipBridge] = {}
|
||||
self._registered_devices: list[dict] = []
|
||||
self._tasks: set[asyncio.Task] = set()
|
||||
|
||||
# ED-thread-owned state: sippy UA objects, only touched from
|
||||
# the Sippy thread (handlers and _run_on_sippy closures)
|
||||
self._ed_ua_to_leg: dict[Any, str] = {}
|
||||
self._ed_leg_to_ua: dict[str, Any] = {}
|
||||
|
||||
# Sippy B2BUA internals (set during start)
|
||||
self._sippy_global_config: dict[str, Any] = {}
|
||||
self._sippy_thread: Optional[threading.Thread] = None
|
||||
|
||||
# ================================================================
|
||||
# Thread-boundary crossing primitives
|
||||
# ================================================================
|
||||
|
||||
def _post_from_ed(self, kind: str, data: dict) -> None:
|
||||
"""Sippy thread → loop: schedule the single state-mutation funnel."""
|
||||
if self._loop is None:
|
||||
return
|
||||
asyncio.run_coroutine_threadsafe(self._on_engine_event(kind, data), self._loop)
|
||||
|
||||
def _run_on_sippy(self, fn: Callable[[], None]) -> None:
|
||||
"""Loop → Sippy thread: run fn where the sippy objects live."""
|
||||
try:
|
||||
from sippy.Core.EventDispatcher import ED2
|
||||
except ImportError:
|
||||
# Simulation mode — no sippy, no ED thread; run inline.
|
||||
fn()
|
||||
return
|
||||
ED2.callFromThread(fn)
|
||||
|
||||
def _ed_forget_leg(self, leg_id: str) -> None:
|
||||
"""Drop the ED-side UA maps for a leg (Sippy thread only)."""
|
||||
ua = self._ed_leg_to_ua.pop(leg_id, None)
|
||||
if ua is not None:
|
||||
self._ed_ua_to_leg.pop(ua, None)
|
||||
|
||||
def _spawn(self, coro, name: str) -> None:
|
||||
"""Track a background task so shutdown can cancel it."""
|
||||
task = asyncio.get_running_loop().create_task(coro, name=name)
|
||||
self._tasks.add(task)
|
||||
task.add_done_callback(self._tasks.discard)
|
||||
|
||||
async def _on_engine_event(self, kind: str, data: dict) -> None:
|
||||
"""
|
||||
The single funnel where Sippy-thread events mutate loop-owned
|
||||
state. Everything here runs on the asyncio loop.
|
||||
"""
|
||||
if kind == "leg_state":
|
||||
leg = self._legs.get(data["leg_id"])
|
||||
if leg is None:
|
||||
return
|
||||
state = data["state"]
|
||||
leg.state = state
|
||||
|
||||
if state == "connected":
|
||||
sdp = data.get("sdp")
|
||||
if sdp and self.media_pipeline:
|
||||
try:
|
||||
remote_rtp = self._parse_sdp_rtp_endpoint(sdp)
|
||||
if remote_rtp:
|
||||
leg.media_port = self.media_pipeline.add_remote_stream(
|
||||
leg.leg_id,
|
||||
remote_rtp["host"],
|
||||
remote_rtp["port"],
|
||||
remote_rtp["codec"],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f" Failed to set up media for {leg.leg_id}: {e}")
|
||||
elif state == "terminated":
|
||||
if self.media_pipeline and leg.media_port is not None:
|
||||
try:
|
||||
self.media_pipeline.remove_stream(leg.leg_id)
|
||||
except Exception as e:
|
||||
logger.error(f" Failed to clean up media for {leg.leg_id}: {e}")
|
||||
leg.media_port = None
|
||||
|
||||
if self._on_leg_state_change:
|
||||
result = self._on_leg_state_change(leg.leg_id, state)
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
|
||||
elif kind == "incoming_invite":
|
||||
leg = SipCallLeg(data["leg_id"], "inbound", data["from_uri"])
|
||||
leg.pending_sdp = data.get("sdp")
|
||||
self._legs[leg.leg_id] = leg
|
||||
if self._on_incoming_call:
|
||||
self._spawn(
|
||||
self._on_incoming_call(data["from_uri"], data["to_uri"], leg.leg_id),
|
||||
name=f"incoming_{leg.leg_id}",
|
||||
)
|
||||
else:
|
||||
# No routing wired — preserve the historical auto-answer
|
||||
await self.accept_inbound(leg.leg_id)
|
||||
|
||||
elif kind == "register":
|
||||
existing = next(
|
||||
(d for d in self._registered_devices if d.get("aor") == data["aor"]),
|
||||
None,
|
||||
)
|
||||
if existing:
|
||||
existing["contact"] = data["contact"]
|
||||
existing["expires"] = data["expires"]
|
||||
else:
|
||||
self._registered_devices.append({
|
||||
"aor": data["aor"],
|
||||
"contact": data["contact"],
|
||||
"expires": data["expires"],
|
||||
})
|
||||
if self._on_device_registered:
|
||||
await self._on_device_registered(
|
||||
data["aor"], data["contact"], data["expires"]
|
||||
)
|
||||
|
||||
elif kind == "deregister":
|
||||
self._registered_devices = [
|
||||
d for d in self._registered_devices if d.get("aor") != data["aor"]
|
||||
]
|
||||
|
||||
elif kind == "dtmf":
|
||||
# Received DTMF has no consumer yet; log until one exists
|
||||
logger.info(f" DTMF '{data['digit']}' received on {data['leg_id']}")
|
||||
|
||||
elif kind == "trunk_registered":
|
||||
self._trunk_registered = data["registered"]
|
||||
|
||||
# ================================================================
|
||||
# Lifecycle
|
||||
# ================================================================
|
||||
@@ -212,7 +313,6 @@ class SippyEngine(SIPEngine):
|
||||
|
||||
try:
|
||||
from sippy.SipConf import SipConf
|
||||
from sippy.SipTransactionManager import SipTransactionManager
|
||||
|
||||
# Configure Sippy
|
||||
SipConf.my_address = self._sip_address
|
||||
@@ -226,7 +326,7 @@ class SippyEngine(SIPEngine):
|
||||
}
|
||||
|
||||
# Start Sippy's SIP transaction manager in a background thread
|
||||
# Sippy uses its own event loop (Twisted reactor or custom loop)
|
||||
# Sippy uses its own event loop (the ED2 event dispatcher)
|
||||
self._sippy_thread = threading.Thread(
|
||||
target=self._run_sippy_loop,
|
||||
name="sippy-b2bua",
|
||||
@@ -254,8 +354,8 @@ class SippyEngine(SIPEngine):
|
||||
def _run_sippy_loop(self):
|
||||
"""Run Sippy B2BUA's event loop in a dedicated thread."""
|
||||
try:
|
||||
from sippy.Core.EventDispatcher import ED2
|
||||
from sippy.SipTransactionManager import SipTransactionManager
|
||||
from sippy.Timeout import Timeout
|
||||
|
||||
# Initialize Sippy's transaction manager
|
||||
stm = SipTransactionManager(self._sippy_global_config, self._handle_sippy_request)
|
||||
@@ -263,11 +363,9 @@ class SippyEngine(SIPEngine):
|
||||
|
||||
logger.info(" Sippy transaction manager started")
|
||||
|
||||
# Sippy will block here in its event loop
|
||||
# For the Twisted-based version, this runs the reactor
|
||||
# For the asyncore version, this runs asyncore.loop()
|
||||
from sippy.Core.EventDispatcher import ED
|
||||
ED.loop()
|
||||
# Sippy blocks here dispatching its event loop; callbacks
|
||||
# injected via ED2.callFromThread run inside this loop.
|
||||
ED2.loop()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f" Sippy event loop crashed: {e}")
|
||||
@@ -294,10 +392,9 @@ class SippyEngine(SIPEngine):
|
||||
"""
|
||||
Handle an incoming SIP REGISTER from a phone or softphone.
|
||||
|
||||
Extracts the AOR (address of record) from the To header, records
|
||||
the contact and expiry, and sends a 200 OK. The gateway's
|
||||
register_device() is called asynchronously via the event loop so
|
||||
the phone gets an extension and SIP URI assigned automatically.
|
||||
Runs on the Sippy thread: parses the request, replies 200 OK,
|
||||
and posts the registration to the loop funnel, which owns the
|
||||
device list and notifies the gateway.
|
||||
"""
|
||||
try:
|
||||
to_uri = str(req.getHFBody("to").getUri())
|
||||
@@ -309,34 +406,13 @@ class SippyEngine(SIPEngine):
|
||||
logger.info(f" SIP REGISTER: {to_uri} contact={contact_uri} expires={expires}")
|
||||
|
||||
if expires == 0:
|
||||
# De-registration
|
||||
self._registered_devices = [
|
||||
d for d in self._registered_devices
|
||||
if d.get("aor") != to_uri
|
||||
]
|
||||
logger.info(f" De-registered: {to_uri}")
|
||||
self._post_from_ed("deregister", {"aor": to_uri})
|
||||
else:
|
||||
# Update or add registration record
|
||||
existing = next(
|
||||
(d for d in self._registered_devices if d.get("aor") == to_uri),
|
||||
None,
|
||||
)
|
||||
if existing:
|
||||
existing["contact"] = contact_uri
|
||||
existing["expires"] = expires
|
||||
else:
|
||||
self._registered_devices.append({
|
||||
"aor": to_uri,
|
||||
"contact": contact_uri,
|
||||
"expires": expires,
|
||||
})
|
||||
|
||||
# Notify the gateway (async) so it can assign an extension
|
||||
if self._loop:
|
||||
self._loop.call_soon_threadsafe(
|
||||
self._loop.create_task,
|
||||
self._notify_registration(to_uri, contact_uri, expires),
|
||||
)
|
||||
self._post_from_ed("register", {
|
||||
"aor": to_uri,
|
||||
"contact": contact_uri,
|
||||
"expires": expires,
|
||||
})
|
||||
|
||||
# Reply 200 OK
|
||||
req.sendResponse(200, "OK")
|
||||
@@ -348,52 +424,41 @@ class SippyEngine(SIPEngine):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _notify_registration(self, aor: str, contact: str, expires: int):
|
||||
"""
|
||||
Async callback: tell the gateway about the newly registered device
|
||||
so it can assign an extension if needed.
|
||||
"""
|
||||
if self._on_device_registered:
|
||||
await self._on_device_registered(aor, contact, expires)
|
||||
|
||||
def _handle_incoming_invite(self, req, sip_t):
|
||||
"""Handle an incoming INVITE — create inbound call leg.
|
||||
"""Handle an incoming INVITE — surface an inbound call leg.
|
||||
|
||||
The gateway is notified via `on_incoming_call`; it decides
|
||||
whether to answer (via `accept_inbound`) or reject the leg
|
||||
based on routing rules.
|
||||
Runs on the Sippy thread: extracts everything the loop needs
|
||||
(URIs, SDP body) as plain strings and posts them. The gateway
|
||||
decides whether to answer (via `accept_inbound`) or reject.
|
||||
"""
|
||||
from_uri = str(req.getHFBody("from").getUri())
|
||||
to_uri = str(req.getHFBody("to").getUri())
|
||||
sdp = str(req.getBody()) if req.getBody() else None
|
||||
|
||||
leg_id = f"leg_{uuid.uuid4().hex[:12]}"
|
||||
leg = SipCallLeg(leg_id, "inbound", from_uri)
|
||||
leg.sippy_ua = sip_t.ua if hasattr(sip_t, "ua") else None
|
||||
leg.pending_invite = req
|
||||
self._legs[leg_id] = leg
|
||||
ua = sip_t.ua if hasattr(sip_t, "ua") else None
|
||||
if ua is not None:
|
||||
self._ed_ua_to_leg[ua] = leg_id
|
||||
self._ed_leg_to_ua[leg_id] = ua
|
||||
|
||||
logger.info(f" Incoming call: {from_uri} → {to_uri} (leg: {leg_id})")
|
||||
|
||||
# Surface to the gateway. If no callback is wired, fall back to
|
||||
# auto-answer so we don't regress the previous behavior.
|
||||
if self._on_incoming_call and self._loop:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self._on_incoming_call(from_uri, to_uri, leg_id),
|
||||
self._loop,
|
||||
)
|
||||
else:
|
||||
controller = SippyCallController(leg, self)
|
||||
controller.on_connected(str(req.getBody()) if req.getBody() else None)
|
||||
self._post_from_ed("incoming_invite", {
|
||||
"leg_id": leg_id,
|
||||
"from_uri": from_uri,
|
||||
"to_uri": to_uri,
|
||||
"sdp": sdp,
|
||||
})
|
||||
|
||||
async def accept_inbound(self, leg_id: str) -> bool:
|
||||
"""Answer a previously-surfaced inbound INVITE."""
|
||||
leg = self._legs.get(leg_id)
|
||||
if not leg or leg.direction != "inbound":
|
||||
return False
|
||||
req = getattr(leg, "pending_invite", None)
|
||||
controller = SippyCallController(leg, self)
|
||||
body = str(req.getBody()) if req and req.getBody() else None
|
||||
controller.on_connected(body)
|
||||
sdp, leg.pending_sdp = leg.pending_sdp, None
|
||||
await self._on_engine_event(
|
||||
"leg_state", {"leg_id": leg_id, "state": "connected", "sdp": sdp}
|
||||
)
|
||||
return True
|
||||
|
||||
async def reject_inbound(self, leg_id: str, code: int = 603, reason: str = "Decline") -> bool:
|
||||
@@ -404,66 +469,66 @@ class SippyEngine(SIPEngine):
|
||||
logger.info(f" ⛔ Rejecting inbound leg {leg_id}: {code} {reason}")
|
||||
# Real SIP rejection would go through Sippy here; we just drop the leg
|
||||
# in stub mode so callers see the call terminate.
|
||||
self._run_on_sippy(lambda: self._ed_forget_leg(leg_id))
|
||||
return True
|
||||
|
||||
def _handle_incoming_bye(self, req, sip_t):
|
||||
"""Handle incoming BYE — tear down call leg."""
|
||||
# Find the leg by Sippy's UA object
|
||||
for leg in self._legs.values():
|
||||
if leg.sippy_ua and hasattr(sip_t, "ua") and leg.sippy_ua == sip_t.ua:
|
||||
controller = SippyCallController(leg, self)
|
||||
controller.on_disconnected("BYE received")
|
||||
break
|
||||
"""Handle incoming BYE — tear down call leg (Sippy thread)."""
|
||||
ua = sip_t.ua if hasattr(sip_t, "ua") else None
|
||||
leg_id = self._ed_ua_to_leg.get(ua) if ua is not None else None
|
||||
if leg_id:
|
||||
SippyCallController(leg_id, self).on_disconnected("BYE received")
|
||||
|
||||
def _handle_incoming_info(self, req, sip_t):
|
||||
"""Handle SIP INFO (DTMF via SIP INFO method)."""
|
||||
"""Handle SIP INFO (DTMF via SIP INFO method) on the Sippy thread."""
|
||||
body = str(req.getBody()) if req.getBody() else ""
|
||||
if "dtmf" in body.lower() or "Signal=" in body:
|
||||
# Extract DTMF digit from SIP INFO body
|
||||
ua = sip_t.ua if hasattr(sip_t, "ua") else None
|
||||
leg_id = self._ed_ua_to_leg.get(ua) if ua is not None else None
|
||||
if not leg_id:
|
||||
return
|
||||
for line in body.split("\n"):
|
||||
if line.startswith("Signal="):
|
||||
digit = line.split("=")[1].strip()
|
||||
for leg in self._legs.values():
|
||||
if leg.sippy_ua and hasattr(sip_t, "ua") and leg.sippy_ua == sip_t.ua:
|
||||
controller = SippyCallController(leg, self)
|
||||
controller.on_dtmf(digit)
|
||||
break
|
||||
SippyCallController(leg_id, self).on_dtmf(digit)
|
||||
|
||||
async def _register_trunk(self) -> None:
|
||||
"""Register with the SIP trunk provider."""
|
||||
try:
|
||||
from sippy.UA import UA
|
||||
from sippy.SipRegistrationAgent import SipRegistrationAgent
|
||||
logger.info(f" Registering with trunk: {self._trunk_host}:{self._trunk_port}")
|
||||
|
||||
logger.info(f" Registering with trunk: {self._trunk_host}:{self._trunk_port}")
|
||||
def do_register():
|
||||
try:
|
||||
from sippy.SipRegistrationAgent import SipRegistrationAgent
|
||||
|
||||
# Run registration in Sippy's thread
|
||||
def do_register():
|
||||
try:
|
||||
reg_agent = SipRegistrationAgent(
|
||||
self._sippy_global_config,
|
||||
f"sip:{self._trunk_username}@{self._trunk_host}",
|
||||
f"sip:{self._trunk_host}:{self._trunk_port}",
|
||||
auth_name=self._trunk_username,
|
||||
auth_password=self._trunk_password,
|
||||
)
|
||||
reg_agent.register()
|
||||
self._trunk_registered = True
|
||||
logger.info(" ✅ Trunk registration sent")
|
||||
except Exception as e:
|
||||
logger.error(f" ❌ Trunk registration failed: {e}")
|
||||
self._trunk_registered = False
|
||||
reg_agent = SipRegistrationAgent(
|
||||
self._sippy_global_config,
|
||||
f"sip:{self._trunk_username}@{self._trunk_host}",
|
||||
f"sip:{self._trunk_host}:{self._trunk_port}",
|
||||
auth_name=self._trunk_username,
|
||||
auth_password=self._trunk_password,
|
||||
)
|
||||
reg_agent.register()
|
||||
logger.info(" ✅ Trunk registration sent")
|
||||
self._post_from_ed("trunk_registered", {"registered": True})
|
||||
except ImportError:
|
||||
logger.warning(" Sippy registration agent not available")
|
||||
self._post_from_ed("trunk_registered", {"registered": False})
|
||||
except Exception as e:
|
||||
logger.error(f" ❌ Trunk registration failed: {e}")
|
||||
self._post_from_ed("trunk_registered", {"registered": False})
|
||||
|
||||
await asyncio.get_event_loop().run_in_executor(None, do_register)
|
||||
|
||||
except ImportError:
|
||||
logger.warning(" Sippy registration agent not available")
|
||||
self._trunk_registered = False
|
||||
self._run_on_sippy(do_register)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Gracefully shut down the SIP engine."""
|
||||
logger.info("🔌 Stopping Sippy B2BUA...")
|
||||
|
||||
# Cancel in-flight incoming-call dispatch tasks
|
||||
for task in list(self._tasks):
|
||||
task.cancel()
|
||||
if self._tasks:
|
||||
await asyncio.gather(*self._tasks, return_exceptions=True)
|
||||
|
||||
# Hang up all active legs
|
||||
for leg_id in list(self._legs.keys()):
|
||||
try:
|
||||
@@ -473,8 +538,8 @@ class SippyEngine(SIPEngine):
|
||||
|
||||
# Stop Sippy's event loop
|
||||
try:
|
||||
from sippy.Core.EventDispatcher import ED
|
||||
ED.breakLoop()
|
||||
from sippy.Core.EventDispatcher import ED2
|
||||
ED2.breakLoop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -512,14 +577,16 @@ class SippyEngine(SIPEngine):
|
||||
|
||||
logger.info(f"📞 Placing call: {from_uri} → {remote_uri} (leg: {leg_id})")
|
||||
|
||||
# Place the call via Sippy
|
||||
# Generate SDP on the loop (allocate_rtp_port is lock-protected)
|
||||
sdp_body = self._generate_sdp(leg_id)
|
||||
|
||||
def do_invite():
|
||||
try:
|
||||
from sippy.UA import UA
|
||||
from sippy.SipCallId import SipCallId
|
||||
from sippy.CCEvents import CCEventTry
|
||||
from sippy.SipCallId import SipCallId
|
||||
from sippy.UA import UA
|
||||
|
||||
controller = SippyCallController(leg, self)
|
||||
controller = SippyCallController(leg_id, self)
|
||||
|
||||
# Create Sippy UA for this call
|
||||
ua = UA(
|
||||
@@ -527,10 +594,8 @@ class SippyEngine(SIPEngine):
|
||||
event_cb=controller,
|
||||
nh_address=(self._trunk_host, self._trunk_port),
|
||||
)
|
||||
leg.sippy_ua = ua
|
||||
|
||||
# Generate SDP for the call
|
||||
sdp_body = self._generate_sdp(leg_id)
|
||||
self._ed_leg_to_ua[leg_id] = ua
|
||||
self._ed_ua_to_leg[ua] = leg_id
|
||||
|
||||
# Send INVITE
|
||||
event = CCEventTry(
|
||||
@@ -539,19 +604,19 @@ class SippyEngine(SIPEngine):
|
||||
)
|
||||
ua.recvEvent(event)
|
||||
|
||||
leg.state = "trying"
|
||||
logger.info(f" INVITE sent for {leg_id}")
|
||||
self._post_from_ed("leg_state", {"leg_id": leg_id, "state": "trying"})
|
||||
|
||||
except ImportError:
|
||||
# Sippy not installed — simulate for development
|
||||
logger.warning(f" Sippy not installed, simulating call for {leg_id}")
|
||||
leg.state = "ringing"
|
||||
self._post_from_ed("leg_state", {"leg_id": leg_id, "state": "ringing"})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f" Failed to send INVITE for {leg_id}: {e}")
|
||||
leg.state = "terminated"
|
||||
self._post_from_ed("leg_state", {"leg_id": leg_id, "state": "terminated"})
|
||||
|
||||
await asyncio.get_event_loop().run_in_executor(None, do_invite)
|
||||
self._run_on_sippy(do_invite)
|
||||
return leg_id
|
||||
|
||||
async def hangup(self, call_leg_id: str) -> None:
|
||||
@@ -563,15 +628,18 @@ class SippyEngine(SIPEngine):
|
||||
|
||||
def do_bye():
|
||||
try:
|
||||
if leg.sippy_ua:
|
||||
ua = self._ed_leg_to_ua.get(call_leg_id)
|
||||
if ua is not None:
|
||||
from sippy.CCEvents import CCEventDisconnect
|
||||
leg.sippy_ua.recvEvent(CCEventDisconnect())
|
||||
ua.recvEvent(CCEventDisconnect())
|
||||
except Exception as e:
|
||||
logger.error(f" Error sending BYE for {call_leg_id}: {e}")
|
||||
finally:
|
||||
leg.state = "terminated"
|
||||
self._ed_forget_leg(call_leg_id)
|
||||
|
||||
await asyncio.get_event_loop().run_in_executor(None, do_bye)
|
||||
self._run_on_sippy(do_bye)
|
||||
|
||||
leg.state = "terminated"
|
||||
|
||||
# Clean up media
|
||||
if self.media_pipeline and leg.media_port is not None:
|
||||
@@ -595,13 +663,13 @@ class SippyEngine(SIPEngine):
|
||||
|
||||
def do_dtmf():
|
||||
try:
|
||||
if leg.sippy_ua:
|
||||
# Send via RFC 2833 (in-band RTP event)
|
||||
# Sippy handles this through the UA's DTMF sender
|
||||
ua = self._ed_leg_to_ua.get(call_leg_id)
|
||||
if ua is not None:
|
||||
# Send via SIP INFO through the UA
|
||||
from sippy.CCEvents import CCEventInfo
|
||||
for digit in digits:
|
||||
from sippy.CCEvents import CCEventInfo
|
||||
body = f"Signal={digit}\r\nDuration=160\r\n"
|
||||
leg.sippy_ua.recvEvent(CCEventInfo(body=body))
|
||||
ua.recvEvent(CCEventInfo(body=body))
|
||||
else:
|
||||
logger.warning(f" No UA for {call_leg_id}, DTMF not sent")
|
||||
except ImportError:
|
||||
@@ -609,7 +677,7 @@ class SippyEngine(SIPEngine):
|
||||
except Exception as e:
|
||||
logger.error(f" DTMF send error: {e}")
|
||||
|
||||
await asyncio.get_event_loop().run_in_executor(None, do_dtmf)
|
||||
self._run_on_sippy(do_dtmf)
|
||||
|
||||
# ================================================================
|
||||
# Device Calls (for transfer)
|
||||
@@ -638,13 +706,15 @@ class SippyEngine(SIPEngine):
|
||||
|
||||
logger.info(f"📱 Calling device: {device.name} ({device.sip_uri}) (leg: {leg_id})")
|
||||
|
||||
sdp_body = self._generate_sdp(leg_id)
|
||||
|
||||
def do_invite_device():
|
||||
try:
|
||||
from sippy.UA import UA
|
||||
from sippy.CCEvents import CCEventTry
|
||||
from sippy.SipCallId import SipCallId
|
||||
from sippy.UA import UA
|
||||
|
||||
controller = SippyCallController(leg, self)
|
||||
controller = SippyCallController(leg_id, self)
|
||||
|
||||
# Parse device SIP URI for routing
|
||||
# sip:robert@192.168.1.100:5060
|
||||
@@ -662,25 +732,24 @@ class SippyEngine(SIPEngine):
|
||||
event_cb=controller,
|
||||
nh_address=(host, port),
|
||||
)
|
||||
leg.sippy_ua = ua
|
||||
|
||||
sdp_body = self._generate_sdp(leg_id)
|
||||
self._ed_leg_to_ua[leg_id] = ua
|
||||
self._ed_ua_to_leg[ua] = leg_id
|
||||
|
||||
event = CCEventTry(
|
||||
(SipCallId(), f"sip:gateway@{self._domain}", device.sip_uri),
|
||||
body=sdp_body,
|
||||
)
|
||||
ua.recvEvent(event)
|
||||
leg.state = "trying"
|
||||
self._post_from_ed("leg_state", {"leg_id": leg_id, "state": "trying"})
|
||||
|
||||
except ImportError:
|
||||
logger.warning(f" Sippy not installed, simulating device call for {leg_id}")
|
||||
leg.state = "ringing"
|
||||
self._post_from_ed("leg_state", {"leg_id": leg_id, "state": "ringing"})
|
||||
except Exception as e:
|
||||
logger.error(f" Failed to call device {device.name}: {e}")
|
||||
leg.state = "terminated"
|
||||
self._post_from_ed("leg_state", {"leg_id": leg_id, "state": "terminated"})
|
||||
|
||||
await asyncio.get_event_loop().run_in_executor(None, do_invite_device)
|
||||
self._run_on_sippy(do_invite_device)
|
||||
return leg_id
|
||||
|
||||
# ================================================================
|
||||
|
||||
@@ -4,7 +4,9 @@ Database connection and session management.
|
||||
PostgreSQL via asyncpg + SQLAlchemy async.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
@@ -49,7 +51,6 @@ class CallRecord(Base):
|
||||
hold_time = Column(Integer, default=0) # seconds spent on hold
|
||||
device_used = Column(String)
|
||||
recording_path = Column(String, nullable=True)
|
||||
transcript = Column(Text, nullable=True)
|
||||
summary = Column(Text, nullable=True)
|
||||
action_items = Column(JSON, nullable=True)
|
||||
sentiment = Column(String, nullable=True)
|
||||
@@ -83,24 +84,6 @@ class StoredCallFlow(Base):
|
||||
return f"<StoredCallFlow {self.id} {self.phone_number}>"
|
||||
|
||||
|
||||
class Contact(Base):
|
||||
__tablename__ = "contacts"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
name = Column(String, nullable=False)
|
||||
phone_numbers = Column(JSON, nullable=False) # [{number, label, primary}, ...]
|
||||
category = Column(String) # personal / business / service
|
||||
routing_preference = Column(String, nullable=True) # how to handle their calls
|
||||
notes = Column(Text, nullable=True)
|
||||
call_count = Column(Integer, default=0)
|
||||
last_call = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Contact {self.id} {self.name}>"
|
||||
|
||||
|
||||
class Device(Base):
|
||||
__tablename__ = "devices"
|
||||
|
||||
@@ -110,7 +93,7 @@ class Device(Base):
|
||||
sip_uri = Column(String, nullable=True) # sip:robert@gateway.helu.ca
|
||||
phone_number = Column(String, nullable=True) # For PSTN devices
|
||||
priority = Column(Integer, default=10) # Routing priority (lower = higher priority)
|
||||
is_online = Column(String, default="false")
|
||||
is_online = Column(Boolean, default=False, nullable=False)
|
||||
capabilities = Column(JSON, default=list) # ["voice", "video", "sms"]
|
||||
dnd = Column(Boolean, default=False, nullable=False)
|
||||
last_seen = Column(DateTime, nullable=True)
|
||||
@@ -204,8 +187,13 @@ def get_session_factory() -> async_sessionmaker[AsyncSession]:
|
||||
return _session_factory
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
"""Dependency: yield an async database session."""
|
||||
@asynccontextmanager
|
||||
async def session_scope() -> AsyncIterator[AsyncSession]:
|
||||
"""A commit-on-success session — the one session-lifecycle convention.
|
||||
|
||||
REST handlers get it via the get_db dependency; services and MCP
|
||||
tools use it directly.
|
||||
"""
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
try:
|
||||
@@ -216,11 +204,37 @@ async def get_db() -> AsyncSession:
|
||||
raise
|
||||
|
||||
|
||||
async def get_db() -> AsyncIterator[AsyncSession]:
|
||||
"""FastAPI dependency: yield an async database session."""
|
||||
async with session_scope() as session:
|
||||
yield session
|
||||
|
||||
|
||||
# The autogenerated baseline revision — a schema created by the old
|
||||
# create_all path is identical to it, so such databases are stamped
|
||||
# here and then migrated forward like any other.
|
||||
_BASELINE_REVISION = "1173a71329ed"
|
||||
|
||||
|
||||
def _upgrade_to_head(connection) -> None:
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import inspect
|
||||
|
||||
cfg = Config(str(Path(__file__).resolve().parent.parent / "alembic.ini"))
|
||||
cfg.attributes["connection"] = connection
|
||||
|
||||
inspector = inspect(connection)
|
||||
if not inspector.has_table("alembic_version") and inspector.has_table("call_records"):
|
||||
command.stamp(cfg, _BASELINE_REVISION)
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
|
||||
async def init_db():
|
||||
"""Create all tables. For development; use Alembic migrations in production."""
|
||||
"""Bring the schema to Alembic head (tests create tables directly)."""
|
||||
engine = get_engine()
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
await conn.run_sync(_upgrade_to_head)
|
||||
|
||||
|
||||
async def close_db():
|
||||
|
||||
68
db/migrations/env.py
Normal file
68
db/migrations/env.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
Alembic environment — async engine against Base.metadata.
|
||||
|
||||
Two entry paths:
|
||||
- CLI (``alembic upgrade head``): builds an async engine from
|
||||
Settings.database_url and runs migrations on it.
|
||||
- App startup (db.database.init_db): passes an already-open
|
||||
connection via ``config.attributes["connection"]`` so migrations
|
||||
run inside the app's engine instead of opening a second one.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from config import get_settings
|
||||
from db.database import Base
|
||||
|
||||
config = context.config
|
||||
|
||||
# Only configure logging on standalone CLI runs — inside the app this
|
||||
# would clobber uvicorn's logger setup.
|
||||
if config.config_file_name is not None and config.attributes.get("connection") is None:
|
||||
fileConfig(config.config_file_name, disable_existing_loggers=False)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Emit SQL to stdout without a live connection (--sql mode)."""
|
||||
context.configure(
|
||||
url=get_settings().database_url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection) -> None:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
engine = create_async_engine(get_settings().database_url, poolclass=pool.NullPool)
|
||||
async with engine.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connection = config.attributes.get("connection")
|
||||
if connection is not None:
|
||||
do_run_migrations(connection)
|
||||
else:
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
26
db/migrations/script.py.mako
Normal file
26
db/migrations/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
129
db/migrations/versions/1173a71329ed_baseline_schema.py
Normal file
129
db/migrations/versions/1173a71329ed_baseline_schema.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""baseline schema
|
||||
|
||||
Revision ID: 1173a71329ed
|
||||
Revises:
|
||||
Create Date: 2026-07-10 07:19:08.321778
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '1173a71329ed'
|
||||
down_revision: Union[str, None] = None
|
||||
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.create_table('call_flows',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('phone_number', sa.String(), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('steps', sa.JSON(), nullable=False),
|
||||
sa.Column('last_verified', sa.DateTime(), nullable=True),
|
||||
sa.Column('avg_hold_time', sa.Integer(), nullable=True),
|
||||
sa.Column('success_rate', sa.Float(), nullable=True),
|
||||
sa.Column('times_used', sa.Integer(), nullable=True),
|
||||
sa.Column('last_used', sa.DateTime(), nullable=True),
|
||||
sa.Column('notes', sa.Text(), nullable=True),
|
||||
sa.Column('tags', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_call_flows_phone_number'), 'call_flows', ['phone_number'], unique=False)
|
||||
op.create_table('call_records',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('direction', sa.String(), nullable=False),
|
||||
sa.Column('remote_number', sa.String(), nullable=False),
|
||||
sa.Column('status', sa.String(), nullable=False),
|
||||
sa.Column('mode', sa.String(), nullable=False),
|
||||
sa.Column('intent', sa.Text(), nullable=True),
|
||||
sa.Column('started_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('ended_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('duration', sa.Integer(), nullable=True),
|
||||
sa.Column('hold_time', sa.Integer(), nullable=True),
|
||||
sa.Column('device_used', sa.String(), nullable=True),
|
||||
sa.Column('recording_path', sa.String(), nullable=True),
|
||||
sa.Column('transcript', sa.Text(), nullable=True),
|
||||
sa.Column('summary', sa.Text(), nullable=True),
|
||||
sa.Column('action_items', sa.JSON(), nullable=True),
|
||||
sa.Column('sentiment', sa.String(), nullable=True),
|
||||
sa.Column('call_flow_id', sa.String(), nullable=True),
|
||||
sa.Column('classification_timeline', sa.JSON(), nullable=True),
|
||||
sa.Column('metadata', sa.JSON(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_call_records_remote_number'), 'call_records', ['remote_number'], unique=False)
|
||||
op.create_table('devices',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('type', sa.String(), nullable=False),
|
||||
sa.Column('sip_uri', sa.String(), nullable=True),
|
||||
sa.Column('phone_number', sa.String(), nullable=True),
|
||||
sa.Column('priority', sa.Integer(), nullable=True),
|
||||
sa.Column('is_online', sa.String(), nullable=True),
|
||||
sa.Column('capabilities', sa.JSON(), nullable=True),
|
||||
sa.Column('dnd', sa.Boolean(), nullable=False),
|
||||
sa.Column('last_seen', sa.DateTime(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('recordings',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('call_id', sa.String(), nullable=False),
|
||||
sa.Column('path', sa.String(), nullable=False),
|
||||
sa.Column('format', sa.String(), nullable=True),
|
||||
sa.Column('duration_s', sa.Float(), nullable=True),
|
||||
sa.Column('size_bytes', sa.Integer(), nullable=True),
|
||||
sa.Column('channels', sa.Integer(), nullable=True),
|
||||
sa.Column('started_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('ended_at', sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_recordings_call_id'), 'recordings', ['call_id'], unique=False)
|
||||
op.create_table('routing_rules',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('priority', sa.Integer(), nullable=False),
|
||||
sa.Column('enabled', sa.Boolean(), nullable=False),
|
||||
sa.Column('match', sa.JSON(), nullable=False),
|
||||
sa.Column('action', sa.JSON(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('transcript_chunks',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('call_id', sa.String(), nullable=False),
|
||||
sa.Column('seq', sa.Integer(), nullable=False),
|
||||
sa.Column('t_offset_ms', sa.Integer(), nullable=True),
|
||||
sa.Column('speaker', sa.String(), nullable=True),
|
||||
sa.Column('text', sa.Text(), nullable=False),
|
||||
sa.Column('confidence', sa.Float(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_transcript_chunks_call_id'), 'transcript_chunks', ['call_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_transcript_chunks_call_id'), table_name='transcript_chunks')
|
||||
op.drop_table('transcript_chunks')
|
||||
op.drop_table('routing_rules')
|
||||
op.drop_index(op.f('ix_recordings_call_id'), table_name='recordings')
|
||||
op.drop_table('recordings')
|
||||
op.drop_table('devices')
|
||||
op.drop_index(op.f('ix_call_records_remote_number'), table_name='call_records')
|
||||
op.drop_table('call_records')
|
||||
op.drop_index(op.f('ix_call_flows_phone_number'), table_name='call_flows')
|
||||
op.drop_table('call_flows')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,45 @@
|
||||
"""drop dead transcript column, boolean is_online
|
||||
|
||||
Revision ID: 5187577efc23
|
||||
Revises: 1173a71329ed
|
||||
Create Date: 2026-07-10 07:19:40.741327
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '5187577efc23'
|
||||
down_revision: Union[str, None] = '1173a71329ed'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Transcript text lives solely in transcript_chunks rows now.
|
||||
op.drop_column('call_records', 'transcript')
|
||||
|
||||
# String "true"/"false" (or NULL) -> real boolean; NULLs become false.
|
||||
# batch mode so the table-recreate path works on SQLite too.
|
||||
with op.batch_alter_table('devices') as batch_op:
|
||||
batch_op.alter_column(
|
||||
'is_online',
|
||||
existing_type=sa.VARCHAR(),
|
||||
type_=sa.Boolean(),
|
||||
nullable=False,
|
||||
postgresql_using="coalesce(lower(is_online) in ('true', 't', '1'), false)",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('devices') as batch_op:
|
||||
batch_op.alter_column(
|
||||
'is_online',
|
||||
existing_type=sa.Boolean(),
|
||||
type_=sa.VARCHAR(),
|
||||
nullable=True,
|
||||
postgresql_using="case when is_online then 'true' else 'false' end",
|
||||
)
|
||||
op.add_column('call_records', sa.Column('transcript', sa.TEXT(), nullable=True))
|
||||
@@ -10,7 +10,7 @@ Hold Slayer is a single-process async Python application built on FastAPI. It ac
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌───────────┐ ┌──────────────┐ │
|
||||
│ │ REST API │ │WebSocket │ │MCP Server │ │ Dashboard │ │
|
||||
│ │ /api/* │ │ /ws/* │ │ (SSE) │ │ /dashboard │ │
|
||||
│ │ /api/* │ │ /ws/* │ │ (HTTP) │ │ /dashboard │ │
|
||||
│ └────┬─────┘ └────┬─────┘ └─────┬─────┘ └──────────────┘ │
|
||||
│ │ │ │ │
|
||||
│ ┌────┴──────────────┴──────────────┴────┐ │
|
||||
@@ -46,7 +46,7 @@ Hold Slayer is a single-process async Python application built on FastAPI. It ac
|
||||
|-----------|------|----------|---------|
|
||||
| REST API | `api/calls.py`, `api/call_flows.py`, `api/devices.py` | HTTP | Call management, CRUD, configuration |
|
||||
| WebSocket | `api/websocket.py` | WS | Real-time event streaming to clients |
|
||||
| MCP Server | `mcp_server/server.py` | SSE | AI assistant tool integration |
|
||||
| MCP Server | `mcp_server/server.py` | Streamable HTTP at `/mcp/` | AI assistant tool integration |
|
||||
|
||||
### Orchestration Layer
|
||||
|
||||
@@ -75,7 +75,7 @@ Hold Slayer is a single-process async Python application built on FastAPI. It ac
|
||||
| Recording | `services/recording.py` | WAV file management and storage |
|
||||
| Analytics | `services/call_analytics.py` | Call metrics, hold time stats, trends |
|
||||
| Notifications | `services/notification.py` | WebSocket + SMS alerts |
|
||||
| Database | `db/database.py` | SQLAlchemy async (PostgreSQL or SQLite) |
|
||||
| Database | `db/database.py` | SQLAlchemy async (PostgreSQL, Alembic migrations) |
|
||||
|
||||
## Data Flow — Hold Slayer Call
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ uvicorn main:app --host 0.0.0.0 --port 8000 --reload
|
||||
### Production
|
||||
|
||||
```bash
|
||||
# Use PostgreSQL instead of SQLite
|
||||
# PostgreSQL is required (no SQLite fallback)
|
||||
DATABASE_URL=postgresql+asyncpg://user:pass@localhost/hold_slayer
|
||||
|
||||
# Use vLLM for faster inference
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.13+
|
||||
- Python 3.12+
|
||||
- Ollama (or any OpenAI-compatible LLM) — for IVR menu analysis
|
||||
- Speaches or Whisper API — for speech-to-text (optional for dev)
|
||||
- A SIP trunk account — for making real calls (optional for dev)
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
# MCP Server
|
||||
|
||||
The MCP (Model Context Protocol) server lets any MCP-compatible AI assistant control the Hold Slayer gateway. Built with [FastMCP](https://github.com/jlowin/fastmcp), it exposes tools and resources over SSE.
|
||||
The MCP (Model Context Protocol) server lets any MCP-compatible AI assistant
|
||||
control the Hold Slayer gateway. Built with [FastMCP](https://github.com/jlowin/fastmcp),
|
||||
it is mounted on the FastAPI app at **`/mcp/`** (trailing slash) over
|
||||
**streamable HTTP** and authenticates with the same static bearer token as the
|
||||
REST API and WebSocket.
|
||||
|
||||
## Overview
|
||||
|
||||
An AI assistant connects via SSE to the MCP server and gains access to tools for placing calls, checking status, sending DTMF, getting transcripts, and managing call flows. The assistant can orchestrate an entire call through natural language.
|
||||
An AI assistant connects to the MCP endpoint and gains access to 15 tools and
|
||||
3 resources for placing calls, checking status, sending DTMF, getting
|
||||
transcripts, and managing call flows. The assistant can orchestrate an entire
|
||||
call through natural language.
|
||||
|
||||
`make_call` places a **real PSTN call** that may incur charges; emergency
|
||||
numbers are always refused, and the concurrent-call cap applies.
|
||||
|
||||
## Tools
|
||||
|
||||
@@ -15,13 +25,32 @@ Place an outbound call through the SIP trunk.
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `number` | string | Yes | Phone number to call (E.164 format) |
|
||||
| `mode` | string | No | Call mode: `direct`, `hold_slayer`, `ai_assisted` (default: `hold_slayer`) |
|
||||
| `mode` | string | No | `direct`, `hold_slayer`, or `ai_assisted` (default: `direct`) |
|
||||
| `intent` | string | No | What you want to accomplish on the call |
|
||||
| `call_flow_id` | string | No | ID of a stored call flow to follow |
|
||||
| `device` | string | No | Device to transfer to when a human is detected |
|
||||
|
||||
Returns: Call ID and initial status.
|
||||
Returns: call ID and initial status.
|
||||
|
||||
### end_call
|
||||
### get_call_status
|
||||
|
||||
Check the current state of a call — status, duration, hold time, current
|
||||
audio classification, recent transcript.
|
||||
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `call_id` | string | Yes | The call to check |
|
||||
|
||||
### transfer_call
|
||||
|
||||
Transfer an active call to a registered device.
|
||||
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `call_id` | string | Yes | The call to transfer |
|
||||
| `device` | string | Yes | Device ID or type to ring |
|
||||
|
||||
### hangup
|
||||
|
||||
Hang up an active call.
|
||||
|
||||
@@ -29,102 +58,117 @@ Hang up an active call.
|
||||
|-------|------|----------|-------------|
|
||||
| `call_id` | string | Yes | The call to hang up |
|
||||
|
||||
### send_dtmf
|
||||
|
||||
Send touch-tone digits to an active call (for manual IVR navigation).
|
||||
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `call_id` | string | Yes | The call to send digits to |
|
||||
| `digits` | string | Yes | DTMF digits to send (e.g., "1", "3#", "1234") |
|
||||
|
||||
### get_call_status
|
||||
|
||||
Check the current state of a call.
|
||||
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `call_id` | string | Yes | The call to check |
|
||||
|
||||
Returns: Status, duration, hold time, audio classification, transcript excerpt.
|
||||
|
||||
### get_call_transcript
|
||||
|
||||
Get the live transcript of a call.
|
||||
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `call_id` | string | Yes | The call to get transcript for |
|
||||
|
||||
Returns: Array of transcript chunks with timestamps and speaker labels.
|
||||
|
||||
### get_call_recording
|
||||
|
||||
Get recording metadata and file path for a call.
|
||||
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `call_id` | string | Yes | The call to get recording for |
|
||||
|
||||
Returns: Recording path, duration, file size.
|
||||
|
||||
### list_active_calls
|
||||
|
||||
List all calls currently in progress. No parameters.
|
||||
|
||||
Returns: Array of active calls with status, number, duration.
|
||||
### send_dtmf
|
||||
|
||||
Send touch-tone digits on an active call (manual IVR navigation).
|
||||
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `call_id` | string | Yes | The call to send digits on |
|
||||
| `digits` | string | Yes | DTMF digits (e.g., `"1"`, `"123#"`) |
|
||||
|
||||
### get_call_transcript
|
||||
|
||||
Get the full transcript of an active call.
|
||||
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `call_id` | string | Yes | The call to get the transcript for |
|
||||
|
||||
### get_call_recording
|
||||
|
||||
Get recording metadata (path, duration) for a persisted call.
|
||||
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `call_id` | string | Yes | The call to look up |
|
||||
|
||||
### get_call_summary
|
||||
|
||||
Get analytics summary — hold times, success rates, call volume. No parameters.
|
||||
Stored summary, action items, and sentiment for a persisted call.
|
||||
|
||||
Returns: Aggregate statistics across all calls.
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `call_id` | string | Yes | The call to look up |
|
||||
|
||||
### search_call_history
|
||||
|
||||
Search past calls by number, company, or date range.
|
||||
Search past call records.
|
||||
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `query` | string | Yes | Search term (phone number, company name) |
|
||||
| `limit` | int | No | Max results (default: 20) |
|
||||
| `phone_number` | string | No | Filter by phone number (partial match) |
|
||||
| `intent` | string | No | Filter by intent text (partial match) |
|
||||
| `limit` | int | No | Max results (default: 10) |
|
||||
|
||||
### get_call_flow
|
||||
|
||||
Look up the stored IVR call flow for a phone number.
|
||||
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `phone_number` | string | Yes | Number to look up (E.164) |
|
||||
|
||||
### create_call_flow
|
||||
|
||||
Store a new IVR call flow by hand.
|
||||
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | string | Yes | Human-readable name |
|
||||
| `phone_number` | string | Yes | Phone number (E.164) |
|
||||
| `steps_json` | string | Yes | JSON array of call flow steps |
|
||||
| `notes` | string | No | General notes |
|
||||
|
||||
### learn_call_flow
|
||||
|
||||
Build a reusable call flow from a completed exploration call.
|
||||
Build (or refine) a reusable IVR call flow from a completed hold-slayer
|
||||
exploration call. Exploration calls record every IVR prompt heard and DTMF
|
||||
sent; this turns those discoveries into a stored flow so the next call
|
||||
navigates directly.
|
||||
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `call_id` | string | Yes | The exploration call to learn from |
|
||||
| `company` | string | No | Company name for the flow |
|
||||
| `call_id` | string | Yes | A completed call that ran in exploration mode |
|
||||
| `company_name` | string | No | Company name for labeling a new flow |
|
||||
|
||||
Returns: The generated CallFlow object.
|
||||
### list_devices
|
||||
|
||||
List registered devices and their online/offline status. No parameters.
|
||||
|
||||
### gateway_status
|
||||
|
||||
Trunk registration, device count, active calls, engine mode. No parameters.
|
||||
|
||||
## Resources
|
||||
|
||||
MCP resources provide read-only data that assistants can reference:
|
||||
|
||||
| Resource URI | Description |
|
||||
|-------------|-------------|
|
||||
| `gateway://status` | Current gateway status — trunk registration, active calls, service health |
|
||||
| `gateway://calls` | List of all active calls with current status |
|
||||
| `gateway://calls/{call_id}` | Detailed status for a specific call |
|
||||
| `gateway://flows` | List of all stored call flows |
|
||||
| `gateway://analytics` | Call analytics summary |
|
||||
| `gateway://status` | Current gateway status — trunk registration, active calls |
|
||||
| `gateway://call-flows` | List of all stored call flows |
|
||||
| `gateway://active-calls` | All active calls with current status |
|
||||
|
||||
## Configuration
|
||||
## Connecting an AI Assistant
|
||||
|
||||
The MCP server is mounted on the FastAPI app at `/mcp`.
|
||||
Claude Code:
|
||||
|
||||
### Connecting an AI Assistant
|
||||
```bash
|
||||
claude mcp add hold-slayer --transport http http://localhost:8000/mcp/ \
|
||||
--header "Authorization: Bearer $API_TOKEN"
|
||||
```
|
||||
|
||||
Add to your MCP client configuration (e.g., Claude Desktop, Cline):
|
||||
Generic MCP client configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"hold-slayer": {
|
||||
"url": "http://localhost:8000/mcp/sse"
|
||||
"url": "http://localhost:8000/mcp/",
|
||||
"headers": {"Authorization": "Bearer <API_TOKEN>"}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,8 +176,6 @@ Add to your MCP client configuration (e.g., Claude Desktop, Cline):
|
||||
|
||||
## Example Conversation
|
||||
|
||||
Here is how an AI assistant would use the MCP tools to handle a complete call:
|
||||
|
||||
**User:** "Call Chase Bank and dispute the Amazon charge from December 15th"
|
||||
|
||||
**Assistant actions:**
|
||||
@@ -141,15 +183,8 @@ Here is how an AI assistant would use the MCP tools to handle a complete call:
|
||||
1. Calls `make_call(number="+18005551234", mode="hold_slayer", intent="dispute Amazon charge Dec 15th", call_flow_id="chase-bank-main")`
|
||||
2. Receives `call_id: "call_abc123"`
|
||||
3. Polls `get_call_status("call_abc123")` periodically
|
||||
4. Status progression: `trying` → `ringing` → `connected` → `on_hold`
|
||||
4. Status progression: `initiating` → `ringing` → `connected` → `on_hold`
|
||||
5. Tells user: "I'm on hold with Chase Bank. Currently 4 minutes in. I'll let you know when someone picks up."
|
||||
6. Status changes to `transferring` — human detected!
|
||||
7. Tells user: "A live agent just picked up. I'm transferring the call to your desk phone now. Pick up!"
|
||||
8. After the call, calls `learn_call_flow("call_abc123", company="Chase Bank")` to save the IVR path for next time.
|
||||
|
||||
**User:** "How long was I on hold?"
|
||||
|
||||
**Assistant actions:**
|
||||
|
||||
1. Calls `get_call_summary()`
|
||||
2. Reports: "Your Chase Bank call lasted 12 minutes total, with 8 minutes on hold. The disputes department averages 6 minutes hold time on Tuesdays."
|
||||
8. After the call, calls `learn_call_flow("call_abc123", company_name="Chase Bank")` to save the IVR path for next time.
|
||||
|
||||
129
main.py
129
main.py
@@ -21,9 +21,19 @@ from fastapi.staticfiles import StaticFiles
|
||||
from api import call_flows, call_history, calls, devices, routing, websocket
|
||||
from api.deps import require_token
|
||||
from config import Settings, get_settings
|
||||
from core.gateway import AIPSTNGateway
|
||||
from core.gateway import AIPSTNGateway, build_sip_engine
|
||||
from db.database import close_db, init_db
|
||||
from mcp_server.server import create_mcp_server
|
||||
from models.call import CallMode
|
||||
from services.audio_classifier import AudioClassifier
|
||||
from services.call_persistence import persist_call_on_create, persist_call_on_end
|
||||
from services.hold_slayer import HoldSlayerService
|
||||
from services.notification import NotificationService
|
||||
from services.receptionist import ReceptionistService
|
||||
from services.recording import RecordingService
|
||||
from services.routing import RoutingService
|
||||
from services.transcription import TranscriptionService
|
||||
from services.tts import TTSService
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
@@ -126,23 +136,70 @@ async def lifespan(app: FastAPI):
|
||||
except Exception as e:
|
||||
_handle_db_error(e)
|
||||
|
||||
# Boot the telephony engine
|
||||
gateway = AIPSTNGateway.from_config()
|
||||
# === Composition root ===
|
||||
# Build the gateway and every service here, wiring them by
|
||||
# constructor/registration — nothing constructs its own deps.
|
||||
gateway = AIPSTNGateway(
|
||||
settings=settings,
|
||||
on_call_created=persist_call_on_create,
|
||||
on_call_ended=persist_call_on_end,
|
||||
)
|
||||
|
||||
classifier = AudioClassifier(settings.classifier)
|
||||
transcription = TranscriptionService(settings.speaches)
|
||||
tts = TTSService(settings.tts)
|
||||
routing_svc = RoutingService(gateway)
|
||||
recording_svc = RecordingService()
|
||||
receptionist = ReceptionistService(
|
||||
gateway,
|
||||
tts=tts,
|
||||
transcription=transcription,
|
||||
recording=recording_svc,
|
||||
routing=routing_svc,
|
||||
)
|
||||
gateway.attach_services(tts=tts)
|
||||
|
||||
def launch_hold_slayer(call, sip_leg_id, call_flow_id):
|
||||
svc = HoldSlayerService(
|
||||
gateway=gateway,
|
||||
call_manager=gateway.call_manager,
|
||||
sip_engine=gateway.sip_engine,
|
||||
classifier=classifier,
|
||||
transcription=transcription,
|
||||
settings=settings,
|
||||
tts=tts,
|
||||
)
|
||||
gateway.spawn(
|
||||
svc.run(call, sip_leg_id, call_flow_id),
|
||||
name=f"holdslayer_{call.id}",
|
||||
)
|
||||
|
||||
gateway.register_mode_handler(CallMode.HOLD_SLAYER, launch_hold_slayer)
|
||||
|
||||
try:
|
||||
gateway.sip_engine = build_sip_engine(
|
||||
settings,
|
||||
gateway.media_pipeline,
|
||||
on_leg_state_change=gateway._on_sip_leg_state,
|
||||
on_device_registered=gateway._on_sip_device_registered,
|
||||
on_incoming_call=receptionist.on_inbound_call,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.critical(f"\n❌ SIP engine failed to initialize:\n {e}")
|
||||
sys.exit(1)
|
||||
|
||||
await routing_svc.start()
|
||||
await gateway.start()
|
||||
app.state.gateway = gateway
|
||||
|
||||
# Start auxiliary services
|
||||
from services.notification import NotificationService
|
||||
from services.recording import RecordingService
|
||||
app.state.routing_service = routing_svc
|
||||
app.state.transcription_service = transcription
|
||||
|
||||
notification_svc = NotificationService(gateway.event_bus, settings)
|
||||
await notification_svc.start()
|
||||
app.state.notification_service = notification_svc
|
||||
|
||||
recording_svc = RecordingService()
|
||||
await recording_svc.start()
|
||||
app.state.recording_service = recording_svc
|
||||
gateway._recording_service = recording_svc
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info("🔥 Hold Slayer Gateway is LIVE")
|
||||
@@ -250,21 +307,67 @@ async def root():
|
||||
|
||||
@app.get("/health", tags=["System"])
|
||||
async def health():
|
||||
"""Health check endpoint."""
|
||||
"""
|
||||
Health check. "healthy" means the gateway can actually do its job:
|
||||
real engine, registered trunk, reachable database. A mock engine or
|
||||
a failing dependency reports "degraded" with the reason visible.
|
||||
"""
|
||||
from core.sip_engine import MockSIPEngine
|
||||
from db.database import session_scope
|
||||
|
||||
gateway = getattr(app.state, "gateway", None)
|
||||
ready = gateway is not None and await gateway.sip_engine.is_ready()
|
||||
trunk_status = await gateway.sip_engine.get_trunk_status() if gateway else {"registered": False}
|
||||
return {
|
||||
"status": "healthy" if ready else "degraded",
|
||||
engine_mode = (
|
||||
"mock" if gateway is None or isinstance(gateway.sip_engine, MockSIPEngine)
|
||||
else "sippy"
|
||||
)
|
||||
|
||||
db_ok = False
|
||||
db_error = None
|
||||
try:
|
||||
from sqlalchemy import text
|
||||
async with session_scope() as session:
|
||||
await session.execute(text("SELECT 1"))
|
||||
db_ok = True
|
||||
except Exception as e:
|
||||
db_error = str(e)[:200]
|
||||
|
||||
healthy = (
|
||||
ready
|
||||
and db_ok
|
||||
and engine_mode == "sippy"
|
||||
and trunk_status.get("registered", False)
|
||||
)
|
||||
|
||||
checks = {
|
||||
"gateway": "ready" if gateway else "not initialized",
|
||||
"engine": engine_mode,
|
||||
"sip_engine": "ready" if ready else "not ready",
|
||||
"database": "ok" if db_ok else f"error: {db_error}",
|
||||
"sip_trunk": {
|
||||
"registered": trunk_status.get("registered", False),
|
||||
"host": trunk_status.get("host"),
|
||||
"mock": trunk_status.get("mock", False),
|
||||
"reason": trunk_status.get("reason"),
|
||||
},
|
||||
}
|
||||
if gateway is not None:
|
||||
tts = getattr(gateway, "_tts", None)
|
||||
checks["tts"] = _availability(tts)
|
||||
transcription = getattr(app.state, "transcription_service", None)
|
||||
checks["stt"] = _availability(transcription)
|
||||
|
||||
return {"status": "healthy" if healthy else "degraded", **checks}
|
||||
|
||||
|
||||
def _availability(service) -> str:
|
||||
"""Last-known reachability of an HTTP leaf service."""
|
||||
if service is None:
|
||||
return "not attached"
|
||||
available = getattr(service, "available", None)
|
||||
if available is None:
|
||||
return "unknown (no requests yet)"
|
||||
return "ok" if available else "unreachable"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -184,18 +184,12 @@ def create_mcp_server(
|
||||
|
||||
Returns the IVR navigation tree if one exists.
|
||||
"""
|
||||
from db.database import StoredCallFlow, get_session_factory
|
||||
from sqlalchemy import select
|
||||
from db.database import session_scope
|
||||
from services import call_persistence as store
|
||||
|
||||
try:
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
result = await session.execute(
|
||||
select(StoredCallFlow).where(
|
||||
StoredCallFlow.phone_number == phone_number
|
||||
)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
async with session_scope() as session:
|
||||
row = await store.get_flow_by_number(session, phone_number)
|
||||
if not row:
|
||||
return f"No stored call flow for {phone_number}."
|
||||
|
||||
@@ -243,25 +237,26 @@ def create_mcp_server(
|
||||
"""
|
||||
from slugify import slugify as do_slugify
|
||||
|
||||
from db.database import StoredCallFlow, get_session_factory
|
||||
from db.database import session_scope
|
||||
from services import call_persistence as store
|
||||
|
||||
try:
|
||||
steps = json.loads(steps_json)
|
||||
flow_id = do_slugify(name)
|
||||
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
db_flow = StoredCallFlow(
|
||||
id=flow_id,
|
||||
async with session_scope() as session:
|
||||
if await store.get_flow(session, flow_id):
|
||||
return f"Call flow '{flow_id}' already exists."
|
||||
await store.create_flow(
|
||||
session,
|
||||
flow_id=flow_id,
|
||||
name=name,
|
||||
phone_number=phone_number,
|
||||
description="Created by AI assistant",
|
||||
steps=steps,
|
||||
notes=notes or None,
|
||||
tags=["ai-created"],
|
||||
notes=notes or None,
|
||||
)
|
||||
session.add(db_flow)
|
||||
await session.commit()
|
||||
|
||||
return f"Call flow '{name}' saved for {phone_number} (ID: {flow_id})"
|
||||
except json.JSONDecodeError:
|
||||
@@ -269,6 +264,70 @@ def create_mcp_server(
|
||||
except Exception as e:
|
||||
return f"Error creating call flow: {e}"
|
||||
|
||||
@mcp.tool()
|
||||
async def learn_call_flow(call_id: str, company_name: str = "") -> str:
|
||||
"""
|
||||
Build (or refine) a reusable IVR call flow from a completed
|
||||
hold-slayer exploration call.
|
||||
|
||||
Exploration calls record every IVR prompt heard and DTMF sent;
|
||||
this turns those discoveries into a stored call flow so the next
|
||||
call to that number navigates directly instead of exploring.
|
||||
If a flow already exists for the number, the discoveries refine
|
||||
it (timeouts averaged, usage counters updated).
|
||||
|
||||
Args:
|
||||
call_id: A completed call that ran in exploration mode
|
||||
company_name: Optional company name for labeling a new flow
|
||||
"""
|
||||
from db.database import session_scope
|
||||
from services import call_persistence as store
|
||||
from services.call_flow_learner import CallFlowLearner
|
||||
from services.llm_client import get_llm
|
||||
|
||||
try:
|
||||
async with session_scope() as session:
|
||||
record = await store.get_record(session, call_id)
|
||||
if not record:
|
||||
return f"No record found for call {call_id}."
|
||||
|
||||
steps = (record.metadata_ or {}).get("exploration_steps") or []
|
||||
if not steps:
|
||||
return (
|
||||
f"Call {call_id} has no exploration data to learn from. "
|
||||
"Only hold-slayer calls without a stored flow record "
|
||||
"IVR discoveries."
|
||||
)
|
||||
|
||||
learner = CallFlowLearner(llm_client=get_llm())
|
||||
existing = await store.get_flow_by_number(
|
||||
session, record.remote_number
|
||||
)
|
||||
if existing:
|
||||
flow = await learner.merge_discoveries(
|
||||
store.flow_to_model(existing), steps, intent=record.intent
|
||||
)
|
||||
await store.update_flow_from_model(session, existing, flow)
|
||||
return (
|
||||
f"Refined existing flow '{existing.name}' for "
|
||||
f"{record.remote_number} from {len(steps)} discoveries "
|
||||
f"({len(flow.steps)} steps, used {flow.times_used}x)."
|
||||
)
|
||||
|
||||
flow = await learner.build_flow(
|
||||
phone_number=record.remote_number,
|
||||
discovered_steps=steps,
|
||||
intent=record.intent,
|
||||
company_name=company_name or None,
|
||||
)
|
||||
await store.save_learned_flow(session, flow)
|
||||
return (
|
||||
f"Learned new flow '{flow.name}' with {len(flow.steps)} "
|
||||
f"steps from {len(steps)} discoveries (ID: {flow.id})."
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Error learning call flow: {e}"
|
||||
|
||||
@mcp.tool()
|
||||
async def send_dtmf(call_id: str, digits: str) -> str:
|
||||
"""
|
||||
@@ -283,12 +342,12 @@ def create_mcp_server(
|
||||
if not call:
|
||||
return f"Call {call_id} not found."
|
||||
|
||||
for leg_id, cid in gateway.call_manager._call_legs.items():
|
||||
if cid == call_id:
|
||||
await gateway.sip_engine.send_dtmf(leg_id, digits)
|
||||
return f"Sent DTMF '{digits}' on call {call_id}."
|
||||
legs = gateway.call_manager.legs_for_call(call_id)
|
||||
if not legs:
|
||||
return f"No active SIP leg found for call {call_id}."
|
||||
|
||||
return f"No active SIP leg found for call {call_id}."
|
||||
await gateway.sip_engine.send_dtmf(legs[0], digits)
|
||||
return f"Sent DTMF '{digits}' on call {call_id}."
|
||||
|
||||
@mcp.tool()
|
||||
async def get_call_transcript(call_id: str) -> str:
|
||||
@@ -318,16 +377,12 @@ def create_mcp_server(
|
||||
|
||||
Returns the recording file path and status.
|
||||
"""
|
||||
from db.database import CallRecord, get_session_factory
|
||||
from sqlalchemy import select
|
||||
from db.database import session_scope
|
||||
from services import call_persistence as store
|
||||
|
||||
try:
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
result = await session.execute(
|
||||
select(CallRecord).where(CallRecord.id == call_id)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
async with session_scope() as session:
|
||||
record = await store.get_record(session, call_id)
|
||||
if not record:
|
||||
return f"No record found for call {call_id}."
|
||||
if not record.recording_path:
|
||||
@@ -348,16 +403,12 @@ def create_mcp_server(
|
||||
|
||||
Returns the summary, action items, and sentiment analysis.
|
||||
"""
|
||||
from db.database import CallRecord, get_session_factory
|
||||
from sqlalchemy import select
|
||||
from db.database import session_scope
|
||||
from services import call_persistence as store
|
||||
|
||||
try:
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
result = await session.execute(
|
||||
select(CallRecord).where(CallRecord.id == call_id)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
async with session_scope() as session:
|
||||
record = await store.get_record(session, call_id)
|
||||
if not record:
|
||||
return f"No record found for call {call_id}."
|
||||
|
||||
@@ -398,27 +449,17 @@ def create_mcp_server(
|
||||
intent: Filter by intent text (partial match)
|
||||
limit: Max results to return (default 10)
|
||||
"""
|
||||
from db.database import CallRecord, get_session_factory
|
||||
from sqlalchemy import select
|
||||
from db.database import session_scope
|
||||
from services import call_persistence as store
|
||||
|
||||
try:
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
query = select(CallRecord).order_by(
|
||||
CallRecord.started_at.desc()
|
||||
).limit(limit)
|
||||
|
||||
if phone_number:
|
||||
query = query.where(
|
||||
CallRecord.remote_number.contains(phone_number)
|
||||
)
|
||||
if intent:
|
||||
query = query.where(
|
||||
CallRecord.intent.icontains(intent)
|
||||
)
|
||||
|
||||
result = await session.execute(query)
|
||||
records = result.scalars().all()
|
||||
async with session_scope() as session:
|
||||
records = await store.search_history(
|
||||
session,
|
||||
number_contains=phone_number or None,
|
||||
intent_contains=intent or None,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
if not records:
|
||||
return "No matching call records found."
|
||||
@@ -482,14 +523,12 @@ def create_mcp_server(
|
||||
@mcp.resource("gateway://call-flows")
|
||||
async def resource_call_flows() -> str:
|
||||
"""List all stored call flows."""
|
||||
from db.database import StoredCallFlow, get_session_factory
|
||||
from sqlalchemy import select
|
||||
from db.database import session_scope
|
||||
from services import call_persistence as store
|
||||
|
||||
try:
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
result = await session.execute(select(StoredCallFlow))
|
||||
rows = result.scalars().all()
|
||||
async with session_scope() as session:
|
||||
rows = await store.list_flows(session)
|
||||
flows = [
|
||||
{
|
||||
"id": r.id,
|
||||
|
||||
@@ -55,6 +55,14 @@ class ClassificationResult(BaseModel):
|
||||
details: Optional[dict] = None # Extra analysis data
|
||||
|
||||
|
||||
class TranscriptEntry(BaseModel):
|
||||
"""One transcribed utterance, offset from call start for seek."""
|
||||
|
||||
t_offset_ms: int
|
||||
speaker: str = "unknown" # caller / agent / receptionist / unknown
|
||||
text: str
|
||||
|
||||
|
||||
class ActiveCall(BaseModel):
|
||||
"""In-memory state for an active call."""
|
||||
|
||||
@@ -71,9 +79,12 @@ class ActiveCall(BaseModel):
|
||||
hold_started_at: Optional[datetime] = None
|
||||
current_classification: AudioClassification = AudioClassification.UNKNOWN
|
||||
classification_history: list[ClassificationResult] = Field(default_factory=list)
|
||||
transcript_chunks: list[str] = Field(default_factory=list)
|
||||
transcript_chunks: list[TranscriptEntry] = Field(default_factory=list)
|
||||
current_step_id: Optional[str] = None # Current position in call flow
|
||||
services: list[str] = Field(default_factory=list) # Active services on this call
|
||||
# IVR discoveries from hold-slayer exploration mode; persisted with
|
||||
# the call record so learn_call_flow can build a flow afterwards
|
||||
exploration_steps: list[dict] = Field(default_factory=list)
|
||||
|
||||
@property
|
||||
def duration(self) -> int:
|
||||
@@ -92,7 +103,7 @@ class ActiveCall(BaseModel):
|
||||
@property
|
||||
def transcript(self) -> str:
|
||||
"""Full transcript so far."""
|
||||
return "\n".join(self.transcript_chunks)
|
||||
return "\n".join(e.text for e in self.transcript_chunks)
|
||||
|
||||
def summary(self) -> dict:
|
||||
"""Compact summary for list views."""
|
||||
@@ -145,6 +156,16 @@ class CallResponse(BaseModel):
|
||||
mode: str
|
||||
message: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def from_call(cls, call: "ActiveCall", message: Optional[str] = None) -> "CallResponse":
|
||||
return cls(
|
||||
call_id=call.id,
|
||||
status=call.status.value,
|
||||
number=call.remote_number,
|
||||
mode=call.mode.value,
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
class CallStatusResponse(BaseModel):
|
||||
"""Full status of an active or completed call."""
|
||||
@@ -163,6 +184,25 @@ class CallStatusResponse(BaseModel):
|
||||
current_step: Optional[str] = None
|
||||
services: list[str] = Field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def from_call(cls, call: "ActiveCall") -> "CallStatusResponse":
|
||||
"""The one ActiveCall → status-response mapping."""
|
||||
return cls(
|
||||
call_id=call.id,
|
||||
status=call.status.value,
|
||||
direction=call.direction,
|
||||
remote_number=call.remote_number,
|
||||
mode=call.mode.value,
|
||||
duration=call.duration,
|
||||
hold_time=call.hold_time,
|
||||
audio_type=call.current_classification.value,
|
||||
intent=call.intent,
|
||||
transcript_excerpt=call.transcript[-500:] if call.transcript else None,
|
||||
classification_history=call.classification_history[-20:],
|
||||
current_step=call.current_step_id,
|
||||
services=call.services,
|
||||
)
|
||||
|
||||
|
||||
class TransferRequest(BaseModel):
|
||||
"""Request to transfer a call to a device."""
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
"""
|
||||
Contact models — People and organizations you call.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PhoneNumber(BaseModel):
|
||||
"""A phone number associated with a contact."""
|
||||
|
||||
number: str # E.164 format
|
||||
label: str = "main" # main, mobile, work, home, fax, etc.
|
||||
primary: bool = False
|
||||
|
||||
|
||||
class ContactBase(BaseModel):
|
||||
"""Shared contact fields."""
|
||||
|
||||
name: str
|
||||
phone_numbers: list[PhoneNumber]
|
||||
category: Optional[str] = None # personal / business / service
|
||||
routing_preference: Optional[str] = None # how to handle their calls
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class Contact(ContactBase):
|
||||
"""Full contact model."""
|
||||
|
||||
id: str
|
||||
call_count: int = 0
|
||||
last_call: Optional[datetime] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
@property
|
||||
def primary_number(self) -> Optional[str]:
|
||||
"""Get the primary phone number."""
|
||||
for pn in self.phone_numbers:
|
||||
if pn.primary:
|
||||
return pn.number
|
||||
return self.phone_numbers[0].number if self.phone_numbers else None
|
||||
|
||||
|
||||
class ContactCreate(ContactBase):
|
||||
"""Request model for creating a contact."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ContactUpdate(BaseModel):
|
||||
"""Request model for updating a contact."""
|
||||
|
||||
name: Optional[str] = None
|
||||
phone_numbers: Optional[list[PhoneNumber]] = None
|
||||
category: Optional[str] = None
|
||||
routing_preference: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
@@ -8,7 +8,7 @@ from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, computed_field
|
||||
|
||||
|
||||
class DeviceType(str, Enum):
|
||||
@@ -43,6 +43,7 @@ class Device(DeviceBase):
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
@computed_field # serialized so API consumers see routability directly
|
||||
@property
|
||||
def can_receive_call(self) -> bool:
|
||||
"""Can this device receive a call right now?"""
|
||||
@@ -71,14 +72,3 @@ class DeviceUpdate(BaseModel):
|
||||
phone_number: Optional[str] = None
|
||||
priority: Optional[int] = None
|
||||
capabilities: Optional[list[str]] = None
|
||||
|
||||
|
||||
class DeviceStatus(BaseModel):
|
||||
"""Lightweight device status for list views."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
type: DeviceType
|
||||
is_online: bool
|
||||
last_seen: Optional[datetime] = None
|
||||
can_receive_call: bool
|
||||
|
||||
@@ -13,34 +13,30 @@ dependencies = [
|
||||
# Web framework
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.32.0",
|
||||
"websockets>=13.0",
|
||||
|
||||
|
||||
# Database
|
||||
"sqlalchemy[asyncio]>=2.0.36",
|
||||
"asyncpg>=0.30.0",
|
||||
"alembic>=1.14.0",
|
||||
|
||||
|
||||
# Settings & validation
|
||||
"pydantic>=2.10.0",
|
||||
"pydantic-settings>=2.6.0",
|
||||
|
||||
|
||||
# SIP signaling
|
||||
"sippy>=1.2.0",
|
||||
|
||||
|
||||
# Audio analysis
|
||||
"numpy>=1.26.0",
|
||||
"librosa>=0.10.0",
|
||||
"soundfile>=0.12.0",
|
||||
|
||||
|
||||
# HTTP client (for Speaches STT)
|
||||
"httpx>=0.28.0",
|
||||
|
||||
|
||||
# MCP server (3.x — http_app + StaticTokenVerifier)
|
||||
"fastmcp>=3.0.0",
|
||||
|
||||
|
||||
# Utilities
|
||||
"python-slugify>=8.0.0",
|
||||
"python-multipart>=0.0.12",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -50,6 +46,7 @@ dev = [
|
||||
"pytest-cov>=6.0.0",
|
||||
"httpx>=0.28.0",
|
||||
"ruff>=0.8.0",
|
||||
"aiosqlite>=0.22.0",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
@@ -69,3 +66,7 @@ line-length = 100
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "N", "W", "UP"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
# Alembic-generated migrations keep the standard template style.
|
||||
"db/migrations/versions/*" = ["E501", "UP007", "UP035", "W291"]
|
||||
|
||||
@@ -12,6 +12,7 @@ Uses spectral analysis (librosa/numpy) to classify audio without needing
|
||||
a trained ML model — just signal processing and heuristics.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
@@ -47,9 +48,23 @@ class AudioClassifier:
|
||||
self._window_samples = int(settings.window_seconds * SAMPLE_RATE)
|
||||
self._classification_history: list[AudioClassification] = []
|
||||
|
||||
async def classify(self, audio_data: bytes) -> ClassificationResult:
|
||||
"""
|
||||
Classify a chunk off the event loop and record it in the history.
|
||||
|
||||
The FFT/autocorrelation work is CPU-bound, so the pure
|
||||
`classify_chunk` runs in a worker thread; the history update
|
||||
happens back on the loop, keeping it single-threaded. This is
|
||||
the call sites' entry point — routing every classification
|
||||
through here is what keeps the history complete.
|
||||
"""
|
||||
result = await asyncio.to_thread(self.classify_chunk, audio_data)
|
||||
self.update_history(result.audio_type)
|
||||
return result
|
||||
|
||||
def classify_chunk(self, audio_data: bytes) -> ClassificationResult:
|
||||
"""
|
||||
Classify a chunk of audio data.
|
||||
Classify a chunk of audio data (pure, synchronous).
|
||||
|
||||
Args:
|
||||
audio_data: Raw PCM audio (16-bit signed, 16kHz, mono)
|
||||
@@ -285,17 +300,15 @@ class AudioClassifier:
|
||||
(941, 1209): "*", (941, 1336): "0", (941, 1477): "#", (941, 1633): "D",
|
||||
}
|
||||
|
||||
# Compute power at each DTMF frequency
|
||||
# Power at each DTMF frequency via the DFT bin (numerically equal
|
||||
# to the Goertzel result s1² + s2² − coeff·s1·s2, but vectorized —
|
||||
# the per-sample Python loop blocked for ~50ms per chunk)
|
||||
n = np.arange(len(samples))
|
||||
|
||||
def goertzel_power(freq: int) -> float:
|
||||
k = int(0.5 + len(samples) * freq / SAMPLE_RATE)
|
||||
w = 2 * np.pi * k / len(samples)
|
||||
coeff = 2 * np.cos(w)
|
||||
s0, s1, s2 = 0.0, 0.0, 0.0
|
||||
for sample in samples:
|
||||
s0 = sample + coeff * s1 - s2
|
||||
s2 = s1
|
||||
s1 = s0
|
||||
return float(s1 * s1 + s2 * s2 - coeff * s1 * s2)
|
||||
bin_value = np.dot(samples, np.exp(-2j * np.pi * k * n / len(samples)))
|
||||
return float(np.abs(bin_value) ** 2)
|
||||
|
||||
# Find strongest low and high frequencies
|
||||
low_powers = [(f, goertzel_power(f)) for f in dtmf_freqs_low]
|
||||
|
||||
@@ -1,324 +0,0 @@
|
||||
"""
|
||||
Call Analytics Service — Tracks call metrics and generates insights.
|
||||
|
||||
Monitors call patterns, hold times, success rates, and IVR navigation
|
||||
efficiency. Provides data for the dashboard and API.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Optional
|
||||
|
||||
from models.call import ActiveCall, AudioClassification, CallMode, CallStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CallAnalytics:
|
||||
"""
|
||||
In-memory call analytics engine.
|
||||
|
||||
Tracks:
|
||||
- Call success/failure rates
|
||||
- Hold time statistics (avg, min, max, p95)
|
||||
- IVR navigation efficiency
|
||||
- Human detection accuracy
|
||||
- Per-number/company patterns
|
||||
- Time-of-day patterns
|
||||
|
||||
In production, this would be backed by TimescaleDB or similar.
|
||||
For now, we keep rolling windows in memory.
|
||||
"""
|
||||
|
||||
def __init__(self, max_history: int = 10000):
|
||||
self._max_history = max_history
|
||||
self._call_records: list[CallRecord] = []
|
||||
self._company_stats: dict[str, CompanyStats] = defaultdict(CompanyStats)
|
||||
|
||||
# ================================================================
|
||||
# Record Calls
|
||||
# ================================================================
|
||||
|
||||
def record_call(self, call: ActiveCall) -> None:
|
||||
"""
|
||||
Record a completed call for analytics.
|
||||
|
||||
Called when a call ends (from CallManager).
|
||||
"""
|
||||
record = CallRecord(
|
||||
call_id=call.id,
|
||||
remote_number=call.remote_number,
|
||||
mode=call.mode,
|
||||
status=call.status,
|
||||
intent=call.intent,
|
||||
started_at=call.created_at,
|
||||
duration_seconds=call.duration,
|
||||
hold_time_seconds=call.hold_time,
|
||||
classification_history=[
|
||||
r.audio_type.value for r in call.classification_history
|
||||
],
|
||||
transcript_chunks=list(call.transcript_chunks),
|
||||
services=list(call.services),
|
||||
)
|
||||
|
||||
self._call_records.append(record)
|
||||
|
||||
# Trim history
|
||||
if len(self._call_records) > self._max_history:
|
||||
self._call_records = self._call_records[-self._max_history :]
|
||||
|
||||
# Update company stats
|
||||
company_key = self._normalize_number(call.remote_number)
|
||||
self._company_stats[company_key].update(record)
|
||||
|
||||
logger.debug(
|
||||
f"📊 Recorded call {call.id}: "
|
||||
f"{call.status.value}, {call.duration}s, hold={call.hold_time}s"
|
||||
)
|
||||
|
||||
# ================================================================
|
||||
# Aggregate Stats
|
||||
# ================================================================
|
||||
|
||||
def get_summary(self, hours: int = 24) -> dict[str, Any]:
|
||||
"""Get summary statistics for the last N hours."""
|
||||
cutoff = datetime.now() - timedelta(hours=hours)
|
||||
recent = [r for r in self._call_records if r.started_at >= cutoff]
|
||||
|
||||
if not recent:
|
||||
return {
|
||||
"period_hours": hours,
|
||||
"total_calls": 0,
|
||||
"success_rate": 0.0,
|
||||
"avg_hold_time": 0.0,
|
||||
"avg_duration": 0.0,
|
||||
}
|
||||
|
||||
total = len(recent)
|
||||
successful = sum(1 for r in recent if r.status in (
|
||||
CallStatus.COMPLETED, CallStatus.BRIDGED, CallStatus.HUMAN_DETECTED
|
||||
))
|
||||
failed = sum(1 for r in recent if r.status == CallStatus.FAILED)
|
||||
|
||||
hold_times = [r.hold_time_seconds for r in recent if r.hold_time_seconds > 0]
|
||||
durations = [r.duration_seconds for r in recent if r.duration_seconds > 0]
|
||||
|
||||
hold_slayer_calls = [r for r in recent if r.mode == CallMode.HOLD_SLAYER]
|
||||
hold_slayer_success = sum(
|
||||
1 for r in hold_slayer_calls
|
||||
if r.status in (CallStatus.BRIDGED, CallStatus.HUMAN_DETECTED)
|
||||
)
|
||||
|
||||
return {
|
||||
"period_hours": hours,
|
||||
"total_calls": total,
|
||||
"successful": successful,
|
||||
"failed": failed,
|
||||
"success_rate": round(successful / total, 3) if total else 0.0,
|
||||
"avg_duration": round(sum(durations) / len(durations), 1) if durations else 0.0,
|
||||
"max_duration": max(durations) if durations else 0,
|
||||
"hold_time": {
|
||||
"avg": round(sum(hold_times) / len(hold_times), 1) if hold_times else 0.0,
|
||||
"min": min(hold_times) if hold_times else 0,
|
||||
"max": max(hold_times) if hold_times else 0,
|
||||
"p95": self._percentile(hold_times, 95) if hold_times else 0,
|
||||
"total": sum(hold_times),
|
||||
},
|
||||
"hold_slayer": {
|
||||
"total": len(hold_slayer_calls),
|
||||
"success": hold_slayer_success,
|
||||
"success_rate": round(
|
||||
hold_slayer_success / len(hold_slayer_calls), 3
|
||||
) if hold_slayer_calls else 0.0,
|
||||
},
|
||||
"by_mode": self._group_by_mode(recent),
|
||||
"by_hour": self._group_by_hour(recent),
|
||||
}
|
||||
|
||||
def get_company_stats(self, number: str) -> dict[str, Any]:
|
||||
"""Get stats for a specific company/number."""
|
||||
key = self._normalize_number(number)
|
||||
stats = self._company_stats.get(key)
|
||||
if not stats:
|
||||
return {"number": number, "total_calls": 0}
|
||||
return stats.to_dict(number)
|
||||
|
||||
def get_top_numbers(self, limit: int = 10) -> list[dict[str, Any]]:
|
||||
"""Get the most-called numbers with their stats."""
|
||||
sorted_stats = sorted(
|
||||
self._company_stats.items(),
|
||||
key=lambda x: x[1].total_calls,
|
||||
reverse=True,
|
||||
)[:limit]
|
||||
return [stats.to_dict(number) for number, stats in sorted_stats]
|
||||
|
||||
# ================================================================
|
||||
# Hold Time Trends
|
||||
# ================================================================
|
||||
|
||||
def get_hold_time_trend(
|
||||
self,
|
||||
number: Optional[str] = None,
|
||||
days: int = 7,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Get hold time trend data for graphing.
|
||||
|
||||
Returns daily average hold times for the last N days.
|
||||
"""
|
||||
cutoff = datetime.now() - timedelta(days=days)
|
||||
records = [r for r in self._call_records if r.started_at >= cutoff]
|
||||
|
||||
if number:
|
||||
key = self._normalize_number(number)
|
||||
records = [r for r in records if self._normalize_number(r.remote_number) == key]
|
||||
|
||||
# Group by day
|
||||
by_day: dict[str, list[int]] = defaultdict(list)
|
||||
for r in records:
|
||||
day = r.started_at.strftime("%Y-%m-%d")
|
||||
if r.hold_time_seconds > 0:
|
||||
by_day[day].append(r.hold_time_seconds)
|
||||
|
||||
trend = []
|
||||
for i in range(days):
|
||||
date = (datetime.now() - timedelta(days=days - 1 - i)).strftime("%Y-%m-%d")
|
||||
times = by_day.get(date, [])
|
||||
trend.append({
|
||||
"date": date,
|
||||
"avg_hold_time": round(sum(times) / len(times), 1) if times else 0,
|
||||
"call_count": len(times),
|
||||
"max_hold_time": max(times) if times else 0,
|
||||
})
|
||||
|
||||
return trend
|
||||
|
||||
# ================================================================
|
||||
# Helpers
|
||||
# ================================================================
|
||||
|
||||
@staticmethod
|
||||
def _normalize_number(number: str) -> str:
|
||||
"""Normalize phone number for grouping."""
|
||||
# Strip formatting, keep last 10 digits
|
||||
digits = "".join(c for c in number if c.isdigit())
|
||||
return digits[-10:] if len(digits) >= 10 else digits
|
||||
|
||||
@staticmethod
|
||||
def _percentile(values: list, pct: int) -> float:
|
||||
"""Calculate percentile value."""
|
||||
if not values:
|
||||
return 0.0
|
||||
sorted_vals = sorted(values)
|
||||
idx = int(len(sorted_vals) * pct / 100)
|
||||
idx = min(idx, len(sorted_vals) - 1)
|
||||
return float(sorted_vals[idx])
|
||||
|
||||
@staticmethod
|
||||
def _group_by_mode(records: list["CallRecord"]) -> dict[str, int]:
|
||||
"""Group call counts by mode."""
|
||||
by_mode: dict[str, int] = defaultdict(int)
|
||||
for r in records:
|
||||
by_mode[r.mode.value] += 1
|
||||
return dict(by_mode)
|
||||
|
||||
@staticmethod
|
||||
def _group_by_hour(records: list["CallRecord"]) -> dict[int, int]:
|
||||
"""Group call counts by hour of day."""
|
||||
by_hour: dict[int, int] = defaultdict(int)
|
||||
for r in records:
|
||||
by_hour[r.started_at.hour] += 1
|
||||
return dict(sorted(by_hour.items()))
|
||||
|
||||
@property
|
||||
def total_calls_recorded(self) -> int:
|
||||
return len(self._call_records)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Data Models
|
||||
# ================================================================
|
||||
|
||||
class CallRecord:
|
||||
"""A completed call record for analytics."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
call_id: str,
|
||||
remote_number: str,
|
||||
mode: CallMode,
|
||||
status: CallStatus,
|
||||
intent: Optional[str] = None,
|
||||
started_at: Optional[datetime] = None,
|
||||
duration_seconds: int = 0,
|
||||
hold_time_seconds: int = 0,
|
||||
classification_history: Optional[list[str]] = None,
|
||||
transcript_chunks: Optional[list[str]] = None,
|
||||
services: Optional[list[str]] = None,
|
||||
):
|
||||
self.call_id = call_id
|
||||
self.remote_number = remote_number
|
||||
self.mode = mode
|
||||
self.status = status
|
||||
self.intent = intent
|
||||
self.started_at = started_at or datetime.now()
|
||||
self.duration_seconds = duration_seconds
|
||||
self.hold_time_seconds = hold_time_seconds
|
||||
self.classification_history = classification_history or []
|
||||
self.transcript_chunks = transcript_chunks or []
|
||||
self.services = services or []
|
||||
|
||||
|
||||
class CompanyStats:
|
||||
"""Aggregated stats for a specific company/phone number."""
|
||||
|
||||
def __init__(self):
|
||||
self.total_calls = 0
|
||||
self.successful_calls = 0
|
||||
self.failed_calls = 0
|
||||
self.total_hold_time = 0
|
||||
self.hold_times: list[int] = []
|
||||
self.total_duration = 0
|
||||
self.last_called: Optional[datetime] = None
|
||||
self.intents: dict[str, int] = defaultdict(int)
|
||||
|
||||
def update(self, record: CallRecord) -> None:
|
||||
"""Update stats with a new call record."""
|
||||
self.total_calls += 1
|
||||
self.total_duration += record.duration_seconds
|
||||
self.last_called = record.started_at
|
||||
|
||||
if record.status in (CallStatus.COMPLETED, CallStatus.BRIDGED, CallStatus.HUMAN_DETECTED):
|
||||
self.successful_calls += 1
|
||||
elif record.status == CallStatus.FAILED:
|
||||
self.failed_calls += 1
|
||||
|
||||
if record.hold_time_seconds > 0:
|
||||
self.total_hold_time += record.hold_time_seconds
|
||||
self.hold_times.append(record.hold_time_seconds)
|
||||
|
||||
if record.intent:
|
||||
self.intents[record.intent] += 1
|
||||
|
||||
def to_dict(self, number: str) -> dict[str, Any]:
|
||||
return {
|
||||
"number": number,
|
||||
"total_calls": self.total_calls,
|
||||
"successful_calls": self.successful_calls,
|
||||
"failed_calls": self.failed_calls,
|
||||
"success_rate": round(
|
||||
self.successful_calls / self.total_calls, 3
|
||||
) if self.total_calls else 0.0,
|
||||
"avg_hold_time": round(
|
||||
self.total_hold_time / len(self.hold_times), 1
|
||||
) if self.hold_times else 0.0,
|
||||
"max_hold_time": max(self.hold_times) if self.hold_times else 0,
|
||||
"avg_duration": round(
|
||||
self.total_duration / self.total_calls, 1
|
||||
) if self.total_calls else 0.0,
|
||||
"last_called": self.last_called.isoformat() if self.last_called else None,
|
||||
"top_intents": dict(
|
||||
sorted(self.intents.items(), key=lambda x: x[1], reverse=True)[:5]
|
||||
),
|
||||
}
|
||||
@@ -1,70 +1,368 @@
|
||||
"""
|
||||
Call Persistence — Writes completed calls and their transcript chunks
|
||||
to the database when CallManager.end_call() fires.
|
||||
Call Persistence — the data-access layer for calls and call flows.
|
||||
|
||||
Holds the on-hangup persistence hook plus the query/write functions
|
||||
that both the REST handlers and the MCP tools call, so the two
|
||||
surfaces can't drift. Every function takes an AsyncSession; callers
|
||||
own the transaction (get_db for REST, session_scope for MCP/services).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from db.database import CallRecord, TranscriptChunk, get_session_factory
|
||||
from sqlalchemy import desc, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db.database import (
|
||||
CallRecord,
|
||||
RecordingRecord,
|
||||
StoredCallFlow,
|
||||
TranscriptChunk,
|
||||
session_scope,
|
||||
)
|
||||
from db.database import Device as DeviceRow
|
||||
from models.call import ActiveCall, CallStatus
|
||||
from models.call_flow import CallFlow, CallFlowStep
|
||||
from models.device import Device
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def persist_call_on_end(call: ActiveCall, final_status: CallStatus) -> None:
|
||||
"""Insert a CallRecord and any transcript chunks for `call`.
|
||||
def flow_to_model(row: StoredCallFlow) -> CallFlow:
|
||||
"""The one StoredCallFlow-row → CallFlow-model mapping."""
|
||||
return CallFlow(
|
||||
id=row.id,
|
||||
name=row.name,
|
||||
phone_number=row.phone_number,
|
||||
description=row.description or "",
|
||||
steps=[CallFlowStep(**s) for s in (row.steps or [])],
|
||||
tags=row.tags or [],
|
||||
notes=row.notes,
|
||||
avg_hold_time=row.avg_hold_time,
|
||||
success_rate=row.success_rate,
|
||||
last_used=row.last_used,
|
||||
times_used=row.times_used or 0,
|
||||
)
|
||||
|
||||
Wired into CallManager via _on_call_ended in gateway.start().
|
||||
|
||||
def record_summary(row: CallRecord) -> dict:
|
||||
"""The one CallRecord-row → list-item mapping."""
|
||||
return {
|
||||
"id": row.id,
|
||||
"direction": row.direction,
|
||||
"remote_number": row.remote_number,
|
||||
"status": row.status,
|
||||
"mode": row.mode,
|
||||
"intent": row.intent,
|
||||
"started_at": row.started_at.isoformat() if row.started_at else None,
|
||||
"ended_at": row.ended_at.isoformat() if row.ended_at else None,
|
||||
"duration": row.duration,
|
||||
"hold_time": row.hold_time,
|
||||
"device_used": row.device_used,
|
||||
"summary": row.summary,
|
||||
}
|
||||
|
||||
|
||||
def record_detail(row: CallRecord) -> dict:
|
||||
"""Full CallRecord-row mapping, superset of record_summary."""
|
||||
return record_summary(row) | {
|
||||
"action_items": row.action_items,
|
||||
"sentiment": row.sentiment,
|
||||
"call_flow_id": row.call_flow_id,
|
||||
"classification_timeline": row.classification_timeline,
|
||||
}
|
||||
|
||||
|
||||
def chunk_to_dict(row: TranscriptChunk) -> dict:
|
||||
"""The one TranscriptChunk-row → dict mapping."""
|
||||
return {
|
||||
"seq": row.seq,
|
||||
"t_offset_ms": row.t_offset_ms,
|
||||
"speaker": row.speaker,
|
||||
"text": row.text,
|
||||
"confidence": row.confidence,
|
||||
}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Call flows
|
||||
# ================================================================
|
||||
|
||||
async def get_flow(session: AsyncSession, flow_id: str) -> StoredCallFlow | None:
|
||||
result = await session.execute(
|
||||
select(StoredCallFlow).where(StoredCallFlow.id == flow_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_flow_by_number(
|
||||
session: AsyncSession, phone_number: str
|
||||
) -> StoredCallFlow | None:
|
||||
result = await session.execute(
|
||||
select(StoredCallFlow).where(StoredCallFlow.phone_number == phone_number)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_flows(session: AsyncSession) -> list[StoredCallFlow]:
|
||||
result = await session.execute(select(StoredCallFlow))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def create_flow(
|
||||
session: AsyncSession,
|
||||
flow_id: str,
|
||||
name: str,
|
||||
phone_number: str,
|
||||
steps: list[dict],
|
||||
description: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
notes: str | None = None,
|
||||
) -> StoredCallFlow:
|
||||
row = StoredCallFlow(
|
||||
id=flow_id,
|
||||
name=name,
|
||||
phone_number=phone_number,
|
||||
description=description,
|
||||
steps=steps,
|
||||
tags=tags,
|
||||
notes=notes,
|
||||
last_verified=datetime.now(),
|
||||
)
|
||||
session.add(row)
|
||||
await session.flush()
|
||||
return row
|
||||
|
||||
|
||||
async def save_learned_flow(session: AsyncSession, flow: CallFlow) -> StoredCallFlow:
|
||||
"""The one CallFlow-model → row mapping (auto-learned flows)."""
|
||||
row = StoredCallFlow(
|
||||
id=flow.id,
|
||||
name=flow.name,
|
||||
phone_number=flow.phone_number,
|
||||
description=flow.description,
|
||||
steps=[s.model_dump(mode="json") for s in flow.steps],
|
||||
tags=flow.tags,
|
||||
notes=flow.notes,
|
||||
times_used=flow.times_used,
|
||||
last_used=flow.last_used,
|
||||
last_verified=datetime.now(),
|
||||
)
|
||||
session.add(row)
|
||||
await session.flush()
|
||||
return row
|
||||
|
||||
|
||||
async def update_flow_from_model(
|
||||
session: AsyncSession, row: StoredCallFlow, flow: CallFlow
|
||||
) -> None:
|
||||
"""Write a refined CallFlow back onto its existing row."""
|
||||
row.steps = [s.model_dump(mode="json") for s in flow.steps]
|
||||
row.times_used = flow.times_used
|
||||
row.last_used = flow.last_used
|
||||
row.notes = flow.notes
|
||||
await session.flush()
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Devices
|
||||
# ================================================================
|
||||
|
||||
async def create_device_row(session: AsyncSession, device: Device) -> None:
|
||||
"""The one Device-model → row mapping."""
|
||||
session.add(DeviceRow(
|
||||
id=device.id,
|
||||
name=device.name,
|
||||
type=device.type.value,
|
||||
sip_uri=device.sip_uri,
|
||||
phone_number=device.phone_number,
|
||||
priority=device.priority,
|
||||
capabilities=device.capabilities,
|
||||
is_online=device.is_online,
|
||||
))
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def update_device_row(
|
||||
session: AsyncSession, device_id: str, values: dict
|
||||
) -> None:
|
||||
result = await session.execute(
|
||||
select(DeviceRow).where(DeviceRow.id == device_id)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
if row is None:
|
||||
return
|
||||
for key, value in values.items():
|
||||
if key == "type" and value is not None:
|
||||
value = value.value if hasattr(value, "value") else value
|
||||
setattr(row, key, value)
|
||||
|
||||
|
||||
async def delete_device_row(session: AsyncSession, device_id: str) -> None:
|
||||
result = await session.execute(
|
||||
select(DeviceRow).where(DeviceRow.id == device_id)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
if row is not None:
|
||||
await session.delete(row)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Call history / records
|
||||
# ================================================================
|
||||
|
||||
async def get_record(session: AsyncSession, call_id: str) -> CallRecord | None:
|
||||
result = await session.execute(
|
||||
select(CallRecord).where(CallRecord.id == call_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def search_history(
|
||||
session: AsyncSession,
|
||||
number: str | None = None,
|
||||
number_contains: str | None = None,
|
||||
intent_contains: str | None = None,
|
||||
status: str | None = None,
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[CallRecord]:
|
||||
stmt = select(CallRecord).order_by(desc(CallRecord.started_at))
|
||||
if number:
|
||||
stmt = stmt.where(CallRecord.remote_number == number)
|
||||
if number_contains:
|
||||
stmt = stmt.where(CallRecord.remote_number.contains(number_contains))
|
||||
if intent_contains:
|
||||
stmt = stmt.where(CallRecord.intent.icontains(intent_contains))
|
||||
if status:
|
||||
stmt = stmt.where(CallRecord.status == status)
|
||||
if since:
|
||||
stmt = stmt.where(CallRecord.started_at >= since)
|
||||
if until:
|
||||
stmt = stmt.where(CallRecord.started_at <= until)
|
||||
result = await session.execute(stmt.offset(offset).limit(limit))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_transcript_chunks(
|
||||
session: AsyncSession, call_id: str
|
||||
) -> list[TranscriptChunk]:
|
||||
result = await session.execute(
|
||||
select(TranscriptChunk)
|
||||
.where(TranscriptChunk.call_id == call_id)
|
||||
.order_by(TranscriptChunk.seq)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def latest_recording(
|
||||
session: AsyncSession, call_id: str
|
||||
) -> RecordingRecord | None:
|
||||
result = await session.execute(
|
||||
select(RecordingRecord)
|
||||
.where(RecordingRecord.call_id == call_id)
|
||||
.order_by(desc(RecordingRecord.started_at))
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def persist_call_on_create(call: ActiveCall) -> None:
|
||||
"""Insert an in_progress CallRecord the moment a call starts.
|
||||
|
||||
Wired into CallManager as its on_call_created hook — a crash
|
||||
mid-call leaves this row behind instead of erasing the call from
|
||||
history. persist_call_on_end updates it to the terminal state.
|
||||
"""
|
||||
try:
|
||||
async with get_session_factory()() as session:
|
||||
record = CallRecord(
|
||||
id=call.id,
|
||||
direction=call.direction,
|
||||
remote_number=call.remote_number,
|
||||
status=final_status.value,
|
||||
mode=call.mode.value,
|
||||
intent=call.intent,
|
||||
started_at=call.started_at,
|
||||
ended_at=datetime.now(),
|
||||
duration=int(call.duration),
|
||||
hold_time=int(call.hold_time),
|
||||
device_used=call.device,
|
||||
call_flow_id=call.call_flow_id,
|
||||
classification_timeline=[
|
||||
{
|
||||
"timestamp": c.timestamp,
|
||||
"audio_type": c.audio_type.value,
|
||||
"confidence": c.confidence,
|
||||
}
|
||||
for c in call.classification_history
|
||||
],
|
||||
metadata_={"services": list(call.services)},
|
||||
)
|
||||
await _with_retry(_insert_in_progress_record, call)
|
||||
|
||||
|
||||
async def persist_call_on_end(call: ActiveCall, final_status: CallStatus) -> None:
|
||||
"""Finalize the CallRecord and write transcript chunks for `call`.
|
||||
|
||||
Wired into CallManager as its on_call_ended hook by the
|
||||
composition root in main.py.
|
||||
"""
|
||||
await _with_retry(_finalize_call_record, call, final_status)
|
||||
|
||||
|
||||
async def _with_retry(write, call: ActiveCall, *args) -> None:
|
||||
"""Losing the row means the call never happened as far as history
|
||||
is concerned, so the final failure logs at ERROR with identifiers."""
|
||||
for attempt in range(3):
|
||||
try:
|
||||
await write(call, *args)
|
||||
return
|
||||
except Exception as e:
|
||||
if attempt == 2:
|
||||
logger.error(
|
||||
f"Call record lost ({write.__name__}): id={call.id} "
|
||||
f"number={call.remote_number}: {e}"
|
||||
)
|
||||
return
|
||||
await asyncio.sleep(2**attempt)
|
||||
|
||||
|
||||
async def _insert_in_progress_record(call: ActiveCall) -> None:
|
||||
async with session_scope() as session:
|
||||
session.add(CallRecord(
|
||||
id=call.id,
|
||||
direction=call.direction,
|
||||
remote_number=call.remote_number,
|
||||
status="in_progress",
|
||||
mode=call.mode.value,
|
||||
intent=call.intent,
|
||||
started_at=call.started_at,
|
||||
device_used=call.device,
|
||||
call_flow_id=call.call_flow_id,
|
||||
metadata_={"services": list(call.services)},
|
||||
))
|
||||
|
||||
|
||||
async def _finalize_call_record(call: ActiveCall, final_status: CallStatus) -> None:
|
||||
async with session_scope() as session:
|
||||
record = await get_record(session, call.id)
|
||||
if record is None:
|
||||
# The create-time insert failed (or predates the hook);
|
||||
# write the whole row now instead.
|
||||
record = CallRecord(id=call.id)
|
||||
session.add(record)
|
||||
|
||||
# Each transcript chunk gets its own row with a sequence number
|
||||
# so the dashboard can render them in order with click-to-seek.
|
||||
for seq, text in enumerate(call.transcript_chunks):
|
||||
speaker = "unknown"
|
||||
payload = text
|
||||
if ":" in text:
|
||||
head, rest = text.split(":", 1)
|
||||
head = head.strip().lower()
|
||||
if head in {"caller", "agent", "receptionist", "caller_message"}:
|
||||
speaker = head if head != "caller_message" else "caller"
|
||||
payload = rest.strip()
|
||||
session.add(TranscriptChunk(
|
||||
id=f"tc_{uuid.uuid4().hex[:10]}",
|
||||
call_id=call.id,
|
||||
seq=seq,
|
||||
t_offset_ms=0,
|
||||
speaker=speaker,
|
||||
text=payload,
|
||||
))
|
||||
record.direction = call.direction
|
||||
record.remote_number = call.remote_number
|
||||
record.status = final_status.value
|
||||
record.mode = call.mode.value
|
||||
record.intent = call.intent
|
||||
record.started_at = call.started_at
|
||||
record.ended_at = datetime.now()
|
||||
record.duration = int(call.duration)
|
||||
record.hold_time = int(call.hold_time)
|
||||
record.device_used = call.device
|
||||
record.call_flow_id = call.call_flow_id
|
||||
record.classification_timeline = [
|
||||
{
|
||||
"timestamp": c.timestamp,
|
||||
"audio_type": c.audio_type.value,
|
||||
"confidence": c.confidence,
|
||||
}
|
||||
for c in call.classification_history
|
||||
]
|
||||
metadata = {"services": list(call.services)}
|
||||
if call.exploration_steps:
|
||||
metadata["exploration_steps"] = call.exploration_steps
|
||||
record.metadata_ = metadata
|
||||
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not persist call {call.id}: {e}")
|
||||
# Each transcript entry gets its own row with a sequence number
|
||||
# and real offset so the dashboard can render click-to-seek.
|
||||
for seq, entry in enumerate(call.transcript_chunks):
|
||||
session.add(TranscriptChunk(
|
||||
id=f"tc_{uuid.uuid4().hex[:10]}",
|
||||
call_id=call.id,
|
||||
seq=seq,
|
||||
t_offset_ms=entry.t_offset_ms,
|
||||
speaker=entry.speaker,
|
||||
text=entry.text,
|
||||
))
|
||||
|
||||
@@ -23,35 +23,12 @@ from models.call import ActiveCall, AudioClassification, CallStatus, Classificat
|
||||
from models.call_flow import ActionType, CallFlow, CallFlowStep
|
||||
from models.events import EventType, GatewayEvent
|
||||
from services.audio_classifier import AudioClassifier
|
||||
from services.llm_client import get_llm
|
||||
from services.transcription import TranscriptionService
|
||||
from services.tts import TTSService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# LLM client is optional — imported at use time
|
||||
_llm_client = None
|
||||
|
||||
|
||||
def _get_llm():
|
||||
"""Lazy-load LLM client (optional dependency)."""
|
||||
global _llm_client
|
||||
if _llm_client is None:
|
||||
try:
|
||||
from config import get_settings
|
||||
from services.llm_client import LLMClient
|
||||
|
||||
settings = get_settings()
|
||||
_llm_client = LLMClient(
|
||||
base_url=settings.llm.base_url,
|
||||
model=settings.llm.model,
|
||||
api_key=settings.llm.api_key.get_secret_value(),
|
||||
timeout=settings.llm.timeout,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"LLM client not available: {e}")
|
||||
_llm_client = False # Sentinel: don't retry
|
||||
return _llm_client if _llm_client is not False else None
|
||||
|
||||
|
||||
class HoldSlayerService:
|
||||
"""
|
||||
@@ -79,6 +56,29 @@ class HoldSlayerService:
|
||||
self.settings = settings
|
||||
self.tts = tts
|
||||
|
||||
async def _service_error(self, call_id: str, service: str, error: Exception) -> None:
|
||||
"""Surface a failed dependency as a typed event, not silence."""
|
||||
logger.error(f"⚠️ {service} failed for {call_id}: {error}")
|
||||
try:
|
||||
await self.gateway.event_bus.publish(GatewayEvent(
|
||||
type=EventType.ERROR,
|
||||
call_id=call_id,
|
||||
data={"service": service, "error": str(error)},
|
||||
message=f"⚠️ {service} failed: {error}",
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _transcribe(
|
||||
self, call_id: str, audio: bytes, prompt: Optional[str] = None
|
||||
) -> str:
|
||||
"""Transcribe with an explicit empty-string fallback on failure."""
|
||||
try:
|
||||
return await self.transcription.transcribe(audio, prompt=prompt)
|
||||
except Exception as e:
|
||||
await self._service_error(call_id, "transcription", e)
|
||||
return ""
|
||||
|
||||
async def run(
|
||||
self,
|
||||
call: ActiveCall,
|
||||
@@ -228,7 +228,7 @@ class HoldSlayerService:
|
||||
|
||||
# Phase 2: LLM fallback if regex couldn't decide
|
||||
if not decision and transcript:
|
||||
llm = _get_llm()
|
||||
llm = get_llm()
|
||||
if llm:
|
||||
try:
|
||||
logger.info("🤖 Regex inconclusive, asking LLM...")
|
||||
@@ -294,7 +294,7 @@ class HoldSlayerService:
|
||||
logger.info(f"🔍 Exploration mode: discovering IVR for {call.remote_number}")
|
||||
await self.call_manager.update_status(call.id, CallStatus.NAVIGATING_IVR)
|
||||
|
||||
discovered_steps: list[dict] = []
|
||||
discovered_steps = call.exploration_steps # persisted with the record
|
||||
max_time = self.settings.hold_slayer.max_hold_time
|
||||
start_time = time.time()
|
||||
|
||||
@@ -323,8 +323,7 @@ class HoldSlayerService:
|
||||
continue
|
||||
|
||||
# Classify the audio
|
||||
classification = self.classifier.classify_chunk(audio_chunk)
|
||||
self.classifier.update_history(classification.audio_type)
|
||||
classification = await self.classifier.classify(audio_chunk)
|
||||
await self.call_manager.add_classification(call.id, classification)
|
||||
|
||||
# Transcribe if it sounds like speech
|
||||
@@ -333,9 +332,10 @@ class HoldSlayerService:
|
||||
AudioClassification.IVR_PROMPT,
|
||||
AudioClassification.LIVE_HUMAN,
|
||||
):
|
||||
transcript = await self.transcription.transcribe(
|
||||
transcript = await self._transcribe(
|
||||
call.id,
|
||||
audio_chunk,
|
||||
prompt="Phone IVR menu, customer service, press 1 for..."
|
||||
prompt="Phone IVR menu, customer service, press 1 for...",
|
||||
)
|
||||
if transcript:
|
||||
await self.call_manager.add_transcript(call.id, transcript)
|
||||
@@ -447,14 +447,13 @@ class HoldSlayerService:
|
||||
continue
|
||||
|
||||
# Classify
|
||||
result = self.classifier.classify_chunk(audio_chunk)
|
||||
self.classifier.update_history(result.audio_type)
|
||||
result = await self.classifier.classify(audio_chunk)
|
||||
await self.call_manager.add_classification(call.id, result)
|
||||
|
||||
# Check for human
|
||||
if result.audio_type == AudioClassification.LIVE_HUMAN:
|
||||
# Verify with transcription
|
||||
transcript = await self.transcription.transcribe(audio_chunk)
|
||||
transcript = await self._transcribe(call.id, audio_chunk)
|
||||
if transcript:
|
||||
await self.call_manager.add_transcript(call.id, transcript)
|
||||
# If we got meaningful speech, it's probably a real person
|
||||
@@ -508,7 +507,7 @@ class HoldSlayerService:
|
||||
continue
|
||||
|
||||
# Classify first
|
||||
result = self.classifier.classify_chunk(audio_chunk)
|
||||
result = await self.classifier.classify(audio_chunk)
|
||||
if result.audio_type not in (
|
||||
AudioClassification.IVR_PROMPT,
|
||||
AudioClassification.LIVE_HUMAN,
|
||||
@@ -516,7 +515,7 @@ class HoldSlayerService:
|
||||
continue
|
||||
|
||||
# Transcribe
|
||||
transcript = await self.transcription.transcribe(audio_chunk)
|
||||
transcript = await self._transcribe(call.id, audio_chunk)
|
||||
if not transcript:
|
||||
continue
|
||||
|
||||
@@ -560,7 +559,7 @@ class HoldSlayerService:
|
||||
if not audio_chunk:
|
||||
break
|
||||
|
||||
result = self.classifier.classify_chunk(audio_chunk)
|
||||
result = await self.classifier.classify(audio_chunk)
|
||||
|
||||
# If we're getting silence after speech, the menu prompt is done
|
||||
if result.audio_type == AudioClassification.SILENCE and transcript_parts:
|
||||
@@ -570,7 +569,7 @@ class HoldSlayerService:
|
||||
AudioClassification.IVR_PROMPT,
|
||||
AudioClassification.LIVE_HUMAN,
|
||||
):
|
||||
text = await self.transcription.transcribe(audio_chunk)
|
||||
text = await self._transcribe(call.id, audio_chunk)
|
||||
if text:
|
||||
transcript_parts.append(text)
|
||||
|
||||
@@ -738,7 +737,11 @@ class HoldSlayerService:
|
||||
os.close(fd)
|
||||
|
||||
try:
|
||||
ok = await self.tts.synthesize_to_file(text, tmp_path)
|
||||
try:
|
||||
ok = await self.tts.synthesize_to_file(text, tmp_path)
|
||||
except Exception as e:
|
||||
await self._service_error(call.id, "tts", e)
|
||||
return False
|
||||
if not ok:
|
||||
logger.warning(f"🗣️ TTS synthesis returned no audio for: '{text[:60]}'")
|
||||
return False
|
||||
|
||||
@@ -327,15 +327,14 @@ class LLMClient:
|
||||
except httpx.HTTPStatusError as e:
|
||||
self._total_errors += 1
|
||||
logger.error(f"LLM API error: {e.response.status_code} {e.response.text[:200]}")
|
||||
return ""
|
||||
raise
|
||||
except httpx.TimeoutException:
|
||||
self._total_errors += 1
|
||||
logger.error(f"LLM API timeout after {self.timeout}s")
|
||||
return ""
|
||||
except Exception as e:
|
||||
raise
|
||||
except Exception:
|
||||
self._total_errors += 1
|
||||
logger.error(f"LLM client error: {e}")
|
||||
return ""
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _parse_json_response(text: str) -> dict[str, Any]:
|
||||
@@ -389,3 +388,31 @@ class LLMClient:
|
||||
"model": self.model,
|
||||
"base_url": self.base_url,
|
||||
}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Shared lazy client
|
||||
# ================================================================
|
||||
|
||||
_shared_client: Optional["LLMClient"] = None
|
||||
_shared_failed = False
|
||||
|
||||
|
||||
def get_llm() -> Optional["LLMClient"]:
|
||||
"""Lazily build the shared LLMClient from settings (None if unavailable)."""
|
||||
global _shared_client, _shared_failed
|
||||
if _shared_client is None and not _shared_failed:
|
||||
try:
|
||||
from config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
_shared_client = LLMClient(
|
||||
base_url=settings.llm.base_url,
|
||||
model=settings.llm.model,
|
||||
api_key=settings.llm.api_key.get_secret_value(),
|
||||
timeout=settings.llm.timeout,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"LLM client not available: {e}")
|
||||
_shared_failed = True # don't retry
|
||||
return _shared_client
|
||||
|
||||
@@ -67,7 +67,6 @@ class NotificationService:
|
||||
self._event_bus = event_bus
|
||||
self._settings = settings
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._sms_sender: Optional[Any] = None
|
||||
|
||||
# Track what we've already notified (avoid spam)
|
||||
self._notified: dict[str, set[str]] = {} # call_id -> set of event types
|
||||
@@ -214,43 +213,3 @@ class NotificationService:
|
||||
|
||||
# WebSocket notifications go through the event bus
|
||||
# (the WebSocket handler in the API reads from EventBus directly)
|
||||
|
||||
# SMS for critical notifications
|
||||
if (
|
||||
notification.priority == NotificationPriority.CRITICAL
|
||||
and self._settings.notify_sms_number
|
||||
):
|
||||
await self._send_sms(notification)
|
||||
|
||||
async def _send_sms(self, notification: Notification) -> None:
|
||||
"""
|
||||
Send an SMS notification.
|
||||
|
||||
Uses a simple HTTP-based SMS gateway. In production,
|
||||
this would use Twilio, AWS SNS, or similar.
|
||||
"""
|
||||
phone = self._settings.notify_sms_number
|
||||
if not phone:
|
||||
return
|
||||
|
||||
try:
|
||||
import httpx
|
||||
|
||||
# Generic webhook-based SMS (configure your provider)
|
||||
# This is a placeholder — wire up your preferred SMS provider
|
||||
logger.info(f"📱 SMS → {phone}: {notification.title}")
|
||||
|
||||
# Example: Twilio-style API
|
||||
# async with httpx.AsyncClient() as client:
|
||||
# await client.post(
|
||||
# "https://api.twilio.com/2010-04-01/Accounts/.../Messages.json",
|
||||
# data={
|
||||
# "To": phone,
|
||||
# "From": self._settings.sip_trunk.did,
|
||||
# "Body": f"{notification.title}\n{notification.message}",
|
||||
# },
|
||||
# auth=(account_sid, auth_token),
|
||||
# )
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"SMS send failed: {e}")
|
||||
|
||||
@@ -27,12 +27,100 @@ from models.routing import RoutingAction, RoutingActionType, RoutingDecision
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ReceptionistService:
|
||||
"""Drives the receptionist state machine for a single inbound call."""
|
||||
def _extract_number(sip_uri: str) -> str:
|
||||
"""Pull the user part out of a SIP URI (sip:+15551212@host → +15551212)."""
|
||||
if not sip_uri:
|
||||
return ""
|
||||
s = sip_uri.strip()
|
||||
if s.startswith("<") and ">" in s:
|
||||
s = s[1 : s.index(">")]
|
||||
if s.startswith("sip:"):
|
||||
s = s[4:]
|
||||
if "@" in s:
|
||||
s = s.split("@", 1)[0]
|
||||
return s
|
||||
|
||||
def __init__(self, gateway):
|
||||
|
||||
class ReceptionistService:
|
||||
"""Owns inbound-call policy: routing evaluation, screening, voicemail."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
gateway,
|
||||
tts=None,
|
||||
transcription=None,
|
||||
recording=None,
|
||||
routing=None,
|
||||
):
|
||||
self.gateway = gateway
|
||||
self.settings = gateway.settings.receptionist
|
||||
self.tts = tts
|
||||
self.transcription = transcription
|
||||
self.recording = recording
|
||||
self.routing = routing
|
||||
|
||||
async def on_inbound_call(self, from_uri: str, to_uri: str, leg_id: str) -> None:
|
||||
"""
|
||||
Entry point for an inbound INVITE (wired as the SIP engine's
|
||||
on_incoming_call by the composition root).
|
||||
|
||||
Evaluates routing rules, then either rejects (rule says
|
||||
reject/DND) or answers and runs the screening flow.
|
||||
"""
|
||||
from models.call import CallMode
|
||||
|
||||
gateway = self.gateway
|
||||
caller_number = _extract_number(from_uri)
|
||||
dnis = _extract_number(to_uri)
|
||||
|
||||
# Create a call record so the dashboard sees the ringing call.
|
||||
call = await gateway.call_manager.create_call(
|
||||
remote_number=caller_number,
|
||||
mode=CallMode.RECEPTIONIST,
|
||||
intent=None,
|
||||
call_flow_id=None,
|
||||
device=None,
|
||||
)
|
||||
call.direction = "inbound"
|
||||
gateway.call_manager.map_leg(leg_id, call.id)
|
||||
await gateway.call_manager.update_status(call.id, CallStatus.RINGING)
|
||||
|
||||
decision = (
|
||||
await self.routing.evaluate(caller_number, dnis)
|
||||
if self.routing is not None
|
||||
else None
|
||||
)
|
||||
|
||||
if decision is not None:
|
||||
await gateway.event_bus.publish(GatewayEvent(
|
||||
type=EventType.ROUTING_RULE_MATCHED,
|
||||
call_id=call.id,
|
||||
data={
|
||||
"matched_rule_id": decision.matched_rule_id,
|
||||
"matched_rule_name": decision.matched_rule_name,
|
||||
"action": decision.action.type.value,
|
||||
"reason": decision.reason,
|
||||
},
|
||||
message=decision.reason,
|
||||
))
|
||||
|
||||
if decision.action.type in (RoutingActionType.REJECT, RoutingActionType.DND):
|
||||
if hasattr(gateway.sip_engine, "reject_inbound"):
|
||||
await gateway.sip_engine.reject_inbound(leg_id)
|
||||
await gateway.call_manager.end_call(call.id, CallStatus.COMPLETED)
|
||||
return
|
||||
|
||||
# Answer the leg
|
||||
if hasattr(gateway.sip_engine, "accept_inbound"):
|
||||
await gateway.sip_engine.accept_inbound(leg_id)
|
||||
await gateway.call_manager.update_status(call.id, CallStatus.CONNECTED)
|
||||
|
||||
# Screen the caller (unless the receptionist is disabled)
|
||||
if self.settings.enabled:
|
||||
gateway.spawn(
|
||||
self.handle(call, leg_id, decision),
|
||||
name=f"receptionist_{call.id}",
|
||||
)
|
||||
|
||||
async def handle(
|
||||
self,
|
||||
@@ -46,13 +134,9 @@ class ReceptionistService:
|
||||
|
||||
transcript = await self._listen(call, sip_leg_id)
|
||||
if transcript:
|
||||
call.transcript_chunks.append(f"caller: {transcript}")
|
||||
await self.gateway.event_bus.publish(GatewayEvent(
|
||||
type=EventType.TRANSCRIPT_CHUNK,
|
||||
call_id=call.id,
|
||||
data={"text": transcript, "speaker": "caller"},
|
||||
message=f"📝 caller: {transcript[:80]}",
|
||||
))
|
||||
await self.gateway.call_manager.add_transcript(
|
||||
call.id, transcript, speaker="caller"
|
||||
)
|
||||
|
||||
classification = await self._classify(call, transcript, routing_decision)
|
||||
call.intent = classification.get("intent")
|
||||
@@ -89,7 +173,10 @@ class ReceptionistService:
|
||||
await self._speak(
|
||||
call, sip_leg_id, "One moment, I'll connect you now."
|
||||
)
|
||||
answered = await self.gateway._routing.ring_chain(
|
||||
if self.routing is None:
|
||||
await self._take_message(call, sip_leg_id)
|
||||
return
|
||||
answered = await self.routing.ring_chain(
|
||||
call.id, devices, action.ring_timeout
|
||||
)
|
||||
if answered:
|
||||
@@ -112,6 +199,19 @@ class ReceptionistService:
|
||||
# State machine steps
|
||||
# ----------------------------------------------------------------
|
||||
|
||||
async def _service_error(self, call_id: str, service: str, error: Exception) -> None:
|
||||
"""Surface a failed dependency as a typed event, not silence."""
|
||||
logger.error(f"⚠️ {service} failed for {call_id}: {error}")
|
||||
try:
|
||||
await self.gateway.event_bus.publish(GatewayEvent(
|
||||
type=EventType.ERROR,
|
||||
call_id=call_id,
|
||||
data={"service": service, "error": str(error)},
|
||||
message=f"⚠️ {service} failed: {error}",
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _greet(self, call: ActiveCall, sip_leg_id: str) -> None:
|
||||
await self.gateway.event_bus.publish(GatewayEvent(
|
||||
type=EventType.RECEPTIONIST_GREETING,
|
||||
@@ -156,10 +256,14 @@ class ReceptionistService:
|
||||
finally:
|
||||
tap.close()
|
||||
|
||||
if not audio:
|
||||
if not audio or self.transcription is None:
|
||||
return ""
|
||||
|
||||
return await self.gateway._transcription.transcribe(bytes(audio))
|
||||
try:
|
||||
return await self.transcription.transcribe(bytes(audio))
|
||||
except Exception as e:
|
||||
await self._service_error(call.id, "transcription", e)
|
||||
return ""
|
||||
|
||||
async def _classify(
|
||||
self,
|
||||
@@ -168,9 +272,9 @@ class ReceptionistService:
|
||||
routing_decision: Optional[RoutingDecision],
|
||||
) -> dict:
|
||||
"""Ask the LLM to interpret the caller's utterance."""
|
||||
from services.hold_slayer import _get_llm
|
||||
from services.llm_client import get_llm
|
||||
|
||||
llm = _get_llm()
|
||||
llm = get_llm()
|
||||
if llm is None or not transcript.strip():
|
||||
return {
|
||||
"intent": transcript or "unknown",
|
||||
@@ -200,7 +304,7 @@ class ReceptionistService:
|
||||
system=self.settings.llm_persona,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Receptionist LLM classify failed: {e}")
|
||||
await self._service_error(call.id, "llm", e)
|
||||
return {
|
||||
"intent": transcript,
|
||||
"urgency": "normal",
|
||||
@@ -213,10 +317,14 @@ class ReceptionistService:
|
||||
routing_decision: Optional[RoutingDecision],
|
||||
classification: dict,
|
||||
) -> RoutingAction:
|
||||
"""Rules win on conflict; otherwise use the LLM's recommendation."""
|
||||
if routing_decision and routing_decision.action.type not in (
|
||||
RoutingActionType.TAKE_MESSAGE,
|
||||
):
|
||||
"""Rules win on conflict; otherwise use the LLM's recommendation.
|
||||
|
||||
A decision counts as a rule only when one actually matched
|
||||
(matched_rule_id set) — the no-rule default is take_message and
|
||||
must stay overridable by the LLM. A matched TAKE_MESSAGE rule
|
||||
wins like any other rule.
|
||||
"""
|
||||
if routing_decision and routing_decision.matched_rule_id:
|
||||
return routing_decision.action
|
||||
|
||||
recommended = (classification.get("recommended_action") or "ring").lower()
|
||||
@@ -245,7 +353,7 @@ class ReceptionistService:
|
||||
await self._speak(call, sip_leg_id, self.settings.message_prompt)
|
||||
|
||||
media = self.gateway.media_pipeline
|
||||
recording_svc = getattr(self.gateway, "_recording_service", None)
|
||||
recording_svc = self.recording
|
||||
if recording_svc is None or media is None:
|
||||
logger.warning("Receptionist: recording unavailable, ending call")
|
||||
await self._hangup(call, sip_leg_id)
|
||||
@@ -256,43 +364,51 @@ class ReceptionistService:
|
||||
call.id, media_pipeline=media, leg_ids=[sip_leg_id]
|
||||
)
|
||||
try:
|
||||
await asyncio.sleep(self.settings.message_max_seconds)
|
||||
# Record up to the cap, but stop early once the caller hangs
|
||||
# up (leg termination ends the call via the leg-state wiring).
|
||||
deadline = _time.monotonic() + self.settings.message_max_seconds
|
||||
while _time.monotonic() < deadline:
|
||||
await asyncio.sleep(1.0)
|
||||
if self.gateway.call_manager.get_call(call.id) is None:
|
||||
break
|
||||
finally:
|
||||
session = await recording_svc.stop_recording(
|
||||
call.id, media_pipeline=media
|
||||
)
|
||||
|
||||
message_text = ""
|
||||
rec_path = session.filepath_mixed if session else None
|
||||
if rec_path and Path(rec_path).exists():
|
||||
try:
|
||||
audio_bytes = Path(rec_path).read_bytes()
|
||||
message_text = await self.gateway._transcription.transcribe(audio_bytes)
|
||||
except Exception as e:
|
||||
logger.warning(f"Receptionist transcribe failed: {e}")
|
||||
message_text = ""
|
||||
rec_path = session.filepath_mixed if session else None
|
||||
if rec_path and Path(rec_path).exists() and self.transcription is not None:
|
||||
try:
|
||||
audio_bytes = Path(rec_path).read_bytes()
|
||||
message_text = await self.transcription.transcribe(audio_bytes)
|
||||
except Exception as e:
|
||||
await self._service_error(call.id, "transcription", e)
|
||||
|
||||
if message_text:
|
||||
call.transcript_chunks.append(f"caller_message: {message_text}")
|
||||
if message_text:
|
||||
await self.gateway.call_manager.add_transcript(
|
||||
call.id, message_text, speaker="caller"
|
||||
)
|
||||
|
||||
await self.gateway.event_bus.publish(GatewayEvent(
|
||||
type=EventType.RECEPTIONIST_MESSAGE_SAVED,
|
||||
call_id=call.id,
|
||||
data={
|
||||
"path": rec_path,
|
||||
"transcript": message_text,
|
||||
"caller": call.remote_number,
|
||||
},
|
||||
message=f"📥 Message saved from {call.remote_number}",
|
||||
))
|
||||
await self.gateway.event_bus.publish(GatewayEvent(
|
||||
type=EventType.RECEPTIONIST_MESSAGE_SAVED,
|
||||
call_id=call.id,
|
||||
data={
|
||||
"path": rec_path,
|
||||
"transcript": message_text,
|
||||
"caller": call.remote_number,
|
||||
},
|
||||
message=f"📥 Message saved from {call.remote_number}",
|
||||
))
|
||||
|
||||
await self._hangup(call, sip_leg_id)
|
||||
await self._hangup(call, sip_leg_id)
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Helpers
|
||||
# ----------------------------------------------------------------
|
||||
|
||||
async def _speak(self, call: ActiveCall, sip_leg_id: str, text: str) -> None:
|
||||
tts = self.gateway._tts
|
||||
tts = self.tts
|
||||
media = self.gateway.media_pipeline
|
||||
if tts is None or media is None or not text.strip():
|
||||
return
|
||||
@@ -303,7 +419,11 @@ class ReceptionistService:
|
||||
fd, tmp_path = tempfile.mkstemp(suffix=".wav", prefix=f"recept_{call.id}_")
|
||||
os.close(fd)
|
||||
try:
|
||||
ok = await tts.synthesize_to_file(text, tmp_path)
|
||||
try:
|
||||
ok = await tts.synthesize_to_file(text, tmp_path)
|
||||
except Exception as e:
|
||||
await self._service_error(call.id, "tts", e)
|
||||
return
|
||||
if not ok:
|
||||
return
|
||||
await media.play_wav(sip_leg_id, tmp_path)
|
||||
|
||||
@@ -39,6 +39,7 @@ class RecordingService:
|
||||
self._max_recording_seconds = max_recording_seconds
|
||||
self._sample_rate = sample_rate
|
||||
self._active_recordings: dict[str, RecordingSession] = {}
|
||||
self._timeout_tasks: dict[str, asyncio.Task] = {}
|
||||
self._metadata: list[dict] = []
|
||||
|
||||
async def start(self) -> None:
|
||||
@@ -90,6 +91,7 @@ class RecordingService:
|
||||
filepath_agent=filepath_agent,
|
||||
started_at=datetime.now(),
|
||||
sample_rate=self._sample_rate,
|
||||
leg_ids=leg_ids,
|
||||
)
|
||||
|
||||
# Start PJSUA2 recording if media pipeline is available
|
||||
@@ -101,8 +103,8 @@ class RecordingService:
|
||||
self._active_recordings[call_id] = session
|
||||
logger.info(f"🔴 Recording started: {call_id} → {filepath_mixed}")
|
||||
|
||||
# Safety timeout
|
||||
asyncio.create_task(
|
||||
# Safety timeout — tracked so it can be cancelled and isn't GC'd
|
||||
self._timeout_tasks[call_id] = asyncio.create_task(
|
||||
self._recording_timeout(call_id),
|
||||
name=f"rec_timeout_{call_id}",
|
||||
)
|
||||
@@ -115,6 +117,14 @@ class RecordingService:
|
||||
media_pipeline=None,
|
||||
) -> Optional["RecordingSession"]:
|
||||
"""Stop recording a call and finalize the WAV file."""
|
||||
timeout_task = self._timeout_tasks.pop(call_id, None)
|
||||
if (
|
||||
timeout_task is not None
|
||||
and timeout_task is not asyncio.current_task()
|
||||
and not timeout_task.done()
|
||||
):
|
||||
timeout_task.cancel()
|
||||
|
||||
session = self._active_recordings.pop(call_id, None)
|
||||
if not session:
|
||||
logger.warning(f" No active recording for {call_id}")
|
||||
@@ -150,26 +160,38 @@ class RecordingService:
|
||||
|
||||
@staticmethod
|
||||
async def _persist_recording(session: "RecordingSession") -> None:
|
||||
"""Write a recordings row for this session. Failures are non-fatal."""
|
||||
try:
|
||||
import uuid as _uuid
|
||||
from db.database import RecordingRecord, get_session_factory
|
||||
"""Write a recordings row for this session, with bounded retry.
|
||||
|
||||
async with get_session_factory()() as db:
|
||||
db.add(RecordingRecord(
|
||||
id=f"rec_{_uuid.uuid4().hex[:10]}",
|
||||
call_id=session.call_id,
|
||||
path=session.filepath_mixed or "",
|
||||
format="wav",
|
||||
duration_s=float(session.duration_seconds or 0),
|
||||
size_bytes=int(session.file_size_bytes or 0),
|
||||
channels=1,
|
||||
started_at=session.started_at,
|
||||
ended_at=session.stopped_at,
|
||||
))
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"Recording persistence failed: {e}")
|
||||
Non-fatal for the call, but a lost row means the dashboard can
|
||||
never find the WAV — so failures log at ERROR, not warning.
|
||||
"""
|
||||
import uuid as _uuid
|
||||
|
||||
from db.database import RecordingRecord, session_scope
|
||||
|
||||
for attempt in range(3):
|
||||
try:
|
||||
async with session_scope() as db:
|
||||
db.add(RecordingRecord(
|
||||
id=f"rec_{_uuid.uuid4().hex[:10]}",
|
||||
call_id=session.call_id,
|
||||
path=session.filepath_mixed or "",
|
||||
format="wav",
|
||||
duration_s=float(session.duration_seconds or 0),
|
||||
size_bytes=int(session.file_size_bytes or 0),
|
||||
channels=1,
|
||||
started_at=session.started_at,
|
||||
ended_at=session.stopped_at,
|
||||
))
|
||||
return
|
||||
except Exception as e:
|
||||
if attempt == 2:
|
||||
logger.error(
|
||||
f"Recording row lost for {session.call_id} "
|
||||
f"(path={session.filepath_mixed}): {e}"
|
||||
)
|
||||
return
|
||||
await asyncio.sleep(2 ** attempt)
|
||||
|
||||
async def _recording_timeout(self, call_id: str) -> None:
|
||||
"""Auto-stop recording after max duration."""
|
||||
@@ -230,6 +252,7 @@ class RecordingSession:
|
||||
filepath_agent: Optional[str] = None,
|
||||
started_at: Optional[datetime] = None,
|
||||
sample_rate: int = 16000,
|
||||
leg_ids: Optional[list[str]] = None,
|
||||
):
|
||||
self.call_id = call_id
|
||||
self.filepath_mixed = filepath_mixed
|
||||
@@ -240,7 +263,7 @@ class RecordingSession:
|
||||
self.duration_seconds: Optional[int] = None
|
||||
self.file_size_bytes: Optional[int] = None
|
||||
self.sample_rate = sample_rate
|
||||
self._leg_ids: list[str] = []
|
||||
self._leg_ids: list[str] = list(leg_ids or [])
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
|
||||
@@ -27,6 +27,8 @@ class TranscriptionService:
|
||||
def __init__(self, settings: SpeachesSettings):
|
||||
self.settings = settings
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
# Last-known reachability, surfaced by /health (None = no requests yet)
|
||||
self.available: Optional[bool] = None
|
||||
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
"""Get or create the HTTP client."""
|
||||
@@ -60,6 +62,9 @@ class TranscriptionService:
|
||||
# Convert raw PCM to WAV format for the API
|
||||
wav_data = self._pcm_to_wav(audio_data)
|
||||
|
||||
# Raises on failure — callers decide the per-call fallback and
|
||||
# publish a service-error event; swallowing here made a down
|
||||
# Speaches look like "the AI is deciding badly".
|
||||
try:
|
||||
response = await client.post(
|
||||
"/v1/audio/transcriptions",
|
||||
@@ -72,44 +77,13 @@ class TranscriptionService:
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
text = response.text.strip()
|
||||
logger.debug(f"Transcription: '{text}'")
|
||||
return text
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"Speaches API error: {e.response.status_code} {e.response.text}")
|
||||
return ""
|
||||
except httpx.ConnectError:
|
||||
logger.error(f"Cannot connect to Speaches at {self.settings.url}")
|
||||
return ""
|
||||
except Exception as e:
|
||||
logger.error(f"Transcription failed: {e}")
|
||||
return ""
|
||||
|
||||
async def transcribe_stream(
|
||||
self,
|
||||
audio_data: bytes,
|
||||
language: str = "en",
|
||||
):
|
||||
"""
|
||||
Stream transcription — for real-time results.
|
||||
|
||||
Uses Speaches streaming endpoint if available,
|
||||
falls back to chunked transcription.
|
||||
|
||||
Yields:
|
||||
str: Partial transcription chunks
|
||||
"""
|
||||
# For now, do chunked transcription
|
||||
# TODO: Implement WebSocket streaming when Speaches supports it
|
||||
chunk_size = 16000 * 2 * 3 # 3 seconds of 16kHz 16-bit mono
|
||||
|
||||
for i in range(0, len(audio_data), chunk_size):
|
||||
chunk = audio_data[i:i + chunk_size]
|
||||
if len(chunk) > 0:
|
||||
text = await self.transcribe(chunk, language)
|
||||
if text:
|
||||
yield text
|
||||
except Exception:
|
||||
self.available = False
|
||||
raise
|
||||
self.available = True
|
||||
text = response.text.strip()
|
||||
logger.debug(f"Transcription: '{text}'")
|
||||
return text
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the HTTP client."""
|
||||
|
||||
@@ -22,6 +22,8 @@ class TTSService:
|
||||
def __init__(self, settings: TTSSettings):
|
||||
self.settings = settings
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
# Last-known reachability, surfaced by /health (None = no requests yet)
|
||||
self.available: Optional[bool] = None
|
||||
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
if self._client is None or self._client.is_closed:
|
||||
@@ -54,19 +56,17 @@ class TTSService:
|
||||
"sample_rate": self.settings.sample_rate,
|
||||
}
|
||||
|
||||
# Raises on failure — callers decide the per-call fallback and
|
||||
# publish a service-error event; swallowing here made a down
|
||||
# Rhema look like "the AI went quiet".
|
||||
try:
|
||||
response = await client.post("/v1/audio/speech", json=body)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"Rhema TTS error: {e.response.status_code} {e.response.text}")
|
||||
return b""
|
||||
except httpx.ConnectError:
|
||||
logger.error(f"Cannot connect to Rhema at {self.settings.base_url}")
|
||||
return b""
|
||||
except Exception as e:
|
||||
logger.error(f"TTS synthesis failed: {e}")
|
||||
return b""
|
||||
except Exception:
|
||||
self.available = False
|
||||
raise
|
||||
self.available = True
|
||||
return response.content
|
||||
|
||||
async def synthesize_to_file(
|
||||
self,
|
||||
|
||||
168
tests/test_concurrency.py
Normal file
168
tests/test_concurrency.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
Thread-ownership and task-hygiene tests.
|
||||
|
||||
Covers the Sippy→loop event funnel (events posted from a foreign
|
||||
thread mutate loop-owned state), the AudioTap thread-safe feed, the
|
||||
off-loop classifier entry point, leg-state propagation into the
|
||||
CallManager, and background-task cancellation on gateway stop.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
|
||||
import numpy as np
|
||||
|
||||
from config import ClassifierSettings, Settings
|
||||
from core.gateway import AIPSTNGateway
|
||||
from core.media_pipeline import AudioTap
|
||||
from core.sippy_engine import SippyEngine
|
||||
from models.call import CallStatus
|
||||
from services.audio_classifier import AudioClassifier
|
||||
|
||||
|
||||
def _engine_on_loop() -> SippyEngine:
|
||||
"""Engine wired to the running loop without starting the SIP stack."""
|
||||
engine = SippyEngine()
|
||||
engine._loop = asyncio.get_running_loop()
|
||||
return engine
|
||||
|
||||
|
||||
def _post_from_thread(engine: SippyEngine, kind: str, data: dict) -> None:
|
||||
"""Post a funnel event from a foreign thread, like the Sippy ED thread."""
|
||||
t = threading.Thread(target=engine._post_from_ed, args=(kind, data))
|
||||
t.start()
|
||||
t.join()
|
||||
|
||||
|
||||
class TestEngineEventFunnel:
|
||||
async def test_register_and_deregister_from_foreign_thread(self):
|
||||
engine = _engine_on_loop()
|
||||
|
||||
_post_from_thread(engine, "register", {
|
||||
"aor": "sip:alice@gw", "contact": "sip:alice@10.0.0.5", "expires": 3600,
|
||||
})
|
||||
await asyncio.sleep(0.05)
|
||||
assert engine._registered_devices == [
|
||||
{"aor": "sip:alice@gw", "contact": "sip:alice@10.0.0.5", "expires": 3600}
|
||||
]
|
||||
|
||||
# Re-register updates in place instead of duplicating
|
||||
_post_from_thread(engine, "register", {
|
||||
"aor": "sip:alice@gw", "contact": "sip:alice@10.0.0.9", "expires": 60,
|
||||
})
|
||||
await asyncio.sleep(0.05)
|
||||
assert len(engine._registered_devices) == 1
|
||||
assert engine._registered_devices[0]["contact"] == "sip:alice@10.0.0.9"
|
||||
|
||||
_post_from_thread(engine, "deregister", {"aor": "sip:alice@gw"})
|
||||
await asyncio.sleep(0.05)
|
||||
assert engine._registered_devices == []
|
||||
|
||||
async def test_incoming_invite_auto_answers_without_callback(self):
|
||||
engine = _engine_on_loop()
|
||||
_post_from_thread(engine, "incoming_invite", {
|
||||
"leg_id": "leg_test1",
|
||||
"from_uri": "sip:caller@pstn",
|
||||
"to_uri": "sip:+15551234567@gw",
|
||||
"sdp": None,
|
||||
})
|
||||
await asyncio.sleep(0.05)
|
||||
leg = engine._legs["leg_test1"]
|
||||
assert leg.direction == "inbound"
|
||||
assert leg.state == "connected"
|
||||
|
||||
async def test_incoming_call_callback_runs_as_tracked_task(self):
|
||||
engine = _engine_on_loop()
|
||||
seen = asyncio.Event()
|
||||
|
||||
async def on_incoming(from_uri, to_uri, leg_id):
|
||||
seen.set()
|
||||
|
||||
engine._on_incoming_call = on_incoming
|
||||
_post_from_thread(engine, "incoming_invite", {
|
||||
"leg_id": "leg_test2", "from_uri": "a", "to_uri": "b", "sdp": None,
|
||||
})
|
||||
await asyncio.wait_for(seen.wait(), timeout=1.0)
|
||||
assert engine._legs["leg_test2"].state == "init" # not auto-answered
|
||||
|
||||
async def test_bye_terminates_leg_and_notifies(self):
|
||||
engine = _engine_on_loop()
|
||||
states: list[tuple[str, str]] = []
|
||||
engine._on_leg_state_change = lambda leg_id, state: states.append((leg_id, state))
|
||||
|
||||
_post_from_thread(engine, "incoming_invite", {
|
||||
"leg_id": "leg_test3", "from_uri": "a", "to_uri": "b", "sdp": None,
|
||||
})
|
||||
await asyncio.sleep(0.05)
|
||||
_post_from_thread(engine, "leg_state", {"leg_id": "leg_test3", "state": "terminated"})
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
assert engine._legs["leg_test3"].state == "terminated"
|
||||
assert ("leg_test3", "terminated") in states
|
||||
|
||||
async def test_dtmf_and_trunk_events(self):
|
||||
engine = _engine_on_loop()
|
||||
_post_from_thread(engine, "dtmf", {"leg_id": "leg_test4", "digit": "5"})
|
||||
_post_from_thread(engine, "trunk_registered", {"registered": True})
|
||||
await asyncio.sleep(0.05)
|
||||
assert engine._trunk_registered is True
|
||||
|
||||
|
||||
class TestAudioTapThreadSafety:
|
||||
async def test_feed_from_foreign_thread_reaches_reader(self):
|
||||
tap = AudioTap("leg_x")
|
||||
frame = b"\x01\x02" * 320
|
||||
|
||||
t = threading.Thread(target=tap.feed, args=(frame,))
|
||||
t.start()
|
||||
t.join()
|
||||
|
||||
received = await tap.read_frame(timeout=1.0)
|
||||
assert received == frame
|
||||
|
||||
|
||||
class TestClassifierOffLoop:
|
||||
async def test_classify_runs_and_records_history(self):
|
||||
classifier = AudioClassifier(ClassifierSettings())
|
||||
silence = np.zeros(16000, dtype=np.int16).tobytes()
|
||||
|
||||
result = await classifier.classify(silence)
|
||||
|
||||
assert result.audio_type.value == "silence"
|
||||
assert classifier._classification_history == [result.audio_type]
|
||||
|
||||
|
||||
class TestLegStatePropagation:
|
||||
async def test_leg_lifecycle_drives_call_status(self):
|
||||
gateway = AIPSTNGateway(settings=Settings(max_concurrent_calls=4))
|
||||
call = await gateway.make_call("+15551234567")
|
||||
assert call.status == CallStatus.RINGING
|
||||
|
||||
(leg_id,) = gateway.call_manager.legs_for_call(call.id)
|
||||
|
||||
await gateway._on_sip_leg_state(leg_id, "connected")
|
||||
assert gateway.get_call(call.id).status == CallStatus.CONNECTED
|
||||
|
||||
await gateway._on_sip_leg_state(leg_id, "terminated")
|
||||
assert gateway.get_call(call.id) is None
|
||||
assert gateway.call_manager.legs_for_call(call.id) == []
|
||||
|
||||
async def test_late_signals_do_not_stomp_service_states(self):
|
||||
gateway = AIPSTNGateway(settings=Settings(max_concurrent_calls=4))
|
||||
call = await gateway.make_call("+15551234567")
|
||||
(leg_id,) = gateway.call_manager.legs_for_call(call.id)
|
||||
|
||||
await gateway.call_manager.update_status(call.id, CallStatus.ON_HOLD)
|
||||
await gateway._on_sip_leg_state(leg_id, "connected")
|
||||
assert gateway.get_call(call.id).status == CallStatus.ON_HOLD
|
||||
|
||||
|
||||
class TestTaskHygiene:
|
||||
async def test_gateway_stop_cancels_spawned_tasks(self):
|
||||
gateway = AIPSTNGateway(settings=Settings(max_concurrent_calls=4))
|
||||
task = gateway.spawn(asyncio.sleep(60), name="test_sleeper")
|
||||
|
||||
await gateway.stop()
|
||||
|
||||
assert task.cancelled()
|
||||
assert gateway._tasks == set()
|
||||
187
tests/test_data_layer.py
Normal file
187
tests/test_data_layer.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Data-layer tests.
|
||||
|
||||
Alembic migrations produce the schema the ORM models declare (and
|
||||
adopt a pre-Alembic database); a call gets a durable in_progress row
|
||||
the moment it starts; transcript entries carry real offsets; the
|
||||
consolidated response models map from the domain in one place.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Boolean, inspect, text
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
import db.database as dbmod
|
||||
from core.call_manager import CallManager
|
||||
from core.event_bus import EventBus
|
||||
from db.database import Base
|
||||
from models.call import ActiveCall, CallStatus, CallStatusResponse
|
||||
from models.device import Device, DeviceType
|
||||
from services import call_persistence as store
|
||||
|
||||
# ================================================================
|
||||
# Alembic migrations
|
||||
# ================================================================
|
||||
|
||||
def _run_alembic(connection, revision: str) -> None:
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
|
||||
cfg = Config("alembic.ini")
|
||||
cfg.attributes["connection"] = connection
|
||||
command.upgrade(cfg, revision)
|
||||
|
||||
|
||||
def _schema_info(sync_conn) -> dict:
|
||||
inspector = inspect(sync_conn)
|
||||
return {
|
||||
"tables": set(inspector.get_table_names()) - {"alembic_version"},
|
||||
"call_record_cols": {c["name"] for c in inspector.get_columns("call_records")},
|
||||
"is_online_type": next(
|
||||
c["type"] for c in inspector.get_columns("devices")
|
||||
if c["name"] == "is_online"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class TestMigrations:
|
||||
async def test_upgrade_head_matches_models(self, tmp_path):
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/mig.db")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(dbmod._upgrade_to_head)
|
||||
async with engine.connect() as conn:
|
||||
info = await conn.run_sync(_schema_info)
|
||||
await engine.dispose()
|
||||
|
||||
assert info["tables"] == set(Base.metadata.tables)
|
||||
assert "transcript" not in info["call_record_cols"]
|
||||
assert isinstance(info["is_online_type"], Boolean)
|
||||
|
||||
async def test_adopts_pre_alembic_schema(self, tmp_path):
|
||||
"""A create_all-era database (baseline schema, no alembic_version)
|
||||
is stamped and migrated forward instead of failing."""
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/legacy.db")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(
|
||||
lambda c: _run_alembic(c, dbmod._BASELINE_REVISION)
|
||||
)
|
||||
await conn.execute(text("DROP TABLE alembic_version"))
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(dbmod._upgrade_to_head)
|
||||
async with engine.connect() as conn:
|
||||
info = await conn.run_sync(_schema_info)
|
||||
await engine.dispose()
|
||||
|
||||
assert "transcript" not in info["call_record_cols"]
|
||||
assert isinstance(info["is_online_type"], Boolean)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Durable call rows
|
||||
# ================================================================
|
||||
|
||||
@pytest.fixture
|
||||
async def mem_db(monkeypatch):
|
||||
engine = create_async_engine(
|
||||
"sqlite+aiosqlite:///:memory:",
|
||||
poolclass=StaticPool,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
monkeypatch.setattr(dbmod, "_engine", engine)
|
||||
monkeypatch.setattr(dbmod, "_session_factory", factory)
|
||||
yield factory
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
class TestDurableCallRows:
|
||||
async def test_in_progress_row_from_the_start(self, mem_db):
|
||||
cm = CallManager(
|
||||
EventBus(),
|
||||
on_call_created=store.persist_call_on_create,
|
||||
on_call_ended=store.persist_call_on_end,
|
||||
)
|
||||
call = await cm.create_call("+15551230000", intent="dispute a charge")
|
||||
|
||||
async with mem_db() as session:
|
||||
row = await store.get_record(session, call.id)
|
||||
assert row is not None
|
||||
assert row.status == "in_progress"
|
||||
assert row.ended_at is None
|
||||
assert row.intent == "dispute a charge"
|
||||
|
||||
await cm.add_transcript(call.id, "hello, billing please", speaker="caller")
|
||||
await cm.end_call(call.id, CallStatus.COMPLETED)
|
||||
|
||||
async with mem_db() as session:
|
||||
row = await store.get_record(session, call.id)
|
||||
chunks = await store.get_transcript_chunks(session, call.id)
|
||||
assert row.status == "completed"
|
||||
assert row.ended_at is not None
|
||||
assert [(c.seq, c.speaker, c.text) for c in chunks] == [
|
||||
(0, "caller", "hello, billing please")
|
||||
]
|
||||
|
||||
async def test_end_without_create_still_writes_row(self, mem_db):
|
||||
"""If the create-time insert never happened, finalize inserts."""
|
||||
cm = CallManager(EventBus(), on_call_ended=store.persist_call_on_end)
|
||||
call = await cm.create_call("+15551230001")
|
||||
await cm.end_call(call.id, CallStatus.FAILED)
|
||||
|
||||
async with mem_db() as session:
|
||||
row = await store.get_record(session, call.id)
|
||||
assert row is not None
|
||||
assert row.status == "failed"
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Transcript offsets
|
||||
# ================================================================
|
||||
|
||||
class TestTranscriptOffsets:
|
||||
async def test_entries_carry_offset_and_speaker(self):
|
||||
cm = CallManager(EventBus())
|
||||
call = await cm.create_call("+15551230002")
|
||||
|
||||
await cm.add_transcript(call.id, "one")
|
||||
await asyncio.sleep(0.02)
|
||||
await cm.add_transcript(call.id, "two", speaker="agent")
|
||||
|
||||
first, second = call.transcript_chunks
|
||||
assert first.t_offset_ms >= 0
|
||||
assert second.t_offset_ms > first.t_offset_ms
|
||||
assert first.speaker == "unknown"
|
||||
assert second.speaker == "agent"
|
||||
assert call.transcript == "one\ntwo"
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Consolidated response models
|
||||
# ================================================================
|
||||
|
||||
class TestResponseModels:
|
||||
def test_status_response_from_call(self):
|
||||
call = ActiveCall(id="call_x", remote_number="+15550000000", intent="pay bill")
|
||||
resp = CallStatusResponse.from_call(call)
|
||||
assert resp.call_id == "call_x"
|
||||
assert resp.status == "initiating"
|
||||
assert resp.remote_number == "+15550000000"
|
||||
assert resp.intent == "pay bill"
|
||||
|
||||
def test_device_serializes_routability(self):
|
||||
device = Device(
|
||||
id="dev_1",
|
||||
name="Desk Phone",
|
||||
type=DeviceType.SIP_PHONE,
|
||||
sip_uri="sip:desk@gw",
|
||||
is_online=True,
|
||||
)
|
||||
assert device.model_dump()["can_receive_call"] is True
|
||||
device.dnd = True
|
||||
assert device.model_dump()["can_receive_call"] is False
|
||||
89
tests/test_health_policy.py
Normal file
89
tests/test_health_policy.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Engine-mode and event-bus-integrity tests.
|
||||
|
||||
The mock engine must be requested explicitly; a full subscriber queue
|
||||
drops its oldest event but never loses the subscription; history is
|
||||
replayable to late joiners.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from config import Settings
|
||||
from core.event_bus import EventBus
|
||||
from core.gateway import build_sip_engine
|
||||
from core.media_pipeline import MediaPipeline
|
||||
from core.sip_engine import MockSIPEngine
|
||||
from models.events import EventType, GatewayEvent
|
||||
|
||||
|
||||
def _noop(*args, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
class TestEngineMode:
|
||||
def _build(self, settings: Settings):
|
||||
return build_sip_engine(
|
||||
settings,
|
||||
MediaPipeline(sample_rate=16000),
|
||||
on_leg_state_change=_noop,
|
||||
on_device_registered=_noop,
|
||||
on_incoming_call=_noop,
|
||||
)
|
||||
|
||||
def test_mock_engine_only_when_asked(self):
|
||||
settings = Settings(use_mock_sip=True)
|
||||
assert isinstance(self._build(settings), MockSIPEngine)
|
||||
|
||||
def test_unconfigured_trunk_refuses_to_build(self):
|
||||
settings = Settings(use_mock_sip=False)
|
||||
settings.sip_trunk.host = "sip.yourprovider.com"
|
||||
with pytest.raises(RuntimeError, match="not configured"):
|
||||
self._build(settings)
|
||||
|
||||
|
||||
def _event(i: int) -> GatewayEvent:
|
||||
return GatewayEvent(
|
||||
type=EventType.CALL_INITIATED,
|
||||
call_id=f"call_{i}",
|
||||
data={},
|
||||
message=f"event {i}",
|
||||
)
|
||||
|
||||
|
||||
class TestEventBusIntegrity:
|
||||
async def test_overflow_drops_oldest_keeps_subscription(self):
|
||||
bus = EventBus()
|
||||
sub = bus.subscribe(max_size=3)
|
||||
|
||||
for i in range(5):
|
||||
await bus.publish(_event(i))
|
||||
|
||||
assert bus.subscriber_count == 1 # never evicted
|
||||
assert sub.dropped == 2
|
||||
received = [await asyncio.wait_for(sub.get(), 1.0) for _ in range(3)]
|
||||
assert [e.call_id for e in received] == ["call_2", "call_3", "call_4"]
|
||||
|
||||
async def test_replay_last_seeds_history(self):
|
||||
bus = EventBus()
|
||||
for i in range(10):
|
||||
await bus.publish(_event(i))
|
||||
|
||||
sub = bus.subscribe(replay_last=3)
|
||||
received = [await asyncio.wait_for(sub.get(), 1.0) for _ in range(3)]
|
||||
assert [e.call_id for e in received] == ["call_7", "call_8", "call_9"]
|
||||
|
||||
async def test_replay_respects_type_filter(self):
|
||||
bus = EventBus()
|
||||
await bus.publish(_event(1))
|
||||
await bus.publish(GatewayEvent(
|
||||
type=EventType.HUMAN_DETECTED, call_id="call_h", data={}, message="x"
|
||||
))
|
||||
|
||||
sub = bus.subscribe(
|
||||
event_types={EventType.HUMAN_DETECTED}, replay_last=5
|
||||
)
|
||||
event = await asyncio.wait_for(sub.get(), 1.0)
|
||||
assert event.call_id == "call_h"
|
||||
assert sub._queue.empty()
|
||||
137
tests/test_learner.py
Normal file
137
tests/test_learner.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
Call-flow learner tests.
|
||||
|
||||
Exploration discoveries become a linked CallFlow, survive with the
|
||||
persisted call record, and the learn_call_flow MCP tool turns them
|
||||
into a stored flow (refining on subsequent calls).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
import db.database as dbmod
|
||||
import services.llm_client as llm_mod
|
||||
from core.call_manager import CallManager
|
||||
from core.event_bus import EventBus
|
||||
from db.database import Base
|
||||
from mcp_server.server import create_mcp_server
|
||||
from models.call import CallStatus
|
||||
from models.call_flow import ActionType
|
||||
from services import call_persistence as store
|
||||
from services.call_flow_learner import CallFlowLearner
|
||||
|
||||
DISCOVERIES = [
|
||||
{"timestamp": 1.0, "audio_type": "ringing", "confidence": 0.9,
|
||||
"transcript": "", "action_taken": None},
|
||||
{"timestamp": 4.0, "audio_type": "ivr_prompt", "confidence": 0.8,
|
||||
"transcript": "press 1 for english press 2 for french",
|
||||
"action_taken": {"dtmf": "1"}},
|
||||
{"timestamp": 8.0, "audio_type": "ivr_prompt", "confidence": 0.8,
|
||||
"transcript": "press 1 for billing press 2 for support press 0 for an agent",
|
||||
"action_taken": {"dtmf": "0"}},
|
||||
{"timestamp": 12.0, "audio_type": "music", "confidence": 0.9,
|
||||
"transcript": "", "action_taken": None},
|
||||
{"timestamp": 200.0, "audio_type": "live_human", "confidence": 0.85,
|
||||
"transcript": "thank you for holding, how can I help",
|
||||
"action_taken": None},
|
||||
]
|
||||
|
||||
|
||||
class TestBuildFlow:
|
||||
async def test_discoveries_become_linked_steps(self):
|
||||
learner = CallFlowLearner(llm_client=None)
|
||||
flow = await learner.build_flow(
|
||||
phone_number="+18005551234",
|
||||
discovered_steps=DISCOVERIES,
|
||||
intent="dispute a charge",
|
||||
)
|
||||
|
||||
# ringing is skipped; menus/hold/human map to actions in order
|
||||
assert [s.action for s in flow.steps] == [
|
||||
ActionType.DTMF, ActionType.DTMF, ActionType.HOLD, ActionType.TRANSFER,
|
||||
]
|
||||
assert [s.action_value for s in flow.steps[:2]] == ["1", "0"]
|
||||
assert [s.next_step for s in flow.steps[:-1]] == [s.id for s in flow.steps[1:]]
|
||||
assert "auto-learned" in flow.tags
|
||||
assert flow.phone_number == "+18005551234"
|
||||
|
||||
|
||||
class TestExplorationPersistence:
|
||||
async def test_exploration_steps_survive_with_the_record(self, mem_db):
|
||||
cm = CallManager(EventBus(), on_call_ended=store.persist_call_on_end)
|
||||
call = await cm.create_call("+18005551234", intent="dispute a charge")
|
||||
call.exploration_steps.extend(DISCOVERIES)
|
||||
await cm.end_call(call.id, CallStatus.COMPLETED)
|
||||
|
||||
async with mem_db() as session:
|
||||
record = await store.get_record(session, call.id)
|
||||
assert record.metadata_["exploration_steps"] == DISCOVERIES
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mem_db(monkeypatch):
|
||||
engine = create_async_engine(
|
||||
"sqlite+aiosqlite:///:memory:",
|
||||
poolclass=StaticPool,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
monkeypatch.setattr(dbmod, "_engine", engine)
|
||||
monkeypatch.setattr(dbmod, "_session_factory", factory)
|
||||
yield factory
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_llm(monkeypatch):
|
||||
"""learn_call_flow must work without an LLM (labels stay heuristic)."""
|
||||
monkeypatch.setattr(llm_mod, "_shared_client", None)
|
||||
monkeypatch.setattr(llm_mod, "_shared_failed", True)
|
||||
|
||||
|
||||
class TestLearnCallFlowTool:
|
||||
async def _completed_exploration_call(self, number: str) -> str:
|
||||
cm = CallManager(EventBus(), on_call_ended=store.persist_call_on_end)
|
||||
call = await cm.create_call(number, intent="dispute a charge")
|
||||
call.exploration_steps.extend(DISCOVERIES)
|
||||
await cm.end_call(call.id, CallStatus.COMPLETED)
|
||||
return call.id
|
||||
|
||||
async def test_learns_then_refines(self, mem_db, no_llm):
|
||||
call_id = await self._completed_exploration_call("+18005551234")
|
||||
|
||||
mcp = create_mcp_server(lambda: None)
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool("learn_call_flow", {"call_id": call_id})
|
||||
assert "Learned new flow" in result.content[0].text
|
||||
|
||||
async with mem_db() as session:
|
||||
row = await store.get_flow_by_number(session, "+18005551234")
|
||||
assert row is not None
|
||||
assert len(row.steps) == 4
|
||||
assert "auto-learned" in row.tags
|
||||
|
||||
result = await client.call_tool("learn_call_flow", {"call_id": call_id})
|
||||
assert "Refined existing flow" in result.content[0].text
|
||||
|
||||
async def test_call_without_exploration_data(self, mem_db, no_llm):
|
||||
cm = CallManager(EventBus(), on_call_ended=store.persist_call_on_end)
|
||||
call = await cm.create_call("+15550001111")
|
||||
await cm.end_call(call.id, CallStatus.COMPLETED)
|
||||
|
||||
mcp = create_mcp_server(lambda: None)
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool("learn_call_flow", {"call_id": call.id})
|
||||
assert "no exploration data" in result.content[0].text
|
||||
|
||||
async def test_unknown_call(self, mem_db, no_llm):
|
||||
mcp = create_mcp_server(lambda: None)
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool(
|
||||
"learn_call_flow", {"call_id": "call_nope"}
|
||||
)
|
||||
assert "No record found" in result.content[0].text
|
||||
@@ -25,6 +25,7 @@ EXPECTED_TOOLS = {
|
||||
"get_call_recording",
|
||||
"get_call_summary",
|
||||
"search_call_history",
|
||||
"learn_call_flow",
|
||||
"list_devices",
|
||||
"gateway_status",
|
||||
}
|
||||
|
||||
@@ -24,10 +24,27 @@ class TestReceptionistDecide:
|
||||
gw = _make_gateway()
|
||||
svc = ReceptionistService(gw)
|
||||
rule_action = RoutingAction(type=RoutingActionType.REJECT, message="nope")
|
||||
decision = RoutingDecision(action=rule_action, reason="rule said so")
|
||||
decision = RoutingDecision(
|
||||
action=rule_action,
|
||||
matched_rule_id="rule_1",
|
||||
matched_rule_name="block",
|
||||
reason="rule said so",
|
||||
)
|
||||
chosen = svc._decide(decision, {"recommended_action": "ring"})
|
||||
assert chosen.type == RoutingActionType.REJECT
|
||||
|
||||
def test_matched_take_message_rule_beats_llm(self):
|
||||
gw = _make_gateway()
|
||||
svc = ReceptionistService(gw)
|
||||
decision = RoutingDecision(
|
||||
action=RoutingAction(type=RoutingActionType.TAKE_MESSAGE),
|
||||
matched_rule_id="rule_2",
|
||||
matched_rule_name="voicemail-hours",
|
||||
reason="matched rule 'voicemail-hours'",
|
||||
)
|
||||
chosen = svc._decide(decision, {"recommended_action": "ring"})
|
||||
assert chosen.type == RoutingActionType.TAKE_MESSAGE
|
||||
|
||||
def test_falls_back_to_llm_when_rule_is_default_take_message(self):
|
||||
gw = _make_gateway()
|
||||
svc = ReceptionistService(gw)
|
||||
|
||||
@@ -123,14 +123,14 @@ class TestLLMClient:
|
||||
assert result["key"] == "value"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_http_error_returns_empty(self):
|
||||
"""Verify HTTP errors return empty string gracefully."""
|
||||
async def test_chat_error_raises(self):
|
||||
"""Failures propagate to the caller (which owns the fallback)."""
|
||||
client = self._make_client()
|
||||
|
||||
with patch.object(client._client, "post", new_callable=AsyncMock) as mock_post:
|
||||
mock_post.side_effect = Exception("Connection refused")
|
||||
result = await client.chat("test", system="test")
|
||||
assert result == ""
|
||||
with pytest.raises(Exception, match="Connection refused"):
|
||||
await client.chat("test", system="test")
|
||||
assert client._total_errors == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
227
tests/test_structure.py
Normal file
227
tests/test_structure.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
Composition and API-surface tests.
|
||||
|
||||
Covers the gateway composed the way main.py's lifespan composes it
|
||||
(mode handlers, on_call_ended hook, receptionist-owned inbound
|
||||
policy) and the REST routes running against a real (SQLite) database
|
||||
through the shared data layer in services/call_persistence.py.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
import main
|
||||
from config import ReceptionistSettings, Settings, get_settings
|
||||
from core.gateway import AIPSTNGateway
|
||||
from db.database import Base, CallRecord, get_db
|
||||
from models.call import CallMode, CallStatus
|
||||
from models.routing import RoutingAction, RoutingActionType, RoutingDecision
|
||||
from services.receptionist import ReceptionistService
|
||||
|
||||
# ================================================================
|
||||
# Gateway composition
|
||||
# ================================================================
|
||||
|
||||
class TestGatewayComposition:
|
||||
async def test_mode_handler_launches_per_call(self):
|
||||
gateway = AIPSTNGateway(settings=Settings(max_concurrent_calls=4))
|
||||
launched: list[tuple] = []
|
||||
gateway.register_mode_handler(
|
||||
CallMode.HOLD_SLAYER,
|
||||
lambda call, leg_id, flow_id: launched.append((call.id, leg_id, flow_id)),
|
||||
)
|
||||
|
||||
call = await gateway.make_call("+15551234567", mode=CallMode.HOLD_SLAYER,
|
||||
call_flow_id="acme-main")
|
||||
|
||||
assert launched == [(call.id, gateway.call_manager.legs_for_call(call.id)[0], "acme-main")]
|
||||
|
||||
async def test_direct_mode_needs_no_handler(self):
|
||||
gateway = AIPSTNGateway(settings=Settings(max_concurrent_calls=4))
|
||||
call = await gateway.make_call("+15551234567")
|
||||
assert call.status == CallStatus.RINGING
|
||||
|
||||
async def test_on_call_ended_hook_from_constructor(self):
|
||||
ended: list[tuple] = []
|
||||
|
||||
async def hook(call, status):
|
||||
ended.append((call.id, status))
|
||||
|
||||
gateway = AIPSTNGateway(
|
||||
settings=Settings(max_concurrent_calls=4), on_call_ended=hook
|
||||
)
|
||||
call = await gateway.make_call("+15551234567")
|
||||
await gateway.hangup_call(call.id)
|
||||
|
||||
assert ended == [(call.id, CallStatus.COMPLETED)]
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Receptionist-owned inbound policy
|
||||
# ================================================================
|
||||
|
||||
class _StubRouting:
|
||||
def __init__(self, decision):
|
||||
self._decision = decision
|
||||
|
||||
async def evaluate(self, caller_number, dnis):
|
||||
return self._decision
|
||||
|
||||
|
||||
class TestInboundPolicy:
|
||||
def _gateway(self) -> AIPSTNGateway:
|
||||
settings = Settings(max_concurrent_calls=4)
|
||||
settings.receptionist = ReceptionistSettings(enabled=False)
|
||||
return AIPSTNGateway(settings=settings)
|
||||
|
||||
async def test_inbound_call_answered_and_tracked(self):
|
||||
gateway = self._gateway()
|
||||
receptionist = ReceptionistService(gateway)
|
||||
|
||||
await receptionist.on_inbound_call(
|
||||
"sip:+16135550100@pstn", "sip:+15551234567@gw", "leg_in1"
|
||||
)
|
||||
|
||||
calls = list(gateway.call_manager.active_calls.values())
|
||||
assert len(calls) == 1
|
||||
call = calls[0]
|
||||
assert call.direction == "inbound"
|
||||
assert call.remote_number == "+16135550100"
|
||||
assert call.status == CallStatus.CONNECTED
|
||||
assert gateway.call_manager.legs_for_call(call.id) == ["leg_in1"]
|
||||
|
||||
async def test_reject_rule_declines_before_answer(self):
|
||||
gateway = self._gateway()
|
||||
decision = RoutingDecision(
|
||||
action=RoutingAction(type=RoutingActionType.REJECT),
|
||||
matched_rule_id="rule_x",
|
||||
matched_rule_name="block",
|
||||
reason="matched rule 'block'",
|
||||
)
|
||||
receptionist = ReceptionistService(gateway, routing=_StubRouting(decision))
|
||||
|
||||
await receptionist.on_inbound_call(
|
||||
"sip:+18005550100@pstn", "sip:+15551234567@gw", "leg_in2"
|
||||
)
|
||||
|
||||
assert gateway.call_manager.active_calls == {}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# REST routes on the shared data layer (real SQLite)
|
||||
# ================================================================
|
||||
|
||||
@pytest.fixture
|
||||
async def client(monkeypatch):
|
||||
monkeypatch.setattr(get_settings(), "api_token", SecretStr(""))
|
||||
|
||||
engine = create_async_engine(
|
||||
"sqlite+aiosqlite:///:memory:",
|
||||
poolclass=StaticPool,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
async def _get_db():
|
||||
async with factory() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
main.app.dependency_overrides[get_db] = _get_db
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
c.db_factory = factory
|
||||
yield c
|
||||
main.app.dependency_overrides.pop(get_db, None)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
FLOW_PAYLOAD = {
|
||||
"name": "Acme Main Line",
|
||||
"phone_number": "+18005551234",
|
||||
"description": "Main IVR",
|
||||
"steps": [
|
||||
{
|
||||
"id": "step1",
|
||||
"description": "Press 2 for billing",
|
||||
"action": "dtmf",
|
||||
"action_value": "2",
|
||||
}
|
||||
],
|
||||
"tags": ["test"],
|
||||
}
|
||||
|
||||
|
||||
class TestCallFlowRoutes:
|
||||
async def test_crud_round_trip(self, client):
|
||||
resp = await client.post("/api/call-flows/", json=FLOW_PAYLOAD)
|
||||
assert resp.status_code == 200, resp.text
|
||||
flow_id = resp.json()["id"]
|
||||
assert flow_id == "acme-main-line"
|
||||
|
||||
resp = await client.post("/api/call-flows/", json=FLOW_PAYLOAD)
|
||||
assert resp.status_code == 409
|
||||
|
||||
resp = await client.get("/api/call-flows/")
|
||||
assert [f["id"] for f in resp.json()] == [flow_id]
|
||||
|
||||
resp = await client.get(f"/api/call-flows/{flow_id}")
|
||||
assert resp.json()["steps"][0]["action_value"] == "2"
|
||||
|
||||
resp = await client.get("/api/call-flows/by-number/+18005551234")
|
||||
assert resp.json()["id"] == flow_id
|
||||
|
||||
resp = await client.put(
|
||||
f"/api/call-flows/{flow_id}", json={"notes": "updated"}
|
||||
)
|
||||
assert resp.json()["notes"] == "updated"
|
||||
|
||||
resp = await client.delete(f"/api/call-flows/{flow_id}")
|
||||
assert resp.json()["status"] == "deleted"
|
||||
|
||||
resp = await client.get(f"/api/call-flows/{flow_id}")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestCallHistoryRoutes:
|
||||
async def test_history_and_record(self, client):
|
||||
resp = await client.get("/api/calls/history")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
async with client.db_factory() as session:
|
||||
session.add(CallRecord(
|
||||
id="call_hist1",
|
||||
direction="outbound",
|
||||
remote_number="+18005551234",
|
||||
status="completed",
|
||||
mode="hold_slayer",
|
||||
intent="dispute charge",
|
||||
duration=120,
|
||||
hold_time=90,
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
resp = await client.get("/api/calls/history")
|
||||
assert [r["id"] for r in resp.json()] == ["call_hist1"]
|
||||
|
||||
resp = await client.get("/api/calls/history?number=%2B18005551234")
|
||||
assert len(resp.json()) == 1
|
||||
|
||||
resp = await client.get("/api/calls/call_hist1/record")
|
||||
assert resp.json()["intent"] == "dispute charge"
|
||||
|
||||
resp = await client.get("/api/calls/call_missing/record")
|
||||
assert resp.status_code == 404
|
||||
|
||||
resp = await client.get("/api/calls/call_hist1/transcript")
|
||||
assert resp.json() == []
|
||||
67
tests/test_websocket.py
Normal file
67
tests/test_websocket.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
WebSocket event-stream tests.
|
||||
|
||||
The socket is refused (4401) without the bearer token, and an
|
||||
authorized client immediately receives the synthetic trunk-status
|
||||
event followed by the replayed recent history.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
from starlette.testclient import TestClient
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
import main
|
||||
from config import Settings, get_settings
|
||||
from core.gateway import AIPSTNGateway
|
||||
from models.events import EventType, GatewayEvent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ws_app(monkeypatch):
|
||||
monkeypatch.setattr(get_settings(), "api_token", SecretStr("tok"))
|
||||
gateway = AIPSTNGateway(settings=Settings())
|
||||
main.app.state.gateway = gateway
|
||||
yield gateway
|
||||
del main.app.state.gateway
|
||||
|
||||
|
||||
def _publish(gateway, call_id: str) -> None:
|
||||
asyncio.run(gateway.event_bus.publish(GatewayEvent(
|
||||
type=EventType.CALL_INITIATED,
|
||||
call_id=call_id,
|
||||
data={},
|
||||
message=f"call {call_id}",
|
||||
)))
|
||||
|
||||
|
||||
class TestEventStream:
|
||||
def test_refused_without_token(self, ws_app):
|
||||
client = TestClient(main.app)
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with client.websocket_connect("/ws/events"):
|
||||
pass
|
||||
assert exc.value.code == 4401
|
||||
|
||||
def test_trunk_status_then_replayed_history(self, ws_app):
|
||||
_publish(ws_app, "call_ws1")
|
||||
_publish(ws_app, "call_ws2")
|
||||
|
||||
client = TestClient(main.app)
|
||||
with client.websocket_connect("/ws/events?token=tok") as ws:
|
||||
first = ws.receive_json()
|
||||
assert first["type"] == EventType.SIP_TRUNK_REGISTRATION_FAILED.value
|
||||
replayed = [ws.receive_json() for _ in range(2)]
|
||||
assert [m["call_id"] for m in replayed] == ["call_ws1", "call_ws2"]
|
||||
|
||||
def test_per_call_stream_filters(self, ws_app):
|
||||
client = TestClient(main.app)
|
||||
with client.websocket_connect(
|
||||
"/ws/calls/call_target/events?token=tok"
|
||||
) as ws:
|
||||
_publish(ws_app, "call_other")
|
||||
_publish(ws_app, "call_target")
|
||||
msg = ws.receive_json()
|
||||
assert msg["call_id"] == "call_target"
|
||||
Reference in New Issue
Block a user