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:
2026-07-10 13:52:28 -04:00
50 changed files with 2921 additions and 1572 deletions

View File

@@ -12,6 +12,9 @@ DATABASE_URL=postgresql+asyncpg://holdslayer:<db-password>@localhost:5432/holdsl
API_TOKEN= API_TOKEN=
# --- SIP Trunk --- # --- 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_HOST=sip.yourprovider.com
SIP_TRUNK_PORT=5060 SIP_TRUNK_PORT=5060
SIP_TRUNK_USERNAME=your_sip_username SIP_TRUNK_USERNAME=your_sip_username

View File

@@ -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 │ │ │ │ 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 ### Core Engine
- **Sippy B2BUA Engine** (`core/sippy_engine.py`) — SIP call control, DTMF, bridging, conference, trunk registration - **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 - **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 - **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 ### API Surface
- **REST API** — Call management, call history, transcripts, recordings, routing rules, device DND, call flow CRUD - **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 - **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 - **Dashboard** — SvelteKit UI served at `/dashboard` with live monitor, call history with transcript playback, and a routing-rules editor
### Data Models ### Data Models
@@ -130,7 +130,7 @@ hold-slayer/
│ ├── calls/[call_id]/ # Detail page + transcript playback │ ├── calls/[call_id]/ # Detail page + transcript playback
│ └── routing/ # Rules editor + DND toggles │ └── routing/ # Rules editor + DND toggles
├── mcp_server/ ├── mcp_server/
│ └── server.py # MCP tools + resources (10 tools) │ └── server.py # MCP tools + resources (15 tools)
├── models/ ├── models/
│ ├── call.py # Call state models │ ├── call.py # Call state models
│ ├── call_flow.py # IVR tree models │ ├── call_flow.py # IVR tree models
@@ -139,7 +139,7 @@ hold-slayer/
│ ├── device.py # Device models │ ├── device.py # Device models
│ └── contact.py # Contact models │ └── contact.py # Contact models
├── db/ ├── db/
│ └── database.py # SQLAlchemy async (PostgreSQL/SQLite) │ └── database.py # SQLAlchemy async (PostgreSQL + Alembic)
└── tests/ └── tests/
├── test_audio_classifier.py # 18 tests — waveform analysis ├── test_audio_classifier.py # 18 tests — waveform analysis
├── test_call_flows.py # 10 tests — call flow models ├── 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" --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`): `gateway://call-flows`, `gateway://active-calls`):
| Tool | Description | | Tool | Description |
@@ -302,6 +302,7 @@ It exposes 14 tools and 3 resources (`gateway://status`,
| `create_call_flow` | Store a new IVR call flow | | `create_call_flow` | Store a new IVR call flow |
| `get_call_summary` | Stored summary and action items for a call | | `get_call_summary` | Stored summary and action items for a call |
| `search_call_history` | Search past calls by number or intent | | `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 ## How It Works
@@ -352,7 +353,7 @@ All configuration is via environment variables (see `.env.example`):
## Tech Stack ## Tech Stack
- **Python 3.13** + **asyncio** — Single-process async architecture - **Python 3.12+** + **asyncio** — Single-process async architecture
- **FastAPI** — REST API + WebSocket server - **FastAPI** — REST API + WebSocket server
- **SvelteKit** — Dashboard UI (built static, served by FastAPI at `/dashboard`) - **SvelteKit** — Dashboard UI (built static, served by FastAPI at `/dashboard`)
- **Sippy B2BUA** — SIP call control and DTMF - **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 - **Speaches** (Whisper) — Speech-to-text
- **Rhema** (Kokoro) — Text-to-speech (OpenAI-compatible `/v1/audio/speech`) - **Rhema** (Kokoro) — Text-to-speech (OpenAI-compatible `/v1/audio/speech`)
- **Ollama / vLLM / OpenAI** — LLM for IVR menu analysis and receptionist intent capture - **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 - **MCP (Model Context Protocol)** — AI assistant integration
## Documentation ## Documentation
@@ -384,7 +385,7 @@ Full documentation is in [`/docs`](docs/README.md):
- [x] Extract EventBus to dedicated module with typed filtering - [x] Extract EventBus to dedicated module with typed filtering
- [x] Implement Sippy B2BUA SIP engine (signaling, DTMF, bridging) - [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] Call manager with active call state tracking
- [x] Gateway orchestrator wiring all components - [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] REST API — calls, call flows, devices, DTMF
- [x] WebSocket real-time event streaming - [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] Notification service (WebSocket + SMS)
- [x] Service wiring in main.py lifespan - [x] Service wiring in main.py lifespan
### Phase 4: Production Hardening 🚧 ### 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] API authentication — static bearer token across REST/WS/MCP
- [x] Emergency-number guard + concurrent-call cap on outbound calls - [x] Emergency-number guard + concurrent-call cap on outbound calls
- [ ] Rate limiting on API endpoints - [ ] Rate limiting on API endpoints
- [ ] Structured JSON logging - [ ] 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.) - [ ] Graceful degradation (classifier works without STT, etc.)
- [ ] Docker Compose (Hold Slayer + PostgreSQL) - [ ] Docker Compose (Hold Slayer + PostgreSQL)

42
alembic.ini Normal file
View 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

View File

@@ -2,26 +2,21 @@
Call Flows API — Store and manage IVR navigation trees. Call Flows API — Store and manage IVR navigation trees.
The system gets smarter every time you call somewhere. 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 fastapi import APIRouter, Depends, HTTPException
from slugify import slugify from slugify import slugify
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from api.deps import get_gateway from db.database import get_db
from core.gateway import AIPSTNGateway
from db.database import StoredCallFlow, get_db
from models.call_flow import ( from models.call_flow import (
CallFlow, CallFlow,
CallFlowCreate, CallFlowCreate,
CallFlowStep,
CallFlowSummary, CallFlowSummary,
CallFlowUpdate, CallFlowUpdate,
) )
from services import call_persistence as store
router = APIRouter() router = APIRouter()
@@ -34,39 +29,23 @@ async def create_call_flow(
"""Store a new call flow for a phone number.""" """Store a new call flow for a phone number."""
flow_id = slugify(flow.name) flow_id = slugify(flow.name)
# Check if ID already exists if await store.get_flow(db, flow_id):
existing = await db.execute(
select(StoredCallFlow).where(StoredCallFlow.id == flow_id)
)
if existing.scalar_one_or_none():
raise HTTPException( raise HTTPException(
status_code=409, status_code=409,
detail=f"Call flow '{flow_id}' already exists. Use PUT to update.", detail=f"Call flow '{flow_id}' already exists. Use PUT to update.",
) )
db_flow = StoredCallFlow( row = await store.create_flow(
id=flow_id, db,
flow_id=flow_id,
name=flow.name, name=flow.name,
phone_number=flow.phone_number, phone_number=flow.phone_number,
description=flow.description, description=flow.description,
steps=[s.model_dump() for s in flow.steps], steps=[s.model_dump() for s in flow.steps],
tags=flow.tags, tags=flow.tags,
notes=flow.notes, 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]) @router.get("/", response_model=list[CallFlowSummary])
@@ -74,9 +53,7 @@ async def list_call_flows(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""List all stored call flows.""" """List all stored call flows."""
result = await db.execute(select(StoredCallFlow)) rows = await store.list_flows(db)
rows = result.scalars().all()
return [ return [
CallFlowSummary( CallFlowSummary(
id=row.id, id=row.id,
@@ -100,26 +77,10 @@ async def get_call_flow(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Get a stored call flow by ID.""" """Get a stored call flow by ID."""
result = await db.execute( row = await store.get_flow(db, flow_id)
select(StoredCallFlow).where(StoredCallFlow.id == flow_id)
)
row = result.scalar_one_or_none()
if not row: if not row:
raise HTTPException(status_code=404, detail=f"Call flow '{flow_id}' not found") raise HTTPException(status_code=404, detail=f"Call flow '{flow_id}' not found")
return store.flow_to_model(row)
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,
)
@router.get("/by-number/{phone_number}", response_model=CallFlow) @router.get("/by-number/{phone_number}", response_model=CallFlow)
@@ -128,29 +89,13 @@ async def get_flow_for_number(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Look up stored call flow by phone number.""" """Look up stored call flow by phone number."""
result = await db.execute( row = await store.get_flow_by_number(db, phone_number)
select(StoredCallFlow).where(StoredCallFlow.phone_number == phone_number)
)
row = result.scalar_one_or_none()
if not row: if not row:
raise HTTPException( raise HTTPException(
status_code=404, status_code=404,
detail=f"No call flow found for {phone_number}", detail=f"No call flow found for {phone_number}",
) )
return store.flow_to_model(row)
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,
)
@router.put("/{flow_id}", response_model=CallFlow) @router.put("/{flow_id}", response_model=CallFlow)
@@ -160,10 +105,7 @@ async def update_call_flow(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Update an existing call flow.""" """Update an existing call flow."""
result = await db.execute( row = await store.get_flow(db, flow_id)
select(StoredCallFlow).where(StoredCallFlow.id == flow_id)
)
row = result.scalar_one_or_none()
if not row: if not row:
raise HTTPException(status_code=404, detail=f"Call flow '{flow_id}' not found") 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 row.last_verified = update.last_verified
await db.flush() await db.flush()
return store.flow_to_model(row)
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,
)
@router.delete("/{flow_id}") @router.delete("/{flow_id}")
@@ -203,10 +132,7 @@ async def delete_call_flow(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Delete a stored call flow.""" """Delete a stored call flow."""
result = await db.execute( row = await store.get_flow(db, flow_id)
select(StoredCallFlow).where(StoredCallFlow.id == flow_id)
)
row = result.scalar_one_or_none()
if not row: if not row:
raise HTTPException(status_code=404, detail=f"Call flow '{flow_id}' not found") raise HTTPException(status_code=404, detail=f"Call flow '{flow_id}' not found")

View File

@@ -1,22 +1,18 @@
""" """
Call History API — Read-only access to persisted call records, Call History API — Read-only access to persisted call records,
transcript chunks, and recording files for the dashboard. 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 datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from sqlalchemy import desc, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from db.database import ( from db.database import get_db
CallRecord, from services import call_persistence as store
RecordingRecord,
TranscriptChunk,
get_db,
)
router = APIRouter() router = APIRouter()
@@ -25,102 +21,47 @@ router = APIRouter()
async def list_history( async def list_history(
limit: int = Query(50, ge=1, le=500), limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
number: Optional[str] = None, number: str | None = None,
status: Optional[str] = None, status: str | None = None,
since: Optional[datetime] = None, since: datetime | None = None,
until: Optional[datetime] = None, until: datetime | None = None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Paged list of past calls, newest first.""" """Paged list of past calls, newest first."""
stmt = select(CallRecord).order_by(desc(CallRecord.started_at)) rows = await store.search_history(
if number: db,
stmt = stmt.where(CallRecord.remote_number == number) number=number,
if status: status=status,
stmt = stmt.where(CallRecord.status == status) since=since,
if since: until=until,
stmt = stmt.where(CallRecord.started_at >= since) limit=limit,
if until: offset=offset,
stmt = stmt.where(CallRecord.started_at <= until) )
return [store.record_summary(r) for r in rows]
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
]
@router.get("/{call_id}/record") @router.get("/{call_id}/record")
async def get_record(call_id: str, db: AsyncSession = Depends(get_db)): async def get_record(call_id: str, db: AsyncSession = Depends(get_db)):
"""Full CallRecord with classification_timeline.""" """Full CallRecord with classification_timeline."""
row = (await db.execute( row = await store.get_record(db, call_id)
select(CallRecord).where(CallRecord.id == call_id)
)).scalar_one_or_none()
if not row: if not row:
raise HTTPException(status_code=404, detail=f"Call {call_id} not found") raise HTTPException(status_code=404, detail=f"Call {call_id} not found")
return { return store.record_detail(row)
"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,
}
@router.get("/{call_id}/transcript") @router.get("/{call_id}/transcript")
async def get_transcript(call_id: str, db: AsyncSession = Depends(get_db)): async def get_transcript(call_id: str, db: AsyncSession = Depends(get_db)):
"""Ordered transcript chunks for a call.""" """Ordered transcript chunks for a call."""
rows = (await db.execute( rows = await store.get_transcript_chunks(db, call_id)
select(TranscriptChunk) return [store.chunk_to_dict(c) for c in rows]
.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
]
@router.get("/{call_id}/recording") @router.get("/{call_id}/recording")
async def get_recording(call_id: str, db: AsyncSession = Depends(get_db)): async def get_recording(call_id: str, db: AsyncSession = Depends(get_db)):
"""Stream the WAV recording for a call.""" """Stream the WAV recording for a call."""
row = (await db.execute( row = await store.latest_recording(db, call_id)
select(RecordingRecord)
.where(RecordingRecord.call_id == call_id)
.order_by(desc(RecordingRecord.started_at))
)).scalar_one_or_none()
if not row or not row.path: if not row or not row.path:
raise HTTPException(status_code=404, detail="Recording not found") raise HTTPException(status_code=404, detail="Recording not found")
import os
if not os.path.exists(row.path): if not os.path.exists(row.path):
raise HTTPException(status_code=404, detail="Recording file missing on disk") 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)) return FileResponse(row.path, media_type="audio/wav", filename=os.path.basename(row.path))

View File

@@ -40,12 +40,7 @@ async def make_call(
call_flow_id=request.call_flow_id, call_flow_id=request.call_flow_id,
services=request.services, services=request.services,
) )
return CallResponse( return CallResponse.from_call(call)
call_id=call.id,
status=call.status.value,
number=request.number,
mode=request.mode.value,
)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
@@ -81,11 +76,8 @@ async def hold_slayer(
call_flow_id=request.call_flow_id, call_flow_id=request.call_flow_id,
device=request.transfer_to, device=request.transfer_to,
) )
return CallResponse( return CallResponse.from_call(
call_id=call.id, call,
status="navigating_ivr",
number=request.number,
mode="hold_slayer",
message="Hold Slayer activated. I'll ring you when a human picks up. ☕", message="Hold Slayer activated. I'll ring you when a human picks up. ☕",
) )
except ValueError as e: except ValueError as e:
@@ -113,21 +105,7 @@ async def get_call(
if not call: if not call:
raise HTTPException(status_code=404, detail=f"Call {call_id} not found") raise HTTPException(status_code=404, detail=f"Call {call_id} not found")
return CallStatusResponse( return CallStatusResponse.from_call(call)
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,
)
@router.post("/{call_id}/transfer") @router.post("/{call_id}/transfer")
@@ -172,10 +150,9 @@ async def send_dtmf(
if not call: if not call:
raise HTTPException(status_code=404, detail=f"Call {call_id} not found") raise HTTPException(status_code=404, detail=f"Call {call_id} not found")
# Find the PSTN leg for this call legs = gateway.call_manager.legs_for_call(call_id)
for leg_id, cid in gateway.call_manager._call_legs.items(): if not legs:
if cid == call_id: raise HTTPException(status_code=409, detail="No active SIP leg found for this call")
await gateway.sip_engine.send_dtmf(leg_id, digits)
return {"status": "sent", "digits": digits}
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}

View File

@@ -18,6 +18,14 @@ def get_gateway(request: Request) -> AIPSTNGateway:
return gateway 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: def require_token(authorization: str | None = Header(default=None)) -> None:
""" """
Enforce the static bearer token (API_TOKEN) on REST routes. Enforce the static bearer token (API_TOKEN) on REST routes.

View File

@@ -1,19 +1,19 @@
""" """
Device Management API — Register and manage phones/softphones. 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 import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from api.deps import get_gateway from api.deps import get_gateway
from core.gateway import AIPSTNGateway from core.gateway import AIPSTNGateway
from db.database import Device as DeviceDB
from db.database import get_db 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() router = APIRouter()
@@ -25,45 +25,18 @@ async def register_device(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Register a new device with the gateway.""" """Register a new device with the gateway."""
device_id = f"dev_{uuid.uuid4().hex[:8]}" dev = Device(id=f"dev_{uuid.uuid4().hex[:8]}", **device.model_dump())
await store.create_device_row(db, dev)
# 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())
gateway.register_device(dev) gateway.register_device(dev)
return dev return dev
@router.get("/", response_model=list[DeviceStatus]) @router.get("/", response_model=list[Device])
async def list_devices( async def list_devices(
gateway: AIPSTNGateway = Depends(get_gateway), gateway: AIPSTNGateway = Depends(get_gateway),
): ):
"""List all registered devices and their status.""" """List all registered devices and their status."""
return [ return list(gateway.devices.values())
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()
]
@router.get("/{device_id}", response_model=Device) @router.get("/{device_id}", response_model=Device)
@@ -90,22 +63,11 @@ async def update_device(
if not device: if not device:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found") raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
# Update in-memory
update_data = update.model_dump(exclude_unset=True) update_data = update.model_dump(exclude_unset=True)
for key, value in update_data.items(): for key, value in update_data.items():
setattr(device, key, value) setattr(device, key, value)
# Update in DB await store.update_device_row(db, device_id, update_data)
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)
return device return device
@@ -120,12 +82,5 @@ async def unregister_device(
raise HTTPException(status_code=404, detail=f"Device {device_id} not found") raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
gateway.unregister_device(device_id) gateway.unregister_device(device_id)
await store.delete_device_row(db, 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)
return {"status": "unregistered", "device_id": device_id} return {"status": "unregistered", "device_id": device_id}

View File

@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession 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 core.gateway import AIPSTNGateway
from db.database import Device as DeviceDB from db.database import Device as DeviceDB
from db.database import get_db from db.database import get_db
@@ -15,36 +15,31 @@ from models.routing import (
RoutingRuleCreate, RoutingRuleCreate,
RoutingRuleUpdate, RoutingRuleUpdate,
) )
from services.routing import RoutingService
router = APIRouter() router = APIRouter()
@router.get("/rules", response_model=list[RoutingRule]) @router.get("/rules", response_model=list[RoutingRule])
async def list_rules(gateway: AIPSTNGateway = Depends(get_gateway)): async def list_rules(routing: RoutingService = Depends(get_routing_service)):
if gateway._routing is None: return sorted(routing.rules, key=lambda r: (r.priority, r.id))
raise HTTPException(status_code=503, detail="Routing service not ready")
return sorted(gateway._routing.rules, key=lambda r: (r.priority, r.id))
@router.post("/rules", response_model=RoutingRule, status_code=201) @router.post("/rules", response_model=RoutingRule, status_code=201)
async def create_rule( async def create_rule(
payload: RoutingRuleCreate, payload: RoutingRuleCreate,
gateway: AIPSTNGateway = Depends(get_gateway), routing: RoutingService = Depends(get_routing_service),
): ):
if gateway._routing is None: return await routing.create_rule(payload)
raise HTTPException(status_code=503, detail="Routing service not ready")
return await gateway._routing.create_rule(payload)
@router.put("/rules/{rule_id}", response_model=RoutingRule) @router.put("/rules/{rule_id}", response_model=RoutingRule)
async def update_rule( async def update_rule(
rule_id: str, rule_id: str,
payload: RoutingRuleUpdate, payload: RoutingRuleUpdate,
gateway: AIPSTNGateway = Depends(get_gateway), routing: RoutingService = Depends(get_routing_service),
): ):
if gateway._routing is None: rule = await routing.update_rule(rule_id, payload)
raise HTTPException(status_code=503, detail="Routing service not ready")
rule = await gateway._routing.update_rule(rule_id, payload)
if rule is None: if rule is None:
raise HTTPException(status_code=404, detail=f"Rule {rule_id} not found") raise HTTPException(status_code=404, detail=f"Rule {rule_id} not found")
return rule return rule
@@ -53,11 +48,9 @@ async def update_rule(
@router.delete("/rules/{rule_id}") @router.delete("/rules/{rule_id}")
async def delete_rule( async def delete_rule(
rule_id: str, rule_id: str,
gateway: AIPSTNGateway = Depends(get_gateway), routing: RoutingService = Depends(get_routing_service),
): ):
if gateway._routing is None: ok = await routing.delete_rule(rule_id)
raise HTTPException(status_code=503, detail="Routing service not ready")
ok = await gateway._routing.delete_rule(rule_id)
if not ok: if not ok:
raise HTTPException(status_code=404, detail=f"Rule {rule_id} not found") raise HTTPException(status_code=404, detail=f"Rule {rule_id} not found")
return {"status": "deleted", "rule_id": rule_id} return {"status": "deleted", "rule_id": rule_id}

View File

@@ -94,7 +94,7 @@ async def event_stream(websocket: WebSocket):
# Immediately push current trunk status so the dashboard doesn't start blank # Immediately push current trunk status so the dashboard doesn't start blank
await _send_trunk_status(websocket, gateway) await _send_trunk_status(websocket, gateway)
subscription = gateway.event_bus.subscribe() subscription = gateway.event_bus.subscribe(replay_last=25)
try: try:
async for event in subscription: async for event in subscription:

View File

@@ -11,7 +11,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class SIPTrunkSettings(BaseSettings): class SIPTrunkSettings(BaseSettings):
"""SIP trunk provider configuration.""" """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" host: str = "sip.provider.com"
port: int = 5060 port: int = 5060
@@ -24,7 +24,7 @@ class SIPTrunkSettings(BaseSettings):
class GatewaySIPSettings(BaseSettings): class GatewaySIPSettings(BaseSettings):
"""Gateway SIP listener for device registration.""" """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" host: str = "0.0.0.0"
port: int = 5060 port: int = 5060
@@ -34,7 +34,7 @@ class GatewaySIPSettings(BaseSettings):
class SpeachesSettings(BaseSettings): class SpeachesSettings(BaseSettings):
"""Speaches STT service configuration.""" """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" url: str = "http://localhost:22070"
model: str = "whisper-large-v3" model: str = "whisper-large-v3"
@@ -43,7 +43,7 @@ class SpeachesSettings(BaseSettings):
class ClassifierSettings(BaseSettings): class ClassifierSettings(BaseSettings):
"""Audio classifier thresholds.""" """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 music_threshold: float = 0.7
speech_threshold: float = 0.6 speech_threshold: float = 0.6
@@ -54,7 +54,7 @@ class ClassifierSettings(BaseSettings):
class LLMSettings(BaseSettings): class LLMSettings(BaseSettings):
"""LLM service configuration (OpenAI-compatible API).""" """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" base_url: str = "http://localhost:11434/v1"
model: str = "llama3" model: str = "llama3"
@@ -67,7 +67,7 @@ class LLMSettings(BaseSettings):
class HoldSlayerSettings(BaseSettings): class HoldSlayerSettings(BaseSettings):
"""Hold Slayer behavior settings.""" """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_transfer_device: str = Field(
default="sip_phone", validation_alias="DEFAULT_TRANSFER_DEVICE" default="sip_phone", validation_alias="DEFAULT_TRANSFER_DEVICE"
@@ -79,7 +79,7 @@ class HoldSlayerSettings(BaseSettings):
class TTSSettings(BaseSettings): class TTSSettings(BaseSettings):
"""Rhema TTS service configuration (OpenAI-compatible /v1/audio/speech).""" """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" base_url: str = "http://localhost:8000"
model: str = "speaches-ai/Kokoro-82M-v1.0-ONNX" model: str = "speaches-ai/Kokoro-82M-v1.0-ONNX"
@@ -92,7 +92,7 @@ class TTSSettings(BaseSettings):
class ReceptionistSettings(BaseSettings): class ReceptionistSettings(BaseSettings):
"""AI Receptionist behavior settings.""" """AI Receptionist behavior settings."""
model_config = SettingsConfigDict(env_prefix="RECEPTIONIST_") model_config = SettingsConfigDict(env_prefix="RECEPTIONIST_", env_file=".env", extra="ignore")
enabled: bool = True enabled: bool = True
greeting_template: str = ( greeting_template: str = (
@@ -133,6 +133,11 @@ class Settings(BaseSettings):
# Outbound-call safety cap (REST + MCP make_call) # Outbound-call safety cap (REST + MCP make_call)
max_concurrent_calls: int = 4 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 # Notifications
notify_sms_number: str = "" notify_sms_number: str = ""

View File

@@ -5,15 +5,19 @@ Central nervous system of the gateway. Tracks all active calls,
publishes events, and coordinates between SIP engine and services. publishes events, and coordinates between SIP engine and services.
""" """
import asyncio
import logging import logging
import uuid import uuid
from collections.abc import AsyncIterator
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from core.event_bus import EventBus, EventSubscription from core.event_bus import EventBus
from models.call import ActiveCall, AudioClassification, CallMode, CallStatus, ClassificationResult from models.call import (
ActiveCall,
CallMode,
CallStatus,
ClassificationResult,
TranscriptEntry,
)
from models.events import EventType, GatewayEvent from models.events import EventType, GatewayEvent
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -26,11 +30,12 @@ class CallManager:
The single source of truth for what's happening on the gateway. 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.event_bus = event_bus
self._active_calls: dict[str, ActiveCall] = {} self._active_calls: dict[str, ActiveCall] = {}
self._call_legs: dict[str, str] = {} # SIP leg ID -> call ID mapping 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 # Call Lifecycle
@@ -67,6 +72,14 @@ class CallManager:
message=f"📞 Calling {remote_number} ({mode.value})", 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 return call
async def update_status(self, call_id: str, status: CallStatus) -> None: 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%})", message=f"🎵 Audio: {result.audio_type.value} ({result.confidence:.0%})",
)) ))
async def add_transcript(self, call_id: str, text: str) -> None: async def add_transcript(
"""Add a transcript chunk to a call.""" 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) call = self._active_calls.get(call_id)
if not call: if not call:
return 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( await self.event_bus.publish(GatewayEvent(
type=EventType.TRANSCRIPT_CHUNK, type=EventType.TRANSCRIPT_CHUNK,
call_id=call_id, 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}'", 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.""" """Map a SIP leg ID to a call ID."""
self._call_legs[sip_leg_id] = 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]: def get_call_for_leg(self, sip_leg_id: str) -> Optional[ActiveCall]:
"""Look up which call a SIP leg belongs to.""" """Look up which call a SIP leg belongs to."""
call_id = self._call_legs.get(sip_leg_id) call_id = self._call_legs.get(sip_leg_id)

View File

@@ -20,63 +20,70 @@ class EventBus:
Features: Features:
- Non-blocking publish (put_nowait) - Non-blocking publish (put_nowait)
- Automatic dead-subscriber cleanup (full queues are removed) - Slow subscribers lose their oldest event, never their subscription
- Event history (last N events for late joiners) - Event history (last N events, replayable to late joiners)
- Typed event filtering on subscriptions - Typed event filtering on subscriptions
- Async iteration via EventSubscription - Async iteration via EventSubscription
""" """
def __init__(self, max_history: int = 1000): 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._history: list[GatewayEvent] = []
self._max_history = max_history self._max_history = max_history
async def publish(self, event: GatewayEvent) -> None: 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) self._history.append(event)
if len(self._history) > self._max_history: if len(self._history) > self._max_history:
self._history = self._history[-self._max_history :] self._history = self._history[-self._max_history :]
logger.info(f"📡 Event: {event.type.value} | {event.message or ''}") logger.info(f"📡 Event: {event.type.value} | {event.message or ''}")
dead_queues = [] for sub in self._subscribers:
for queue, type_filter in self._subscribers: if sub.type_filter and event.type not in sub.type_filter:
# Skip if subscriber has a type filter and this event doesn't match
if type_filter and event.type not in type_filter:
continue continue
try: sub.deliver(event)
queue.put_nowait(event)
except asyncio.QueueFull:
dead_queues.append((queue, type_filter))
for entry in dead_queues:
self._subscribers.remove(entry)
def subscribe( def subscribe(
self, self,
max_size: int = 100, max_size: int = 100,
event_types: Optional[set[EventType]] = None, event_types: Optional[set[EventType]] = None,
replay_last: int = 0,
) -> "EventSubscription": ) -> "EventSubscription":
""" """
Create a new subscription. Create a new subscription.
Args: 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. event_types: Optional filter — only receive these event types.
None means receive everything. None means receive everything.
replay_last: Seed the queue with up to N most recent
history events (post-filter) before live ones.
Returns: Returns:
An async iterator of GatewayEvents. An async iterator of GatewayEvents.
""" """
queue: asyncio.Queue[GatewayEvent] = asyncio.Queue(maxsize=max_size) queue: asyncio.Queue[GatewayEvent] = asyncio.Queue(maxsize=max_size)
entry = (queue, event_types) sub = EventSubscription(queue, self, event_types)
self._subscribers.append(entry) if replay_last > 0:
return EventSubscription(queue, self, entry) 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.""" """Remove a subscriber."""
if entry in self._subscribers: if sub in self._subscribers:
self._subscribers.remove(entry) self._subscribers.remove(sub)
@property @property
def recent_events(self) -> list[GatewayEvent]: def recent_events(self) -> list[GatewayEvent]:
@@ -95,11 +102,28 @@ class EventSubscription:
self, self,
queue: asyncio.Queue[GatewayEvent], queue: asyncio.Queue[GatewayEvent],
bus: EventBus, bus: EventBus,
entry: tuple, type_filter: Optional[set[EventType]] = None,
): ):
self._queue = queue self._queue = queue
self._bus = bus 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): def __aiter__(self):
return self return self
@@ -108,7 +132,7 @@ class EventSubscription:
try: try:
return await self._queue.get() return await self._queue.get()
except asyncio.CancelledError: except asyncio.CancelledError:
self._bus.unsubscribe(self._entry) self._bus.unsubscribe(self)
raise raise
async def get(self, timeout: Optional[float] = None) -> GatewayEvent: async def get(self, timeout: Optional[float] = None) -> GatewayEvent:
@@ -117,4 +141,4 @@ class EventSubscription:
def close(self): def close(self):
"""Unsubscribe from the event bus.""" """Unsubscribe from the event bus."""
self._bus.unsubscribe(self._entry) self._bus.unsubscribe(self)

View File

@@ -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. The application service that FastAPI and MCP talk to for live-call
This is the top-level object that FastAPI and MCP talk to. work. Composition happens in main.py's lifespan: services are built
there and attached; this module never imports from services/.
""" """
import asyncio
import logging import logging
from collections.abc import Callable
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from config import Settings, get_settings from config import Settings
from core.call_manager import CallManager from core.call_manager import CallManager
from core.dial_plan import is_emergency_number, next_extension from core.dial_plan import is_emergency_number, next_extension
from core.event_bus import EventBus 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.sip_engine import MockSIPEngine, SIPEngine
from core.sippy_engine import SippyEngine from core.sippy_engine import SippyEngine
from models.call import ActiveCall, CallMode, CallStatus from models.call import ActiveCall, CallMode, CallStatus
from models.call_flow import CallFlow
from models.device import Device, DeviceType from models.device import Device, DeviceType
from models.events import EventType, GatewayEvent from models.events import EventType, GatewayEvent
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _extract_number(sip_uri: str) -> str: def build_sip_engine(
"""Pull the user part out of a SIP URI (sip:+15551212@host → +15551212).""" settings: Settings,
if not sip_uri: media_pipeline: MediaPipeline,
return "" on_leg_state_change: Callable,
s = sip_uri.strip() on_device_registered: Callable,
if s.startswith("<") and ">" in s: on_incoming_call: Callable,
s = s[1:s.index(">")] ) -> SIPEngine:
if s.startswith("sip:"): """
s = s[4:] Build the SIP engine from config.
if "@" in s:
s = s.split("@", 1)[0]
return s
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 trunk = settings.sip_trunk
gw_sip = settings.gateway_sip gw_sip = settings.gateway_sip
if trunk.host and trunk.host != "sip.provider.com": if not trunk.host or trunk.host in ("sip.provider.com", "sip.yourprovider.com"):
# Real trunk configured — use Sippy B2BUA raise RuntimeError(
try: "SIP trunk is not configured (SIP_TRUNK_HOST is unset or a "
return SippyEngine( "placeholder). Set SIP_TRUNK_* in .env, or set USE_MOCK_SIP=true "
sip_address=gw_sip.host, "for development without a trunk."
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")
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: class AIPSTNGateway:
""" """
The AI PSTN Gateway. The AI PSTN Gateway.
Central coordination point for: Owns live-call operations (make/transfer/hangup), the device
- SIP engine (signaling + media) registry, and per-call background tasks. Services are attached by
- Call manager (state + events) the composition root; mode handlers launch per-call services
- Hold Slayer service (hold slayer) without the gateway knowing their types.
- Audio classifier
- Transcription service
- Device management
""" """
def __init__( def __init__(
self, self,
settings: Settings, settings: Settings,
sip_engine: Optional[SIPEngine] = None, sip_engine: Optional[SIPEngine] = None,
on_call_created=None,
on_call_ended=None,
): ):
self.settings = settings self.settings = settings
self.event_bus = EventBus() 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.media_pipeline = MediaPipeline(sample_rate=16000)
self.sip_engine: SIPEngine = sip_engine or MockSIPEngine() self.sip_engine: SIPEngine = sip_engine or MockSIPEngine()
# Services (initialized in start()) # Attached by the composition root (attach_services)
self._hold_slayer = None
self._audio_classifier = None
self._transcription = None
self._tts = None 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] = {} 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 # Startup time
self._started_at: Optional[datetime] = None self._started_at: Optional[datetime] = None
@classmethod def spawn(self, coro, name: str) -> asyncio.Task:
def from_config(cls, sip_engine: Optional[SIPEngine] = None) -> "AIPSTNGateway": """Launch a tracked background task."""
"""Create gateway from environment config.""" task = asyncio.get_running_loop().create_task(coro, name=name)
settings = get_settings() self._tasks.add(task)
gw = cls(settings=settings) task.add_done_callback(self._tasks.discard)
if sip_engine is not None: return task
gw.sip_engine = sip_engine
else: def attach_services(self, tts=None) -> None:
gw.sip_engine = _build_sip_engine(settings, gw) """Attach shared services the gateway must manage on shutdown."""
return gw 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 # Lifecycle
# ================================================================ # ================================================================
async def start(self) -> None: 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...") logger.info("🔥 Starting AI PSTN Gateway...")
# Start media pipeline first so SIP engine can hand it RTP streams # Start media pipeline first so SIP engine can hand it RTP streams
@@ -128,26 +143,7 @@ class AIPSTNGateway:
# Start SIP engine # Start SIP engine
await self.sip_engine.start() await self.sip_engine.start()
logger.info(f" SIP Engine: ready") logger.info(" 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
self._started_at = datetime.now() self._started_at = datetime.now()
@@ -176,6 +172,12 @@ class AIPSTNGateway:
"""Gracefully shut down.""" """Gracefully shut down."""
logger.info("Shutting down AI PSTN Gateway...") 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 # End all active calls
for call_id in list(self.call_manager.active_calls.keys()): for call_id in list(self.call_manager.active_calls.keys()):
call = self.call_manager.get_call(call_id) call = self.call_manager.get_call(call_id)
@@ -264,25 +266,10 @@ class AIPSTNGateway:
await self.call_manager.update_status(call.id, CallStatus.FAILED) await self.call_manager.update_status(call.id, CallStatus.FAILED)
raise raise
# If hold_slayer mode, launch the Hold Slayer service # Hand off to the registered per-mode launcher (e.g. hold slayer)
if mode == CallMode.HOLD_SLAYER: handler = self._mode_handlers.get(mode)
from services.hold_slayer import HoldSlayerService if handler is not None:
handler(call, sip_leg_id, call_flow_id)
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}",
)
return call return call
@@ -303,11 +290,14 @@ class AIPSTNGateway:
self.call_manager.map_leg(device_leg_id, call_id) self.call_manager.map_leg(device_leg_id, call_id)
# Get the original PSTN leg # Get the original PSTN leg
pstn_leg_id = None pstn_leg_id = next(
for leg_id, cid in self.call_manager._call_legs.items(): (
if cid == call_id and leg_id != device_leg_id: leg_id
pstn_leg_id = leg_id for leg_id in self.call_manager.legs_for_call(call_id)
break if leg_id != device_leg_id
),
None,
)
if pstn_leg_id: if pstn_leg_id:
# Bridge the PSTN leg and device leg # Bridge the PSTN leg and device leg
@@ -324,9 +314,8 @@ class AIPSTNGateway:
raise ValueError(f"Call {call_id} not found") raise ValueError(f"Call {call_id} not found")
# Hang up all legs associated with this call # Hang up all legs associated with this call
for leg_id, cid in list(self.call_manager._call_legs.items()): for leg_id in self.call_manager.legs_for_call(call_id):
if cid == call_id: await self.sip_engine.hangup(leg_id)
await self.sip_engine.hangup(leg_id)
await self.call_manager.end_call(call_id) await self.call_manager.end_call(call_id)
@@ -366,6 +355,33 @@ class AIPSTNGateway:
if device: if device:
logger.info(f"📱 Device unregistered: {device.name}") 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( async def _on_sip_device_registered(
self, aor: str, contact: str, expires: int self, aor: str, contact: str, expires: int
) -> None: ) -> 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]: def preferred_device(self) -> Optional[Device]:
"""Get the highest-priority online device.""" """Get the highest-priority online device."""
online_devices = [ online_devices = [

View File

@@ -52,11 +52,23 @@ class AudioTap:
self._buffer: asyncio.Queue[bytes] = asyncio.Queue(maxsize=500) self._buffer: asyncio.Queue[bytes] = asyncio.Queue(maxsize=500)
self._active = True self._active = True
self._pjsua2_port = None # PJSUA2 AudioMediaPort for tapping 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: 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: if not self._active:
return 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: try:
self._buffer.put_nowait(pcm_data) self._buffer.put_nowait(pcm_data)
except asyncio.QueueFull: except asyncio.QueueFull:

View File

@@ -9,11 +9,22 @@ Architecture:
Sippy B2BUA → SIP signaling (call control, registration, DTMF) Sippy B2BUA → SIP signaling (call control, registration, DTMF)
PJSUA2 → Media anchor (conference bridge, audio tapping, recording) PJSUA2 → Media anchor (conference bridge, audio tapping, recording)
Sippy B2BUA runs in its own thread (it has its own event loop). Thread-ownership rule:
We bridge async/sync via run_in_executor. - 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 asyncio
import inspect
import logging import logging
import threading import threading
import uuid import uuid
@@ -30,16 +41,15 @@ logger = logging.getLogger(__name__)
# ================================================================ # ================================================================
class SipCallLeg: 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): def __init__(self, leg_id: str, direction: str, remote_uri: str):
self.leg_id = leg_id self.leg_id = leg_id
self.direction = direction # "outbound" or "inbound" self.direction = direction # "outbound" or "inbound"
self.remote_uri = remote_uri self.remote_uri = remote_uri
self.state = "init" # init, trying, ringing, connected, terminated 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.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): def __repr__(self):
return f"<SipCallLeg {self.leg_id} {self.direction} {self.state}{self.remote_uri}>" 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. Handles Sippy B2BUA callbacks for a single call leg.
Sippy B2BUA uses a callback model — when SIP events happen Runs entirely on the Sippy ED thread. It holds only the leg_id
(180 Ringing, 200 OK, BYE, etc.), the corresponding method token and forwards every state change to the asyncio loop via
is called on this controller. the engine's event funnel — it never touches loop-owned state.
""" """
def __init__(self, leg: SipCallLeg, engine: "SippyEngine"): def __init__(self, leg_id: str, engine: "SippyEngine"):
self.leg = leg self.leg_id = leg_id
self.engine = engine self.engine = engine
def on_trying(self): def on_trying(self):
"""100 Trying received.""" """100 Trying received."""
self.leg.state = "trying" logger.debug(f" {self.leg_id}: 100 Trying")
logger.debug(f" {self.leg.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): def on_ringing(self, ringing_code: int = 180):
"""180 Ringing / 183 Session Progress received.""" """180 Ringing / 183 Session Progress received."""
self.leg.state = "ringing" logger.info(f" {self.leg_id}: {ringing_code} Ringing")
logger.info(f" {self.leg.leg_id}: {ringing_code} Ringing") self.engine._post_from_ed("leg_state", {"leg_id": self.leg_id, "state": "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"
)
def on_connected(self, sdp_body: Optional[str] = None): def on_connected(self, sdp_body: Optional[str] = None):
"""200 OK — call connected, media negotiated.""" """200 OK — call connected, media negotiated."""
self.leg.state = "connected" logger.info(f" {self.leg_id}: Connected")
logger.info(f" {self.leg.leg_id}: Connected") self.engine._post_from_ed(
"leg_state", {"leg_id": self.leg_id, "state": "connected", "sdp": sdp_body}
# 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"
)
def on_disconnected(self, reason: str = ""): def on_disconnected(self, reason: str = ""):
"""BYE received or call terminated.""" """BYE received or call terminated."""
self.leg.state = "terminated" logger.info(f" {self.leg_id}: Disconnected ({reason})")
logger.info(f" {self.leg.leg_id}: Disconnected ({reason})") self.engine._ed_forget_leg(self.leg_id)
self.engine._post_from_ed(
# Clean up media "leg_state", {"leg_id": self.leg_id, "state": "terminated", "reason": reason}
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"
)
def on_dtmf(self, digit: str): def on_dtmf(self, digit: str):
"""DTMF digit received (RFC 2833 or SIP INFO).""" """DTMF digit received (RFC 2833 or SIP INFO)."""
self.leg.dtmf_buffer.append(digit) logger.debug(f" {self.leg_id}: DTMF '{digit}'")
logger.debug(f" {self.leg.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._on_incoming_call = on_incoming_call
self._loop: Optional[asyncio.AbstractEventLoop] = None 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._ready = False
self._trunk_registered = False self._trunk_registered = False
self._legs: dict[str, SipCallLeg] = {} self._legs: dict[str, SipCallLeg] = {}
self._bridges: dict[str, SipBridge] = {} self._bridges: dict[str, SipBridge] = {}
self._registered_devices: list[dict] = [] 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) # Sippy B2BUA internals (set during start)
self._sippy_global_config: dict[str, Any] = {} self._sippy_global_config: dict[str, Any] = {}
self._sippy_thread: Optional[threading.Thread] = None 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 # Lifecycle
# ================================================================ # ================================================================
@@ -212,7 +313,6 @@ class SippyEngine(SIPEngine):
try: try:
from sippy.SipConf import SipConf from sippy.SipConf import SipConf
from sippy.SipTransactionManager import SipTransactionManager
# Configure Sippy # Configure Sippy
SipConf.my_address = self._sip_address SipConf.my_address = self._sip_address
@@ -226,7 +326,7 @@ class SippyEngine(SIPEngine):
} }
# Start Sippy's SIP transaction manager in a background thread # 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( self._sippy_thread = threading.Thread(
target=self._run_sippy_loop, target=self._run_sippy_loop,
name="sippy-b2bua", name="sippy-b2bua",
@@ -254,8 +354,8 @@ class SippyEngine(SIPEngine):
def _run_sippy_loop(self): def _run_sippy_loop(self):
"""Run Sippy B2BUA's event loop in a dedicated thread.""" """Run Sippy B2BUA's event loop in a dedicated thread."""
try: try:
from sippy.Core.EventDispatcher import ED2
from sippy.SipTransactionManager import SipTransactionManager from sippy.SipTransactionManager import SipTransactionManager
from sippy.Timeout import Timeout
# Initialize Sippy's transaction manager # Initialize Sippy's transaction manager
stm = SipTransactionManager(self._sippy_global_config, self._handle_sippy_request) stm = SipTransactionManager(self._sippy_global_config, self._handle_sippy_request)
@@ -263,11 +363,9 @@ class SippyEngine(SIPEngine):
logger.info(" Sippy transaction manager started") logger.info(" Sippy transaction manager started")
# Sippy will block here in its event loop # Sippy blocks here dispatching its event loop; callbacks
# For the Twisted-based version, this runs the reactor # injected via ED2.callFromThread run inside this loop.
# For the asyncore version, this runs asyncore.loop() ED2.loop()
from sippy.Core.EventDispatcher import ED
ED.loop()
except Exception as e: except Exception as e:
logger.error(f" Sippy event loop crashed: {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. Handle an incoming SIP REGISTER from a phone or softphone.
Extracts the AOR (address of record) from the To header, records Runs on the Sippy thread: parses the request, replies 200 OK,
the contact and expiry, and sends a 200 OK. The gateway's and posts the registration to the loop funnel, which owns the
register_device() is called asynchronously via the event loop so device list and notifies the gateway.
the phone gets an extension and SIP URI assigned automatically.
""" """
try: try:
to_uri = str(req.getHFBody("to").getUri()) 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}") logger.info(f" SIP REGISTER: {to_uri} contact={contact_uri} expires={expires}")
if expires == 0: if expires == 0:
# De-registration self._post_from_ed("deregister", {"aor": to_uri})
self._registered_devices = [
d for d in self._registered_devices
if d.get("aor") != to_uri
]
logger.info(f" De-registered: {to_uri}")
else: else:
# Update or add registration record self._post_from_ed("register", {
existing = next( "aor": to_uri,
(d for d in self._registered_devices if d.get("aor") == to_uri), "contact": contact_uri,
None, "expires": expires,
) })
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),
)
# Reply 200 OK # Reply 200 OK
req.sendResponse(200, "OK") req.sendResponse(200, "OK")
@@ -348,52 +424,41 @@ class SippyEngine(SIPEngine):
except Exception: except Exception:
pass 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): 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 Runs on the Sippy thread: extracts everything the loop needs
whether to answer (via `accept_inbound`) or reject the leg (URIs, SDP body) as plain strings and posts them. The gateway
based on routing rules. decides whether to answer (via `accept_inbound`) or reject.
""" """
from_uri = str(req.getHFBody("from").getUri()) from_uri = str(req.getHFBody("from").getUri())
to_uri = str(req.getHFBody("to").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_id = f"leg_{uuid.uuid4().hex[:12]}"
leg = SipCallLeg(leg_id, "inbound", from_uri) ua = sip_t.ua if hasattr(sip_t, "ua") else None
leg.sippy_ua = sip_t.ua if hasattr(sip_t, "ua") else None if ua is not None:
leg.pending_invite = req self._ed_ua_to_leg[ua] = leg_id
self._legs[leg_id] = leg self._ed_leg_to_ua[leg_id] = ua
logger.info(f" Incoming call: {from_uri}{to_uri} (leg: {leg_id})") logger.info(f" Incoming call: {from_uri}{to_uri} (leg: {leg_id})")
# Surface to the gateway. If no callback is wired, fall back to self._post_from_ed("incoming_invite", {
# auto-answer so we don't regress the previous behavior. "leg_id": leg_id,
if self._on_incoming_call and self._loop: "from_uri": from_uri,
asyncio.run_coroutine_threadsafe( "to_uri": to_uri,
self._on_incoming_call(from_uri, to_uri, leg_id), "sdp": sdp,
self._loop, })
)
else:
controller = SippyCallController(leg, self)
controller.on_connected(str(req.getBody()) if req.getBody() else None)
async def accept_inbound(self, leg_id: str) -> bool: async def accept_inbound(self, leg_id: str) -> bool:
"""Answer a previously-surfaced inbound INVITE.""" """Answer a previously-surfaced inbound INVITE."""
leg = self._legs.get(leg_id) leg = self._legs.get(leg_id)
if not leg or leg.direction != "inbound": if not leg or leg.direction != "inbound":
return False return False
req = getattr(leg, "pending_invite", None) sdp, leg.pending_sdp = leg.pending_sdp, None
controller = SippyCallController(leg, self) await self._on_engine_event(
body = str(req.getBody()) if req and req.getBody() else None "leg_state", {"leg_id": leg_id, "state": "connected", "sdp": sdp}
controller.on_connected(body) )
return True return True
async def reject_inbound(self, leg_id: str, code: int = 603, reason: str = "Decline") -> bool: 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}") logger.info(f" ⛔ Rejecting inbound leg {leg_id}: {code} {reason}")
# Real SIP rejection would go through Sippy here; we just drop the leg # Real SIP rejection would go through Sippy here; we just drop the leg
# in stub mode so callers see the call terminate. # in stub mode so callers see the call terminate.
self._run_on_sippy(lambda: self._ed_forget_leg(leg_id))
return True return True
def _handle_incoming_bye(self, req, sip_t): def _handle_incoming_bye(self, req, sip_t):
"""Handle incoming BYE — tear down call leg.""" """Handle incoming BYE — tear down call leg (Sippy thread)."""
# Find the leg by Sippy's UA object ua = sip_t.ua if hasattr(sip_t, "ua") else None
for leg in self._legs.values(): leg_id = self._ed_ua_to_leg.get(ua) if ua is not None else None
if leg.sippy_ua and hasattr(sip_t, "ua") and leg.sippy_ua == sip_t.ua: if leg_id:
controller = SippyCallController(leg, self) SippyCallController(leg_id, self).on_disconnected("BYE received")
controller.on_disconnected("BYE received")
break
def _handle_incoming_info(self, req, sip_t): 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 "" body = str(req.getBody()) if req.getBody() else ""
if "dtmf" in body.lower() or "Signal=" in body: 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"): for line in body.split("\n"):
if line.startswith("Signal="): if line.startswith("Signal="):
digit = line.split("=")[1].strip() digit = line.split("=")[1].strip()
for leg in self._legs.values(): SippyCallController(leg_id, self).on_dtmf(digit)
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
async def _register_trunk(self) -> None: async def _register_trunk(self) -> None:
"""Register with the SIP trunk provider.""" """Register with the SIP trunk provider."""
try: logger.info(f" Registering with trunk: {self._trunk_host}:{self._trunk_port}")
from sippy.UA import UA
from sippy.SipRegistrationAgent import SipRegistrationAgent
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 reg_agent = SipRegistrationAgent(
def do_register(): self._sippy_global_config,
try: f"sip:{self._trunk_username}@{self._trunk_host}",
reg_agent = SipRegistrationAgent( f"sip:{self._trunk_host}:{self._trunk_port}",
self._sippy_global_config, auth_name=self._trunk_username,
f"sip:{self._trunk_username}@{self._trunk_host}", auth_password=self._trunk_password,
f"sip:{self._trunk_host}:{self._trunk_port}", )
auth_name=self._trunk_username, reg_agent.register()
auth_password=self._trunk_password, logger.info(" ✅ Trunk registration sent")
) self._post_from_ed("trunk_registered", {"registered": True})
reg_agent.register() except ImportError:
self._trunk_registered = True logger.warning(" Sippy registration agent not available")
logger.info(" ✅ Trunk registration sent") self._post_from_ed("trunk_registered", {"registered": False})
except Exception as e: except Exception as e:
logger.error(f" ❌ Trunk registration failed: {e}") logger.error(f" ❌ Trunk registration failed: {e}")
self._trunk_registered = False self._post_from_ed("trunk_registered", {"registered": False})
await asyncio.get_event_loop().run_in_executor(None, do_register) self._run_on_sippy(do_register)
except ImportError:
logger.warning(" Sippy registration agent not available")
self._trunk_registered = False
async def stop(self) -> None: async def stop(self) -> None:
"""Gracefully shut down the SIP engine.""" """Gracefully shut down the SIP engine."""
logger.info("🔌 Stopping Sippy B2BUA...") 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 # Hang up all active legs
for leg_id in list(self._legs.keys()): for leg_id in list(self._legs.keys()):
try: try:
@@ -473,8 +538,8 @@ class SippyEngine(SIPEngine):
# Stop Sippy's event loop # Stop Sippy's event loop
try: try:
from sippy.Core.EventDispatcher import ED from sippy.Core.EventDispatcher import ED2
ED.breakLoop() ED2.breakLoop()
except Exception: except Exception:
pass pass
@@ -512,14 +577,16 @@ class SippyEngine(SIPEngine):
logger.info(f"📞 Placing call: {from_uri}{remote_uri} (leg: {leg_id})") 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(): def do_invite():
try: try:
from sippy.UA import UA
from sippy.SipCallId import SipCallId
from sippy.CCEvents import CCEventTry 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 # Create Sippy UA for this call
ua = UA( ua = UA(
@@ -527,10 +594,8 @@ class SippyEngine(SIPEngine):
event_cb=controller, event_cb=controller,
nh_address=(self._trunk_host, self._trunk_port), nh_address=(self._trunk_host, self._trunk_port),
) )
leg.sippy_ua = ua self._ed_leg_to_ua[leg_id] = ua
self._ed_ua_to_leg[ua] = leg_id
# Generate SDP for the call
sdp_body = self._generate_sdp(leg_id)
# Send INVITE # Send INVITE
event = CCEventTry( event = CCEventTry(
@@ -539,19 +604,19 @@ class SippyEngine(SIPEngine):
) )
ua.recvEvent(event) ua.recvEvent(event)
leg.state = "trying"
logger.info(f" INVITE sent for {leg_id}") logger.info(f" INVITE sent for {leg_id}")
self._post_from_ed("leg_state", {"leg_id": leg_id, "state": "trying"})
except ImportError: except ImportError:
# Sippy not installed — simulate for development # Sippy not installed — simulate for development
logger.warning(f" Sippy not installed, simulating call for {leg_id}") 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: except Exception as e:
logger.error(f" Failed to send INVITE for {leg_id}: {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 return leg_id
async def hangup(self, call_leg_id: str) -> None: async def hangup(self, call_leg_id: str) -> None:
@@ -563,15 +628,18 @@ class SippyEngine(SIPEngine):
def do_bye(): def do_bye():
try: 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 from sippy.CCEvents import CCEventDisconnect
leg.sippy_ua.recvEvent(CCEventDisconnect()) ua.recvEvent(CCEventDisconnect())
except Exception as e: except Exception as e:
logger.error(f" Error sending BYE for {call_leg_id}: {e}") logger.error(f" Error sending BYE for {call_leg_id}: {e}")
finally: 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 # Clean up media
if self.media_pipeline and leg.media_port is not None: if self.media_pipeline and leg.media_port is not None:
@@ -595,13 +663,13 @@ class SippyEngine(SIPEngine):
def do_dtmf(): def do_dtmf():
try: try:
if leg.sippy_ua: ua = self._ed_leg_to_ua.get(call_leg_id)
# Send via RFC 2833 (in-band RTP event) if ua is not None:
# Sippy handles this through the UA's DTMF sender # Send via SIP INFO through the UA
from sippy.CCEvents import CCEventInfo
for digit in digits: for digit in digits:
from sippy.CCEvents import CCEventInfo
body = f"Signal={digit}\r\nDuration=160\r\n" body = f"Signal={digit}\r\nDuration=160\r\n"
leg.sippy_ua.recvEvent(CCEventInfo(body=body)) ua.recvEvent(CCEventInfo(body=body))
else: else:
logger.warning(f" No UA for {call_leg_id}, DTMF not sent") logger.warning(f" No UA for {call_leg_id}, DTMF not sent")
except ImportError: except ImportError:
@@ -609,7 +677,7 @@ class SippyEngine(SIPEngine):
except Exception as e: except Exception as e:
logger.error(f" DTMF send error: {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) # Device Calls (for transfer)
@@ -638,13 +706,15 @@ class SippyEngine(SIPEngine):
logger.info(f"📱 Calling device: {device.name} ({device.sip_uri}) (leg: {leg_id})") logger.info(f"📱 Calling device: {device.name} ({device.sip_uri}) (leg: {leg_id})")
sdp_body = self._generate_sdp(leg_id)
def do_invite_device(): def do_invite_device():
try: try:
from sippy.UA import UA
from sippy.CCEvents import CCEventTry from sippy.CCEvents import CCEventTry
from sippy.SipCallId import SipCallId 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 # Parse device SIP URI for routing
# sip:robert@192.168.1.100:5060 # sip:robert@192.168.1.100:5060
@@ -662,25 +732,24 @@ class SippyEngine(SIPEngine):
event_cb=controller, event_cb=controller,
nh_address=(host, port), nh_address=(host, port),
) )
leg.sippy_ua = ua self._ed_leg_to_ua[leg_id] = ua
self._ed_ua_to_leg[ua] = leg_id
sdp_body = self._generate_sdp(leg_id)
event = CCEventTry( event = CCEventTry(
(SipCallId(), f"sip:gateway@{self._domain}", device.sip_uri), (SipCallId(), f"sip:gateway@{self._domain}", device.sip_uri),
body=sdp_body, body=sdp_body,
) )
ua.recvEvent(event) ua.recvEvent(event)
leg.state = "trying" self._post_from_ed("leg_state", {"leg_id": leg_id, "state": "trying"})
except ImportError: except ImportError:
logger.warning(f" Sippy not installed, simulating device call for {leg_id}") 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: except Exception as e:
logger.error(f" Failed to call device {device.name}: {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 return leg_id
# ================================================================ # ================================================================

View File

@@ -4,7 +4,9 @@ Database connection and session management.
PostgreSQL via asyncpg + SQLAlchemy async. 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 ( from sqlalchemy import (
JSON, JSON,
@@ -49,7 +51,6 @@ class CallRecord(Base):
hold_time = Column(Integer, default=0) # seconds spent on hold hold_time = Column(Integer, default=0) # seconds spent on hold
device_used = Column(String) device_used = Column(String)
recording_path = Column(String, nullable=True) recording_path = Column(String, nullable=True)
transcript = Column(Text, nullable=True)
summary = Column(Text, nullable=True) summary = Column(Text, nullable=True)
action_items = Column(JSON, nullable=True) action_items = Column(JSON, nullable=True)
sentiment = Column(String, nullable=True) sentiment = Column(String, nullable=True)
@@ -83,24 +84,6 @@ class StoredCallFlow(Base):
return f"<StoredCallFlow {self.id} {self.phone_number}>" 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): class Device(Base):
__tablename__ = "devices" __tablename__ = "devices"
@@ -110,7 +93,7 @@ class Device(Base):
sip_uri = Column(String, nullable=True) # sip:robert@gateway.helu.ca sip_uri = Column(String, nullable=True) # sip:robert@gateway.helu.ca
phone_number = Column(String, nullable=True) # For PSTN devices phone_number = Column(String, nullable=True) # For PSTN devices
priority = Column(Integer, default=10) # Routing priority (lower = higher priority) 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"] capabilities = Column(JSON, default=list) # ["voice", "video", "sms"]
dnd = Column(Boolean, default=False, nullable=False) dnd = Column(Boolean, default=False, nullable=False)
last_seen = Column(DateTime, nullable=True) last_seen = Column(DateTime, nullable=True)
@@ -204,8 +187,13 @@ def get_session_factory() -> async_sessionmaker[AsyncSession]:
return _session_factory return _session_factory
async def get_db() -> AsyncSession: @asynccontextmanager
"""Dependency: yield an async database session.""" 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() factory = get_session_factory()
async with factory() as session: async with factory() as session:
try: try:
@@ -216,11 +204,37 @@ async def get_db() -> AsyncSession:
raise 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(): 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() engine = get_engine()
async with engine.begin() as conn: 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(): async def close_db():

68
db/migrations/env.py Normal file
View 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()

View 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"}

View 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 ###

View File

@@ -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))

View File

@@ -10,7 +10,7 @@ Hold Slayer is a single-process async Python application built on FastAPI. It ac
│ │ │ │
│ ┌──────────┐ ┌──────────┐ ┌───────────┐ ┌──────────────┐ │ │ ┌──────────┐ ┌──────────┐ ┌───────────┐ ┌──────────────┐ │
│ │ REST API │ │WebSocket │ │MCP Server │ │ Dashboard │ │ │ │ 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 | | 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 | | 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 ### 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 | | Recording | `services/recording.py` | WAV file management and storage |
| Analytics | `services/call_analytics.py` | Call metrics, hold time stats, trends | | Analytics | `services/call_analytics.py` | Call metrics, hold time stats, trends |
| Notifications | `services/notification.py` | WebSocket + SMS alerts | | 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 ## Data Flow — Hold Slayer Call

View File

@@ -126,7 +126,7 @@ uvicorn main:app --host 0.0.0.0 --port 8000 --reload
### Production ### Production
```bash ```bash
# Use PostgreSQL instead of SQLite # PostgreSQL is required (no SQLite fallback)
DATABASE_URL=postgresql+asyncpg://user:pass@localhost/hold_slayer DATABASE_URL=postgresql+asyncpg://user:pass@localhost/hold_slayer
# Use vLLM for faster inference # Use vLLM for faster inference

View File

@@ -4,7 +4,7 @@
### Prerequisites ### Prerequisites
- Python 3.13+ - Python 3.12+
- Ollama (or any OpenAI-compatible LLM) — for IVR menu analysis - Ollama (or any OpenAI-compatible LLM) — for IVR menu analysis
- Speaches or Whisper API — for speech-to-text (optional for dev) - Speaches or Whisper API — for speech-to-text (optional for dev)
- A SIP trunk account — for making real calls (optional for dev) - A SIP trunk account — for making real calls (optional for dev)

View File

@@ -1,10 +1,20 @@
# MCP Server # 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 ## 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 ## Tools
@@ -15,13 +25,32 @@ Place an outbound call through the SIP trunk.
| Param | Type | Required | Description | | Param | Type | Required | Description |
|-------|------|----------|-------------| |-------|------|----------|-------------|
| `number` | string | Yes | Phone number to call (E.164 format) | | `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 | | `intent` | string | No | What you want to accomplish on the call |
| `call_flow_id` | string | No | ID of a stored call flow to follow | | `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. Hang up an active call.
@@ -29,102 +58,117 @@ Hang up an active call.
|-------|------|----------|-------------| |-------|------|----------|-------------|
| `call_id` | string | Yes | The call to hang up | | `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_active_calls
List all calls currently in progress. No parameters. 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_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_call_history
Search past calls by number, company, or date range. Search past call records.
| Param | Type | Required | Description | | Param | Type | Required | Description |
|-------|------|----------|-------------| |-------|------|----------|-------------|
| `query` | string | Yes | Search term (phone number, company name) | | `phone_number` | string | No | Filter by phone number (partial match) |
| `limit` | int | No | Max results (default: 20) | | `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 ### 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 | | Param | Type | Required | Description |
|-------|------|----------|-------------| |-------|------|----------|-------------|
| `call_id` | string | Yes | The exploration call to learn from | | `call_id` | string | Yes | A completed call that ran in exploration mode |
| `company` | string | No | Company name for the flow | | `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 ## Resources
MCP resources provide read-only data that assistants can reference:
| Resource URI | Description | | Resource URI | Description |
|-------------|-------------| |-------------|-------------|
| `gateway://status` | Current gateway status — trunk registration, active calls, service health | | `gateway://status` | Current gateway status — trunk registration, active calls |
| `gateway://calls` | List of all active calls with current status | | `gateway://call-flows` | List of all stored call flows |
| `gateway://calls/{call_id}` | Detailed status for a specific call | | `gateway://active-calls` | All active calls with current status |
| `gateway://flows` | List of all stored call flows |
| `gateway://analytics` | Call analytics summary |
## 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 ```json
{ {
"mcpServers": { "mcpServers": {
"hold-slayer": { "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 ## 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" **User:** "Call Chase Bank and dispute the Amazon charge from December 15th"
**Assistant actions:** **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")` 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"` 2. Receives `call_id: "call_abc123"`
3. Polls `get_call_status("call_abc123")` periodically 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." 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! 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!" 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. 8. After the call, calls `learn_call_flow("call_abc123", company_name="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."

129
main.py
View File

@@ -21,9 +21,19 @@ from fastapi.staticfiles import StaticFiles
from api import call_flows, call_history, calls, devices, routing, websocket from api import call_flows, call_history, calls, devices, routing, websocket
from api.deps import require_token from api.deps import require_token
from config import Settings, get_settings 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 db.database import close_db, init_db
from mcp_server.server import create_mcp_server 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 # Configure logging
logging.basicConfig( logging.basicConfig(
@@ -126,23 +136,70 @@ async def lifespan(app: FastAPI):
except Exception as e: except Exception as e:
_handle_db_error(e) _handle_db_error(e)
# Boot the telephony engine # === Composition root ===
gateway = AIPSTNGateway.from_config() # 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() await gateway.start()
app.state.gateway = gateway app.state.gateway = gateway
app.state.routing_service = routing_svc
# Start auxiliary services app.state.transcription_service = transcription
from services.notification import NotificationService
from services.recording import RecordingService
notification_svc = NotificationService(gateway.event_bus, settings) notification_svc = NotificationService(gateway.event_bus, settings)
await notification_svc.start() await notification_svc.start()
app.state.notification_service = notification_svc app.state.notification_service = notification_svc
recording_svc = RecordingService()
await recording_svc.start() await recording_svc.start()
app.state.recording_service = recording_svc app.state.recording_service = recording_svc
gateway._recording_service = recording_svc
logger.info("=" * 60) logger.info("=" * 60)
logger.info("🔥 Hold Slayer Gateway is LIVE") logger.info("🔥 Hold Slayer Gateway is LIVE")
@@ -250,21 +307,67 @@ async def root():
@app.get("/health", tags=["System"]) @app.get("/health", tags=["System"])
async def health(): 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) gateway = getattr(app.state, "gateway", None)
ready = gateway is not None and await gateway.sip_engine.is_ready() 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} trunk_status = await gateway.sip_engine.get_trunk_status() if gateway else {"registered": False}
return { engine_mode = (
"status": "healthy" if ready else "degraded", "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", "gateway": "ready" if gateway else "not initialized",
"engine": engine_mode,
"sip_engine": "ready" if ready else "not ready", "sip_engine": "ready" if ready else "not ready",
"database": "ok" if db_ok else f"error: {db_error}",
"sip_trunk": { "sip_trunk": {
"registered": trunk_status.get("registered", False), "registered": trunk_status.get("registered", False),
"host": trunk_status.get("host"), "host": trunk_status.get("host"),
"mock": trunk_status.get("mock", False),
"reason": trunk_status.get("reason"), "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__": if __name__ == "__main__":

View File

@@ -184,18 +184,12 @@ def create_mcp_server(
Returns the IVR navigation tree if one exists. Returns the IVR navigation tree if one exists.
""" """
from db.database import StoredCallFlow, get_session_factory from db.database import session_scope
from sqlalchemy import select from services import call_persistence as store
try: try:
factory = get_session_factory() async with session_scope() as session:
async with factory() as session: row = await store.get_flow_by_number(session, phone_number)
result = await session.execute(
select(StoredCallFlow).where(
StoredCallFlow.phone_number == phone_number
)
)
row = result.scalar_one_or_none()
if not row: if not row:
return f"No stored call flow for {phone_number}." 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 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: try:
steps = json.loads(steps_json) steps = json.loads(steps_json)
flow_id = do_slugify(name) flow_id = do_slugify(name)
factory = get_session_factory() async with session_scope() as session:
async with factory() as session: if await store.get_flow(session, flow_id):
db_flow = StoredCallFlow( return f"Call flow '{flow_id}' already exists."
id=flow_id, await store.create_flow(
session,
flow_id=flow_id,
name=name, name=name,
phone_number=phone_number, phone_number=phone_number,
description="Created by AI assistant", description="Created by AI assistant",
steps=steps, steps=steps,
notes=notes or None,
tags=["ai-created"], 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})" return f"Call flow '{name}' saved for {phone_number} (ID: {flow_id})"
except json.JSONDecodeError: except json.JSONDecodeError:
@@ -269,6 +264,70 @@ def create_mcp_server(
except Exception as e: except Exception as e:
return f"Error creating call flow: {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() @mcp.tool()
async def send_dtmf(call_id: str, digits: str) -> str: async def send_dtmf(call_id: str, digits: str) -> str:
""" """
@@ -283,12 +342,12 @@ def create_mcp_server(
if not call: if not call:
return f"Call {call_id} not found." return f"Call {call_id} not found."
for leg_id, cid in gateway.call_manager._call_legs.items(): legs = gateway.call_manager.legs_for_call(call_id)
if cid == call_id: if not legs:
await gateway.sip_engine.send_dtmf(leg_id, digits) return f"No active SIP leg found for call {call_id}."
return f"Sent DTMF '{digits}' on 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() @mcp.tool()
async def get_call_transcript(call_id: str) -> str: async def get_call_transcript(call_id: str) -> str:
@@ -318,16 +377,12 @@ def create_mcp_server(
Returns the recording file path and status. Returns the recording file path and status.
""" """
from db.database import CallRecord, get_session_factory from db.database import session_scope
from sqlalchemy import select from services import call_persistence as store
try: try:
factory = get_session_factory() async with session_scope() as session:
async with factory() as session: record = await store.get_record(session, call_id)
result = await session.execute(
select(CallRecord).where(CallRecord.id == call_id)
)
record = result.scalar_one_or_none()
if not record: if not record:
return f"No record found for call {call_id}." return f"No record found for call {call_id}."
if not record.recording_path: if not record.recording_path:
@@ -348,16 +403,12 @@ def create_mcp_server(
Returns the summary, action items, and sentiment analysis. Returns the summary, action items, and sentiment analysis.
""" """
from db.database import CallRecord, get_session_factory from db.database import session_scope
from sqlalchemy import select from services import call_persistence as store
try: try:
factory = get_session_factory() async with session_scope() as session:
async with factory() as session: record = await store.get_record(session, call_id)
result = await session.execute(
select(CallRecord).where(CallRecord.id == call_id)
)
record = result.scalar_one_or_none()
if not record: if not record:
return f"No record found for call {call_id}." return f"No record found for call {call_id}."
@@ -398,27 +449,17 @@ def create_mcp_server(
intent: Filter by intent text (partial match) intent: Filter by intent text (partial match)
limit: Max results to return (default 10) limit: Max results to return (default 10)
""" """
from db.database import CallRecord, get_session_factory from db.database import session_scope
from sqlalchemy import select from services import call_persistence as store
try: try:
factory = get_session_factory() async with session_scope() as session:
async with factory() as session: records = await store.search_history(
query = select(CallRecord).order_by( session,
CallRecord.started_at.desc() number_contains=phone_number or None,
).limit(limit) intent_contains=intent or None,
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()
if not records: if not records:
return "No matching call records found." return "No matching call records found."
@@ -482,14 +523,12 @@ def create_mcp_server(
@mcp.resource("gateway://call-flows") @mcp.resource("gateway://call-flows")
async def resource_call_flows() -> str: async def resource_call_flows() -> str:
"""List all stored call flows.""" """List all stored call flows."""
from db.database import StoredCallFlow, get_session_factory from db.database import session_scope
from sqlalchemy import select from services import call_persistence as store
try: try:
factory = get_session_factory() async with session_scope() as session:
async with factory() as session: rows = await store.list_flows(session)
result = await session.execute(select(StoredCallFlow))
rows = result.scalars().all()
flows = [ flows = [
{ {
"id": r.id, "id": r.id,

View File

@@ -55,6 +55,14 @@ class ClassificationResult(BaseModel):
details: Optional[dict] = None # Extra analysis data 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): class ActiveCall(BaseModel):
"""In-memory state for an active call.""" """In-memory state for an active call."""
@@ -71,9 +79,12 @@ class ActiveCall(BaseModel):
hold_started_at: Optional[datetime] = None hold_started_at: Optional[datetime] = None
current_classification: AudioClassification = AudioClassification.UNKNOWN current_classification: AudioClassification = AudioClassification.UNKNOWN
classification_history: list[ClassificationResult] = Field(default_factory=list) 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 current_step_id: Optional[str] = None # Current position in call flow
services: list[str] = Field(default_factory=list) # Active services on this call 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 @property
def duration(self) -> int: def duration(self) -> int:
@@ -92,7 +103,7 @@ class ActiveCall(BaseModel):
@property @property
def transcript(self) -> str: def transcript(self) -> str:
"""Full transcript so far.""" """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: def summary(self) -> dict:
"""Compact summary for list views.""" """Compact summary for list views."""
@@ -145,6 +156,16 @@ class CallResponse(BaseModel):
mode: str mode: str
message: Optional[str] = None 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): class CallStatusResponse(BaseModel):
"""Full status of an active or completed call.""" """Full status of an active or completed call."""
@@ -163,6 +184,25 @@ class CallStatusResponse(BaseModel):
current_step: Optional[str] = None current_step: Optional[str] = None
services: list[str] = Field(default_factory=list) 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): class TransferRequest(BaseModel):
"""Request to transfer a call to a device.""" """Request to transfer a call to a device."""

View File

@@ -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

View File

@@ -8,7 +8,7 @@ from datetime import datetime
from enum import Enum from enum import Enum
from typing import Optional from typing import Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, computed_field
class DeviceType(str, Enum): class DeviceType(str, Enum):
@@ -43,6 +43,7 @@ class Device(DeviceBase):
created_at: Optional[datetime] = None created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None
@computed_field # serialized so API consumers see routability directly
@property @property
def can_receive_call(self) -> bool: def can_receive_call(self) -> bool:
"""Can this device receive a call right now?""" """Can this device receive a call right now?"""
@@ -71,14 +72,3 @@ class DeviceUpdate(BaseModel):
phone_number: Optional[str] = None phone_number: Optional[str] = None
priority: Optional[int] = None priority: Optional[int] = None
capabilities: Optional[list[str]] = 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

View File

@@ -13,34 +13,30 @@ dependencies = [
# Web framework # Web framework
"fastapi>=0.115.0", "fastapi>=0.115.0",
"uvicorn[standard]>=0.32.0", "uvicorn[standard]>=0.32.0",
"websockets>=13.0",
# Database # Database
"sqlalchemy[asyncio]>=2.0.36", "sqlalchemy[asyncio]>=2.0.36",
"asyncpg>=0.30.0", "asyncpg>=0.30.0",
"alembic>=1.14.0", "alembic>=1.14.0",
# Settings & validation # Settings & validation
"pydantic>=2.10.0", "pydantic>=2.10.0",
"pydantic-settings>=2.6.0", "pydantic-settings>=2.6.0",
# SIP signaling # SIP signaling
"sippy>=1.2.0", "sippy>=1.2.0",
# Audio analysis # Audio analysis
"numpy>=1.26.0", "numpy>=1.26.0",
"librosa>=0.10.0",
"soundfile>=0.12.0",
# HTTP client (for Speaches STT) # HTTP client (for Speaches STT)
"httpx>=0.28.0", "httpx>=0.28.0",
# MCP server (3.x — http_app + StaticTokenVerifier) # MCP server (3.x — http_app + StaticTokenVerifier)
"fastmcp>=3.0.0", "fastmcp>=3.0.0",
# Utilities # Utilities
"python-slugify>=8.0.0", "python-slugify>=8.0.0",
"python-multipart>=0.0.12",
] ]
[project.optional-dependencies] [project.optional-dependencies]
@@ -50,6 +46,7 @@ dev = [
"pytest-cov>=6.0.0", "pytest-cov>=6.0.0",
"httpx>=0.28.0", "httpx>=0.28.0",
"ruff>=0.8.0", "ruff>=0.8.0",
"aiosqlite>=0.22.0",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
@@ -69,3 +66,7 @@ line-length = 100
[tool.ruff.lint] [tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "UP"] 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"]

View File

@@ -12,6 +12,7 @@ Uses spectral analysis (librosa/numpy) to classify audio without needing
a trained ML model — just signal processing and heuristics. a trained ML model — just signal processing and heuristics.
""" """
import asyncio
import logging import logging
import time import time
from typing import Optional from typing import Optional
@@ -47,9 +48,23 @@ class AudioClassifier:
self._window_samples = int(settings.window_seconds * SAMPLE_RATE) self._window_samples = int(settings.window_seconds * SAMPLE_RATE)
self._classification_history: list[AudioClassification] = [] 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: def classify_chunk(self, audio_data: bytes) -> ClassificationResult:
""" """
Classify a chunk of audio data. Classify a chunk of audio data (pure, synchronous).
Args: Args:
audio_data: Raw PCM audio (16-bit signed, 16kHz, mono) 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", (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: def goertzel_power(freq: int) -> float:
k = int(0.5 + len(samples) * freq / SAMPLE_RATE) k = int(0.5 + len(samples) * freq / SAMPLE_RATE)
w = 2 * np.pi * k / len(samples) bin_value = np.dot(samples, np.exp(-2j * np.pi * k * n / len(samples)))
coeff = 2 * np.cos(w) return float(np.abs(bin_value) ** 2)
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)
# Find strongest low and high frequencies # Find strongest low and high frequencies
low_powers = [(f, goertzel_power(f)) for f in dtmf_freqs_low] low_powers = [(f, goertzel_power(f)) for f in dtmf_freqs_low]

View File

@@ -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]
),
}

View File

@@ -1,70 +1,368 @@
""" """
Call Persistence — Writes completed calls and their transcript chunks Call Persistence — the data-access layer for calls and call flows.
to the database when CallManager.end_call() fires.
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 logging
import uuid import uuid
from datetime import datetime 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 import ActiveCall, CallStatus
from models.call_flow import CallFlow, CallFlowStep
from models.device import Device
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
async def persist_call_on_end(call: ActiveCall, final_status: CallStatus) -> None: def flow_to_model(row: StoredCallFlow) -> CallFlow:
"""Insert a CallRecord and any transcript chunks for `call`. """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: await _with_retry(_insert_in_progress_record, call)
async with get_session_factory()() as session:
record = CallRecord(
id=call.id, async def persist_call_on_end(call: ActiveCall, final_status: CallStatus) -> None:
direction=call.direction, """Finalize the CallRecord and write transcript chunks for `call`.
remote_number=call.remote_number,
status=final_status.value, Wired into CallManager as its on_call_ended hook by the
mode=call.mode.value, composition root in main.py.
intent=call.intent, """
started_at=call.started_at, await _with_retry(_finalize_call_record, call, final_status)
ended_at=datetime.now(),
duration=int(call.duration),
hold_time=int(call.hold_time), async def _with_retry(write, call: ActiveCall, *args) -> None:
device_used=call.device, """Losing the row means the call never happened as far as history
call_flow_id=call.call_flow_id, is concerned, so the final failure logs at ERROR with identifiers."""
classification_timeline=[ for attempt in range(3):
{ try:
"timestamp": c.timestamp, await write(call, *args)
"audio_type": c.audio_type.value, return
"confidence": c.confidence, except Exception as e:
} if attempt == 2:
for c in call.classification_history logger.error(
], f"Call record lost ({write.__name__}): id={call.id} "
metadata_={"services": list(call.services)}, 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) session.add(record)
# Each transcript chunk gets its own row with a sequence number record.direction = call.direction
# so the dashboard can render them in order with click-to-seek. record.remote_number = call.remote_number
for seq, text in enumerate(call.transcript_chunks): record.status = final_status.value
speaker = "unknown" record.mode = call.mode.value
payload = text record.intent = call.intent
if ":" in text: record.started_at = call.started_at
head, rest = text.split(":", 1) record.ended_at = datetime.now()
head = head.strip().lower() record.duration = int(call.duration)
if head in {"caller", "agent", "receptionist", "caller_message"}: record.hold_time = int(call.hold_time)
speaker = head if head != "caller_message" else "caller" record.device_used = call.device
payload = rest.strip() record.call_flow_id = call.call_flow_id
session.add(TranscriptChunk( record.classification_timeline = [
id=f"tc_{uuid.uuid4().hex[:10]}", {
call_id=call.id, "timestamp": c.timestamp,
seq=seq, "audio_type": c.audio_type.value,
t_offset_ms=0, "confidence": c.confidence,
speaker=speaker, }
text=payload, 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() # Each transcript entry gets its own row with a sequence number
except Exception as e: # and real offset so the dashboard can render click-to-seek.
logger.warning(f"Could not persist call {call.id}: {e}") 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,
))

View File

@@ -23,35 +23,12 @@ from models.call import ActiveCall, AudioClassification, CallStatus, Classificat
from models.call_flow import ActionType, CallFlow, CallFlowStep from models.call_flow import ActionType, CallFlow, CallFlowStep
from models.events import EventType, GatewayEvent from models.events import EventType, GatewayEvent
from services.audio_classifier import AudioClassifier from services.audio_classifier import AudioClassifier
from services.llm_client import get_llm
from services.transcription import TranscriptionService from services.transcription import TranscriptionService
from services.tts import TTSService from services.tts import TTSService
logger = logging.getLogger(__name__) 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: class HoldSlayerService:
""" """
@@ -79,6 +56,29 @@ class HoldSlayerService:
self.settings = settings self.settings = settings
self.tts = tts 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( async def run(
self, self,
call: ActiveCall, call: ActiveCall,
@@ -228,7 +228,7 @@ class HoldSlayerService:
# Phase 2: LLM fallback if regex couldn't decide # Phase 2: LLM fallback if regex couldn't decide
if not decision and transcript: if not decision and transcript:
llm = _get_llm() llm = get_llm()
if llm: if llm:
try: try:
logger.info("🤖 Regex inconclusive, asking LLM...") logger.info("🤖 Regex inconclusive, asking LLM...")
@@ -294,7 +294,7 @@ class HoldSlayerService:
logger.info(f"🔍 Exploration mode: discovering IVR for {call.remote_number}") logger.info(f"🔍 Exploration mode: discovering IVR for {call.remote_number}")
await self.call_manager.update_status(call.id, CallStatus.NAVIGATING_IVR) 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 max_time = self.settings.hold_slayer.max_hold_time
start_time = time.time() start_time = time.time()
@@ -323,8 +323,7 @@ class HoldSlayerService:
continue continue
# Classify the audio # Classify the audio
classification = self.classifier.classify_chunk(audio_chunk) classification = await self.classifier.classify(audio_chunk)
self.classifier.update_history(classification.audio_type)
await self.call_manager.add_classification(call.id, classification) await self.call_manager.add_classification(call.id, classification)
# Transcribe if it sounds like speech # Transcribe if it sounds like speech
@@ -333,9 +332,10 @@ class HoldSlayerService:
AudioClassification.IVR_PROMPT, AudioClassification.IVR_PROMPT,
AudioClassification.LIVE_HUMAN, AudioClassification.LIVE_HUMAN,
): ):
transcript = await self.transcription.transcribe( transcript = await self._transcribe(
call.id,
audio_chunk, audio_chunk,
prompt="Phone IVR menu, customer service, press 1 for..." prompt="Phone IVR menu, customer service, press 1 for...",
) )
if transcript: if transcript:
await self.call_manager.add_transcript(call.id, transcript) await self.call_manager.add_transcript(call.id, transcript)
@@ -447,14 +447,13 @@ class HoldSlayerService:
continue continue
# Classify # Classify
result = self.classifier.classify_chunk(audio_chunk) result = await self.classifier.classify(audio_chunk)
self.classifier.update_history(result.audio_type)
await self.call_manager.add_classification(call.id, result) await self.call_manager.add_classification(call.id, result)
# Check for human # Check for human
if result.audio_type == AudioClassification.LIVE_HUMAN: if result.audio_type == AudioClassification.LIVE_HUMAN:
# Verify with transcription # Verify with transcription
transcript = await self.transcription.transcribe(audio_chunk) transcript = await self._transcribe(call.id, audio_chunk)
if transcript: if transcript:
await self.call_manager.add_transcript(call.id, transcript) await self.call_manager.add_transcript(call.id, transcript)
# If we got meaningful speech, it's probably a real person # If we got meaningful speech, it's probably a real person
@@ -508,7 +507,7 @@ class HoldSlayerService:
continue continue
# Classify first # Classify first
result = self.classifier.classify_chunk(audio_chunk) result = await self.classifier.classify(audio_chunk)
if result.audio_type not in ( if result.audio_type not in (
AudioClassification.IVR_PROMPT, AudioClassification.IVR_PROMPT,
AudioClassification.LIVE_HUMAN, AudioClassification.LIVE_HUMAN,
@@ -516,7 +515,7 @@ class HoldSlayerService:
continue continue
# Transcribe # Transcribe
transcript = await self.transcription.transcribe(audio_chunk) transcript = await self._transcribe(call.id, audio_chunk)
if not transcript: if not transcript:
continue continue
@@ -560,7 +559,7 @@ class HoldSlayerService:
if not audio_chunk: if not audio_chunk:
break 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 we're getting silence after speech, the menu prompt is done
if result.audio_type == AudioClassification.SILENCE and transcript_parts: if result.audio_type == AudioClassification.SILENCE and transcript_parts:
@@ -570,7 +569,7 @@ class HoldSlayerService:
AudioClassification.IVR_PROMPT, AudioClassification.IVR_PROMPT,
AudioClassification.LIVE_HUMAN, AudioClassification.LIVE_HUMAN,
): ):
text = await self.transcription.transcribe(audio_chunk) text = await self._transcribe(call.id, audio_chunk)
if text: if text:
transcript_parts.append(text) transcript_parts.append(text)
@@ -738,7 +737,11 @@ class HoldSlayerService:
os.close(fd) os.close(fd)
try: 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: if not ok:
logger.warning(f"🗣️ TTS synthesis returned no audio for: '{text[:60]}'") logger.warning(f"🗣️ TTS synthesis returned no audio for: '{text[:60]}'")
return False return False

View File

@@ -327,15 +327,14 @@ class LLMClient:
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
self._total_errors += 1 self._total_errors += 1
logger.error(f"LLM API error: {e.response.status_code} {e.response.text[:200]}") logger.error(f"LLM API error: {e.response.status_code} {e.response.text[:200]}")
return "" raise
except httpx.TimeoutException: except httpx.TimeoutException:
self._total_errors += 1 self._total_errors += 1
logger.error(f"LLM API timeout after {self.timeout}s") logger.error(f"LLM API timeout after {self.timeout}s")
return "" raise
except Exception as e: except Exception:
self._total_errors += 1 self._total_errors += 1
logger.error(f"LLM client error: {e}") raise
return ""
@staticmethod @staticmethod
def _parse_json_response(text: str) -> dict[str, Any]: def _parse_json_response(text: str) -> dict[str, Any]:
@@ -389,3 +388,31 @@ class LLMClient:
"model": self.model, "model": self.model,
"base_url": self.base_url, "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

View File

@@ -67,7 +67,6 @@ class NotificationService:
self._event_bus = event_bus self._event_bus = event_bus
self._settings = settings self._settings = settings
self._task: Optional[asyncio.Task] = None self._task: Optional[asyncio.Task] = None
self._sms_sender: Optional[Any] = None
# Track what we've already notified (avoid spam) # Track what we've already notified (avoid spam)
self._notified: dict[str, set[str]] = {} # call_id -> set of event types 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 # WebSocket notifications go through the event bus
# (the WebSocket handler in the API reads from EventBus directly) # (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}")

View File

@@ -27,12 +27,100 @@ from models.routing import RoutingAction, RoutingActionType, RoutingDecision
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class ReceptionistService: def _extract_number(sip_uri: str) -> str:
"""Drives the receptionist state machine for a single inbound call.""" """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.gateway = gateway
self.settings = gateway.settings.receptionist 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( async def handle(
self, self,
@@ -46,13 +134,9 @@ class ReceptionistService:
transcript = await self._listen(call, sip_leg_id) transcript = await self._listen(call, sip_leg_id)
if transcript: if transcript:
call.transcript_chunks.append(f"caller: {transcript}") await self.gateway.call_manager.add_transcript(
await self.gateway.event_bus.publish(GatewayEvent( call.id, transcript, speaker="caller"
type=EventType.TRANSCRIPT_CHUNK, )
call_id=call.id,
data={"text": transcript, "speaker": "caller"},
message=f"📝 caller: {transcript[:80]}",
))
classification = await self._classify(call, transcript, routing_decision) classification = await self._classify(call, transcript, routing_decision)
call.intent = classification.get("intent") call.intent = classification.get("intent")
@@ -89,7 +173,10 @@ class ReceptionistService:
await self._speak( await self._speak(
call, sip_leg_id, "One moment, I'll connect you now." 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 call.id, devices, action.ring_timeout
) )
if answered: if answered:
@@ -112,6 +199,19 @@ class ReceptionistService:
# State machine steps # 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: async def _greet(self, call: ActiveCall, sip_leg_id: str) -> None:
await self.gateway.event_bus.publish(GatewayEvent( await self.gateway.event_bus.publish(GatewayEvent(
type=EventType.RECEPTIONIST_GREETING, type=EventType.RECEPTIONIST_GREETING,
@@ -156,10 +256,14 @@ class ReceptionistService:
finally: finally:
tap.close() tap.close()
if not audio: if not audio or self.transcription is None:
return "" 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( async def _classify(
self, self,
@@ -168,9 +272,9 @@ class ReceptionistService:
routing_decision: Optional[RoutingDecision], routing_decision: Optional[RoutingDecision],
) -> dict: ) -> dict:
"""Ask the LLM to interpret the caller's utterance.""" """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(): if llm is None or not transcript.strip():
return { return {
"intent": transcript or "unknown", "intent": transcript or "unknown",
@@ -200,7 +304,7 @@ class ReceptionistService:
system=self.settings.llm_persona, system=self.settings.llm_persona,
) )
except Exception as e: except Exception as e:
logger.warning(f"Receptionist LLM classify failed: {e}") await self._service_error(call.id, "llm", e)
return { return {
"intent": transcript, "intent": transcript,
"urgency": "normal", "urgency": "normal",
@@ -213,10 +317,14 @@ class ReceptionistService:
routing_decision: Optional[RoutingDecision], routing_decision: Optional[RoutingDecision],
classification: dict, classification: dict,
) -> RoutingAction: ) -> RoutingAction:
"""Rules win on conflict; otherwise use the LLM's recommendation.""" """Rules win on conflict; otherwise use the LLM's recommendation.
if routing_decision and routing_decision.action.type not in (
RoutingActionType.TAKE_MESSAGE, 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 return routing_decision.action
recommended = (classification.get("recommended_action") or "ring").lower() 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) await self._speak(call, sip_leg_id, self.settings.message_prompt)
media = self.gateway.media_pipeline 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: if recording_svc is None or media is None:
logger.warning("Receptionist: recording unavailable, ending call") logger.warning("Receptionist: recording unavailable, ending call")
await self._hangup(call, sip_leg_id) await self._hangup(call, sip_leg_id)
@@ -256,43 +364,51 @@ class ReceptionistService:
call.id, media_pipeline=media, leg_ids=[sip_leg_id] call.id, media_pipeline=media, leg_ids=[sip_leg_id]
) )
try: 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: finally:
session = await recording_svc.stop_recording( session = await recording_svc.stop_recording(
call.id, media_pipeline=media call.id, media_pipeline=media
) )
message_text = "" message_text = ""
rec_path = session.filepath_mixed if session else None rec_path = session.filepath_mixed if session else None
if rec_path and Path(rec_path).exists(): if rec_path and Path(rec_path).exists() and self.transcription is not None:
try: try:
audio_bytes = Path(rec_path).read_bytes() audio_bytes = Path(rec_path).read_bytes()
message_text = await self.gateway._transcription.transcribe(audio_bytes) message_text = await self.transcription.transcribe(audio_bytes)
except Exception as e: except Exception as e:
logger.warning(f"Receptionist transcribe failed: {e}") await self._service_error(call.id, "transcription", e)
if message_text: if message_text:
call.transcript_chunks.append(f"caller_message: {message_text}") await self.gateway.call_manager.add_transcript(
call.id, message_text, speaker="caller"
)
await self.gateway.event_bus.publish(GatewayEvent( await self.gateway.event_bus.publish(GatewayEvent(
type=EventType.RECEPTIONIST_MESSAGE_SAVED, type=EventType.RECEPTIONIST_MESSAGE_SAVED,
call_id=call.id, call_id=call.id,
data={ data={
"path": rec_path, "path": rec_path,
"transcript": message_text, "transcript": message_text,
"caller": call.remote_number, "caller": call.remote_number,
}, },
message=f"📥 Message saved from {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 # Helpers
# ---------------------------------------------------------------- # ----------------------------------------------------------------
async def _speak(self, call: ActiveCall, sip_leg_id: str, text: str) -> None: 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 media = self.gateway.media_pipeline
if tts is None or media is None or not text.strip(): if tts is None or media is None or not text.strip():
return return
@@ -303,7 +419,11 @@ class ReceptionistService:
fd, tmp_path = tempfile.mkstemp(suffix=".wav", prefix=f"recept_{call.id}_") fd, tmp_path = tempfile.mkstemp(suffix=".wav", prefix=f"recept_{call.id}_")
os.close(fd) os.close(fd)
try: 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: if not ok:
return return
await media.play_wav(sip_leg_id, tmp_path) await media.play_wav(sip_leg_id, tmp_path)

View File

@@ -39,6 +39,7 @@ class RecordingService:
self._max_recording_seconds = max_recording_seconds self._max_recording_seconds = max_recording_seconds
self._sample_rate = sample_rate self._sample_rate = sample_rate
self._active_recordings: dict[str, RecordingSession] = {} self._active_recordings: dict[str, RecordingSession] = {}
self._timeout_tasks: dict[str, asyncio.Task] = {}
self._metadata: list[dict] = [] self._metadata: list[dict] = []
async def start(self) -> None: async def start(self) -> None:
@@ -90,6 +91,7 @@ class RecordingService:
filepath_agent=filepath_agent, filepath_agent=filepath_agent,
started_at=datetime.now(), started_at=datetime.now(),
sample_rate=self._sample_rate, sample_rate=self._sample_rate,
leg_ids=leg_ids,
) )
# Start PJSUA2 recording if media pipeline is available # Start PJSUA2 recording if media pipeline is available
@@ -101,8 +103,8 @@ class RecordingService:
self._active_recordings[call_id] = session self._active_recordings[call_id] = session
logger.info(f"🔴 Recording started: {call_id}{filepath_mixed}") logger.info(f"🔴 Recording started: {call_id}{filepath_mixed}")
# Safety timeout # Safety timeout — tracked so it can be cancelled and isn't GC'd
asyncio.create_task( self._timeout_tasks[call_id] = asyncio.create_task(
self._recording_timeout(call_id), self._recording_timeout(call_id),
name=f"rec_timeout_{call_id}", name=f"rec_timeout_{call_id}",
) )
@@ -115,6 +117,14 @@ class RecordingService:
media_pipeline=None, media_pipeline=None,
) -> Optional["RecordingSession"]: ) -> Optional["RecordingSession"]:
"""Stop recording a call and finalize the WAV file.""" """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) session = self._active_recordings.pop(call_id, None)
if not session: if not session:
logger.warning(f" No active recording for {call_id}") logger.warning(f" No active recording for {call_id}")
@@ -150,26 +160,38 @@ class RecordingService:
@staticmethod @staticmethod
async def _persist_recording(session: "RecordingSession") -> None: async def _persist_recording(session: "RecordingSession") -> None:
"""Write a recordings row for this session. Failures are non-fatal.""" """Write a recordings row for this session, with bounded retry.
try:
import uuid as _uuid
from db.database import RecordingRecord, get_session_factory
async with get_session_factory()() as db: Non-fatal for the call, but a lost row means the dashboard can
db.add(RecordingRecord( never find the WAV — so failures log at ERROR, not warning.
id=f"rec_{_uuid.uuid4().hex[:10]}", """
call_id=session.call_id, import uuid as _uuid
path=session.filepath_mixed or "",
format="wav", from db.database import RecordingRecord, session_scope
duration_s=float(session.duration_seconds or 0),
size_bytes=int(session.file_size_bytes or 0), for attempt in range(3):
channels=1, try:
started_at=session.started_at, async with session_scope() as db:
ended_at=session.stopped_at, db.add(RecordingRecord(
)) id=f"rec_{_uuid.uuid4().hex[:10]}",
await db.commit() call_id=session.call_id,
except Exception as e: path=session.filepath_mixed or "",
logger.warning(f"Recording persistence failed: {e}") 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: async def _recording_timeout(self, call_id: str) -> None:
"""Auto-stop recording after max duration.""" """Auto-stop recording after max duration."""
@@ -230,6 +252,7 @@ class RecordingSession:
filepath_agent: Optional[str] = None, filepath_agent: Optional[str] = None,
started_at: Optional[datetime] = None, started_at: Optional[datetime] = None,
sample_rate: int = 16000, sample_rate: int = 16000,
leg_ids: Optional[list[str]] = None,
): ):
self.call_id = call_id self.call_id = call_id
self.filepath_mixed = filepath_mixed self.filepath_mixed = filepath_mixed
@@ -240,7 +263,7 @@ class RecordingSession:
self.duration_seconds: Optional[int] = None self.duration_seconds: Optional[int] = None
self.file_size_bytes: Optional[int] = None self.file_size_bytes: Optional[int] = None
self.sample_rate = sample_rate self.sample_rate = sample_rate
self._leg_ids: list[str] = [] self._leg_ids: list[str] = list(leg_ids or [])
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {

View File

@@ -27,6 +27,8 @@ class TranscriptionService:
def __init__(self, settings: SpeachesSettings): def __init__(self, settings: SpeachesSettings):
self.settings = settings self.settings = settings
self._client: Optional[httpx.AsyncClient] = None 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: async def _get_client(self) -> httpx.AsyncClient:
"""Get or create the HTTP client.""" """Get or create the HTTP client."""
@@ -60,6 +62,9 @@ class TranscriptionService:
# Convert raw PCM to WAV format for the API # Convert raw PCM to WAV format for the API
wav_data = self._pcm_to_wav(audio_data) 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: try:
response = await client.post( response = await client.post(
"/v1/audio/transcriptions", "/v1/audio/transcriptions",
@@ -72,44 +77,13 @@ class TranscriptionService:
}, },
) )
response.raise_for_status() response.raise_for_status()
text = response.text.strip() except Exception:
logger.debug(f"Transcription: '{text}'") self.available = False
return text raise
self.available = True
except httpx.HTTPStatusError as e: text = response.text.strip()
logger.error(f"Speaches API error: {e.response.status_code} {e.response.text}") logger.debug(f"Transcription: '{text}'")
return "" return text
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
async def close(self) -> None: async def close(self) -> None:
"""Close the HTTP client.""" """Close the HTTP client."""

View File

@@ -22,6 +22,8 @@ class TTSService:
def __init__(self, settings: TTSSettings): def __init__(self, settings: TTSSettings):
self.settings = settings self.settings = settings
self._client: Optional[httpx.AsyncClient] = None 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: async def _get_client(self) -> httpx.AsyncClient:
if self._client is None or self._client.is_closed: if self._client is None or self._client.is_closed:
@@ -54,19 +56,17 @@ class TTSService:
"sample_rate": self.settings.sample_rate, "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: try:
response = await client.post("/v1/audio/speech", json=body) response = await client.post("/v1/audio/speech", json=body)
response.raise_for_status() response.raise_for_status()
return response.content except Exception:
except httpx.HTTPStatusError as e: self.available = False
logger.error(f"Rhema TTS error: {e.response.status_code} {e.response.text}") raise
return b"" self.available = True
except httpx.ConnectError: return response.content
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""
async def synthesize_to_file( async def synthesize_to_file(
self, self,

168
tests/test_concurrency.py Normal file
View 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
View 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

View 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
View 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

View File

@@ -25,6 +25,7 @@ EXPECTED_TOOLS = {
"get_call_recording", "get_call_recording",
"get_call_summary", "get_call_summary",
"search_call_history", "search_call_history",
"learn_call_flow",
"list_devices", "list_devices",
"gateway_status", "gateway_status",
} }

View File

@@ -24,10 +24,27 @@ class TestReceptionistDecide:
gw = _make_gateway() gw = _make_gateway()
svc = ReceptionistService(gw) svc = ReceptionistService(gw)
rule_action = RoutingAction(type=RoutingActionType.REJECT, message="nope") 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"}) chosen = svc._decide(decision, {"recommended_action": "ring"})
assert chosen.type == RoutingActionType.REJECT 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): def test_falls_back_to_llm_when_rule_is_default_take_message(self):
gw = _make_gateway() gw = _make_gateway()
svc = ReceptionistService(gw) svc = ReceptionistService(gw)

View File

@@ -123,14 +123,14 @@ class TestLLMClient:
assert result["key"] == "value" assert result["key"] == "value"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_chat_http_error_returns_empty(self): async def test_chat_error_raises(self):
"""Verify HTTP errors return empty string gracefully.""" """Failures propagate to the caller (which owns the fallback)."""
client = self._make_client() client = self._make_client()
with patch.object(client._client, "post", new_callable=AsyncMock) as mock_post: with patch.object(client._client, "post", new_callable=AsyncMock) as mock_post:
mock_post.side_effect = Exception("Connection refused") mock_post.side_effect = Exception("Connection refused")
result = await client.chat("test", system="test") with pytest.raises(Exception, match="Connection refused"):
assert result == "" await client.chat("test", system="test")
assert client._total_errors == 1 assert client._total_errors == 1
@pytest.mark.asyncio @pytest.mark.asyncio

227
tests/test_structure.py Normal file
View 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
View 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"