Merge stages 2-6 into main (completes the stacked PR chain #2-#6)
PRs #2-#6 each merged into their stacked base branch rather than main; feature/stage5-data-layer ended up holding the full accepted stack. This merge lands that exact tree on main — no new content. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,9 @@ DATABASE_URL=postgresql+asyncpg://holdslayer:<db-password>@localhost:5432/holdsl
|
|||||||
API_TOKEN=
|
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
|
||||||
|
|||||||
25
README.md
25
README.md
@@ -21,7 +21,7 @@ You give it a phone number and an intent ("dispute a charge on my December state
|
|||||||
│ │
|
│ │
|
||||||
│ ┌──────────┐ ┌──────────┐ ┌───────────┐ ┌──────────────┐ │
|
│ ┌──────────┐ ┌──────────┐ ┌───────────┐ ┌──────────────┐ │
|
||||||
│ │ REST API │ │WebSocket │ │MCP Server │ │ Dashboard │ │
|
│ │ 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
42
alembic.ini
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Alembic configuration. The database URL is not set here — env.py
|
||||||
|
# reads it from config.Settings (environment / .env), so CLI runs and
|
||||||
|
# app startup migrate the same database the app uses.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
script_location = db/migrations
|
||||||
|
prepend_sys_path = .
|
||||||
|
path_separator = os
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARNING
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARNING
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
@@ -2,26 +2,21 @@
|
|||||||
Call Flows API — Store and manage IVR navigation trees.
|
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")
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
41
api/calls.py
41
api/calls.py
@@ -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}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
21
config.py
21
config.py
@@ -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 = ""
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
286
core/gateway.py
286
core/gateway.py
@@ -1,15 +1,18 @@
|
|||||||
"""
|
"""
|
||||||
AI PSTN Gateway — The main orchestrator.
|
AI PSTN Gateway — call operations and device registry.
|
||||||
|
|
||||||
Ties together SIP engine, call manager, event bus, and all services.
|
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,35 +20,41 @@ 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 "
|
||||||
|
"placeholder). Set SIP_TRUNK_* in .env, or set USE_MOCK_SIP=true "
|
||||||
|
"for development without a trunk."
|
||||||
|
)
|
||||||
|
|
||||||
return SippyEngine(
|
return SippyEngine(
|
||||||
sip_address=gw_sip.host,
|
sip_address=gw_sip.host,
|
||||||
sip_port=gw_sip.port,
|
sip_port=gw_sip.port,
|
||||||
@@ -56,71 +65,77 @@ def _build_sip_engine(settings: Settings, gateway: "AIPSTNGateway") -> SIPEngine
|
|||||||
trunk_transport=trunk.transport,
|
trunk_transport=trunk.transport,
|
||||||
domain=gw_sip.domain,
|
domain=gw_sip.domain,
|
||||||
did=trunk.did,
|
did=trunk.did,
|
||||||
media_pipeline=gateway.media_pipeline,
|
media_pipeline=media_pipeline,
|
||||||
on_device_registered=gateway._on_sip_device_registered,
|
on_leg_state_change=on_leg_state_change,
|
||||||
on_incoming_call=gateway._on_sip_incoming_call,
|
on_device_registered=on_device_registered,
|
||||||
|
on_incoming_call=on_incoming_call,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Could not create SippyEngine: {e} — using mock")
|
|
||||||
|
|
||||||
return MockSIPEngine()
|
|
||||||
|
|
||||||
|
|
||||||
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,8 +314,7 @@ 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 = [
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,35 +406,14 @@ 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(
|
|
||||||
(d for d in self._registered_devices if d.get("aor") == to_uri),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if existing:
|
|
||||||
existing["contact"] = contact_uri
|
|
||||||
existing["expires"] = expires
|
|
||||||
else:
|
|
||||||
self._registered_devices.append({
|
|
||||||
"aor": to_uri,
|
"aor": to_uri,
|
||||||
"contact": contact_uri,
|
"contact": contact_uri,
|
||||||
"expires": expires,
|
"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,42 +469,37 @@ 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:
|
|
||||||
from sippy.UA import UA
|
|
||||||
from sippy.SipRegistrationAgent import SipRegistrationAgent
|
|
||||||
|
|
||||||
logger.info(f" Registering with trunk: {self._trunk_host}:{self._trunk_port}")
|
logger.info(f" Registering with trunk: {self._trunk_host}:{self._trunk_port}")
|
||||||
|
|
||||||
# Run registration in Sippy's thread
|
|
||||||
def do_register():
|
def do_register():
|
||||||
try:
|
try:
|
||||||
|
from sippy.SipRegistrationAgent import SipRegistrationAgent
|
||||||
|
|
||||||
reg_agent = SipRegistrationAgent(
|
reg_agent = SipRegistrationAgent(
|
||||||
self._sippy_global_config,
|
self._sippy_global_config,
|
||||||
f"sip:{self._trunk_username}@{self._trunk_host}",
|
f"sip:{self._trunk_username}@{self._trunk_host}",
|
||||||
@@ -448,22 +508,27 @@ class SippyEngine(SIPEngine):
|
|||||||
auth_password=self._trunk_password,
|
auth_password=self._trunk_password,
|
||||||
)
|
)
|
||||||
reg_agent.register()
|
reg_agent.register()
|
||||||
self._trunk_registered = True
|
|
||||||
logger.info(" ✅ Trunk registration sent")
|
logger.info(" ✅ Trunk registration sent")
|
||||||
except Exception as e:
|
self._post_from_ed("trunk_registered", {"registered": True})
|
||||||
logger.error(f" ❌ Trunk registration failed: {e}")
|
|
||||||
self._trunk_registered = False
|
|
||||||
|
|
||||||
await asyncio.get_event_loop().run_in_executor(None, do_register)
|
|
||||||
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.warning(" Sippy registration agent not available")
|
logger.warning(" Sippy registration agent not available")
|
||||||
self._trunk_registered = False
|
self._post_from_ed("trunk_registered", {"registered": False})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f" ❌ Trunk registration failed: {e}")
|
||||||
|
self._post_from_ed("trunk_registered", {"registered": False})
|
||||||
|
|
||||||
|
self._run_on_sippy(do_register)
|
||||||
|
|
||||||
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
|
||||||
for digit in digits:
|
|
||||||
from sippy.CCEvents import CCEventInfo
|
from sippy.CCEvents import CCEventInfo
|
||||||
|
for digit in digits:
|
||||||
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
|
||||||
|
|
||||||
# ================================================================
|
# ================================================================
|
||||||
|
|||||||
@@ -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
68
db/migrations/env.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"""
|
||||||
|
Alembic environment — async engine against Base.metadata.
|
||||||
|
|
||||||
|
Two entry paths:
|
||||||
|
- CLI (``alembic upgrade head``): builds an async engine from
|
||||||
|
Settings.database_url and runs migrations on it.
|
||||||
|
- App startup (db.database.init_db): passes an already-open
|
||||||
|
connection via ``config.attributes["connection"]`` so migrations
|
||||||
|
run inside the app's engine instead of opening a second one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import pool
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
|
from config import get_settings
|
||||||
|
from db.database import Base
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Only configure logging on standalone CLI runs — inside the app this
|
||||||
|
# would clobber uvicorn's logger setup.
|
||||||
|
if config.config_file_name is not None and config.attributes.get("connection") is None:
|
||||||
|
fileConfig(config.config_file_name, disable_existing_loggers=False)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Emit SQL to stdout without a live connection (--sql mode)."""
|
||||||
|
context.configure(
|
||||||
|
url=get_settings().database_url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def do_run_migrations(connection) -> None:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_async_migrations() -> None:
|
||||||
|
engine = create_async_engine(get_settings().database_url, poolclass=pool.NullPool)
|
||||||
|
async with engine.connect() as connection:
|
||||||
|
await connection.run_sync(do_run_migrations)
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
connection = config.attributes.get("connection")
|
||||||
|
if connection is not None:
|
||||||
|
do_run_migrations(connection)
|
||||||
|
else:
|
||||||
|
asyncio.run(run_async_migrations())
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
26
db/migrations/script.py.mako
Normal file
26
db/migrations/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
129
db/migrations/versions/1173a71329ed_baseline_schema.py
Normal file
129
db/migrations/versions/1173a71329ed_baseline_schema.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
"""baseline schema
|
||||||
|
|
||||||
|
Revision ID: 1173a71329ed
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-07-10 07:19:08.321778
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '1173a71329ed'
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('call_flows',
|
||||||
|
sa.Column('id', sa.String(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(), nullable=False),
|
||||||
|
sa.Column('phone_number', sa.String(), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('steps', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('last_verified', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('avg_hold_time', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('success_rate', sa.Float(), nullable=True),
|
||||||
|
sa.Column('times_used', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('last_used', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('notes', sa.Text(), nullable=True),
|
||||||
|
sa.Column('tags', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_call_flows_phone_number'), 'call_flows', ['phone_number'], unique=False)
|
||||||
|
op.create_table('call_records',
|
||||||
|
sa.Column('id', sa.String(), nullable=False),
|
||||||
|
sa.Column('direction', sa.String(), nullable=False),
|
||||||
|
sa.Column('remote_number', sa.String(), nullable=False),
|
||||||
|
sa.Column('status', sa.String(), nullable=False),
|
||||||
|
sa.Column('mode', sa.String(), nullable=False),
|
||||||
|
sa.Column('intent', sa.Text(), nullable=True),
|
||||||
|
sa.Column('started_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('ended_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('duration', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('hold_time', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('device_used', sa.String(), nullable=True),
|
||||||
|
sa.Column('recording_path', sa.String(), nullable=True),
|
||||||
|
sa.Column('transcript', sa.Text(), nullable=True),
|
||||||
|
sa.Column('summary', sa.Text(), nullable=True),
|
||||||
|
sa.Column('action_items', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('sentiment', sa.String(), nullable=True),
|
||||||
|
sa.Column('call_flow_id', sa.String(), nullable=True),
|
||||||
|
sa.Column('classification_timeline', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('metadata', sa.JSON(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_call_records_remote_number'), 'call_records', ['remote_number'], unique=False)
|
||||||
|
op.create_table('devices',
|
||||||
|
sa.Column('id', sa.String(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(), nullable=False),
|
||||||
|
sa.Column('type', sa.String(), nullable=False),
|
||||||
|
sa.Column('sip_uri', sa.String(), nullable=True),
|
||||||
|
sa.Column('phone_number', sa.String(), nullable=True),
|
||||||
|
sa.Column('priority', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('is_online', sa.String(), nullable=True),
|
||||||
|
sa.Column('capabilities', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('dnd', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('last_seen', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('recordings',
|
||||||
|
sa.Column('id', sa.String(), nullable=False),
|
||||||
|
sa.Column('call_id', sa.String(), nullable=False),
|
||||||
|
sa.Column('path', sa.String(), nullable=False),
|
||||||
|
sa.Column('format', sa.String(), nullable=True),
|
||||||
|
sa.Column('duration_s', sa.Float(), nullable=True),
|
||||||
|
sa.Column('size_bytes', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('channels', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('started_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('ended_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_recordings_call_id'), 'recordings', ['call_id'], unique=False)
|
||||||
|
op.create_table('routing_rules',
|
||||||
|
sa.Column('id', sa.String(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(), nullable=False),
|
||||||
|
sa.Column('priority', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('enabled', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('match', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('action', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('transcript_chunks',
|
||||||
|
sa.Column('id', sa.String(), nullable=False),
|
||||||
|
sa.Column('call_id', sa.String(), nullable=False),
|
||||||
|
sa.Column('seq', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('t_offset_ms', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('speaker', sa.String(), nullable=True),
|
||||||
|
sa.Column('text', sa.Text(), nullable=False),
|
||||||
|
sa.Column('confidence', sa.Float(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_transcript_chunks_call_id'), 'transcript_chunks', ['call_id'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_transcript_chunks_call_id'), table_name='transcript_chunks')
|
||||||
|
op.drop_table('transcript_chunks')
|
||||||
|
op.drop_table('routing_rules')
|
||||||
|
op.drop_index(op.f('ix_recordings_call_id'), table_name='recordings')
|
||||||
|
op.drop_table('recordings')
|
||||||
|
op.drop_table('devices')
|
||||||
|
op.drop_index(op.f('ix_call_records_remote_number'), table_name='call_records')
|
||||||
|
op.drop_table('call_records')
|
||||||
|
op.drop_index(op.f('ix_call_flows_phone_number'), table_name='call_flows')
|
||||||
|
op.drop_table('call_flows')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""drop dead transcript column, boolean is_online
|
||||||
|
|
||||||
|
Revision ID: 5187577efc23
|
||||||
|
Revises: 1173a71329ed
|
||||||
|
Create Date: 2026-07-10 07:19:40.741327
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '5187577efc23'
|
||||||
|
down_revision: Union[str, None] = '1173a71329ed'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Transcript text lives solely in transcript_chunks rows now.
|
||||||
|
op.drop_column('call_records', 'transcript')
|
||||||
|
|
||||||
|
# String "true"/"false" (or NULL) -> real boolean; NULLs become false.
|
||||||
|
# batch mode so the table-recreate path works on SQLite too.
|
||||||
|
with op.batch_alter_table('devices') as batch_op:
|
||||||
|
batch_op.alter_column(
|
||||||
|
'is_online',
|
||||||
|
existing_type=sa.VARCHAR(),
|
||||||
|
type_=sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
postgresql_using="coalesce(lower(is_online) in ('true', 't', '1'), false)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('devices') as batch_op:
|
||||||
|
batch_op.alter_column(
|
||||||
|
'is_online',
|
||||||
|
existing_type=sa.Boolean(),
|
||||||
|
type_=sa.VARCHAR(),
|
||||||
|
nullable=True,
|
||||||
|
postgresql_using="case when is_online then 'true' else 'false' end",
|
||||||
|
)
|
||||||
|
op.add_column('call_records', sa.Column('transcript', sa.TEXT(), nullable=True))
|
||||||
@@ -10,7 +10,7 @@ Hold Slayer is a single-process async Python application built on FastAPI. It ac
|
|||||||
│ │
|
│ │
|
||||||
│ ┌──────────┐ ┌──────────┐ ┌───────────┐ ┌──────────────┐ │
|
│ ┌──────────┐ ┌──────────┐ ┌───────────┐ ┌──────────────┐ │
|
||||||
│ │ REST API │ │WebSocket │ │MCP Server │ │ Dashboard │ │
|
│ │ 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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
129
main.py
@@ -21,9 +21,19 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from api import call_flows, call_history, calls, devices, routing, websocket
|
from api 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__":
|
||||||
|
|||||||
@@ -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,13 +342,13 @@ 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"Sent DTMF '{digits}' on call {call_id}."
|
|
||||||
|
|
||||||
return f"No active SIP leg found for call {call_id}."
|
return f"No active SIP leg found for call {call_id}."
|
||||||
|
|
||||||
|
await gateway.sip_engine.send_dtmf(legs[0], digits)
|
||||||
|
return f"Sent DTMF '{digits}' on call {call_id}."
|
||||||
|
|
||||||
@mcp.tool()
|
@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,
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
"""
|
|
||||||
Contact models — People and organizations you call.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class PhoneNumber(BaseModel):
|
|
||||||
"""A phone number associated with a contact."""
|
|
||||||
|
|
||||||
number: str # E.164 format
|
|
||||||
label: str = "main" # main, mobile, work, home, fax, etc.
|
|
||||||
primary: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class ContactBase(BaseModel):
|
|
||||||
"""Shared contact fields."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
phone_numbers: list[PhoneNumber]
|
|
||||||
category: Optional[str] = None # personal / business / service
|
|
||||||
routing_preference: Optional[str] = None # how to handle their calls
|
|
||||||
notes: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class Contact(ContactBase):
|
|
||||||
"""Full contact model."""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
call_count: int = 0
|
|
||||||
last_call: Optional[datetime] = None
|
|
||||||
created_at: Optional[datetime] = None
|
|
||||||
updated_at: Optional[datetime] = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def primary_number(self) -> Optional[str]:
|
|
||||||
"""Get the primary phone number."""
|
|
||||||
for pn in self.phone_numbers:
|
|
||||||
if pn.primary:
|
|
||||||
return pn.number
|
|
||||||
return self.phone_numbers[0].number if self.phone_numbers else None
|
|
||||||
|
|
||||||
|
|
||||||
class ContactCreate(ContactBase):
|
|
||||||
"""Request model for creating a contact."""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ContactUpdate(BaseModel):
|
|
||||||
"""Request model for updating a contact."""
|
|
||||||
|
|
||||||
name: Optional[str] = None
|
|
||||||
phone_numbers: Optional[list[PhoneNumber]] = None
|
|
||||||
category: Optional[str] = None
|
|
||||||
routing_preference: Optional[str] = None
|
|
||||||
notes: Optional[str] = None
|
|
||||||
@@ -8,7 +8,7 @@ from datetime import datetime
|
|||||||
from enum import Enum
|
from 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
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ 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",
|
||||||
@@ -29,8 +28,6 @@ dependencies = [
|
|||||||
|
|
||||||
# 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",
|
||||||
@@ -40,7 +37,6 @@ dependencies = [
|
|||||||
|
|
||||||
# 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"]
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -1,324 +0,0 @@
|
|||||||
"""
|
|
||||||
Call Analytics Service — Tracks call metrics and generates insights.
|
|
||||||
|
|
||||||
Monitors call patterns, hold times, success rates, and IVR navigation
|
|
||||||
efficiency. Provides data for the dashboard and API.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from collections import defaultdict
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
from models.call import ActiveCall, AudioClassification, CallMode, CallStatus
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class CallAnalytics:
|
|
||||||
"""
|
|
||||||
In-memory call analytics engine.
|
|
||||||
|
|
||||||
Tracks:
|
|
||||||
- Call success/failure rates
|
|
||||||
- Hold time statistics (avg, min, max, p95)
|
|
||||||
- IVR navigation efficiency
|
|
||||||
- Human detection accuracy
|
|
||||||
- Per-number/company patterns
|
|
||||||
- Time-of-day patterns
|
|
||||||
|
|
||||||
In production, this would be backed by TimescaleDB or similar.
|
|
||||||
For now, we keep rolling windows in memory.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, max_history: int = 10000):
|
|
||||||
self._max_history = max_history
|
|
||||||
self._call_records: list[CallRecord] = []
|
|
||||||
self._company_stats: dict[str, CompanyStats] = defaultdict(CompanyStats)
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# Record Calls
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
def record_call(self, call: ActiveCall) -> None:
|
|
||||||
"""
|
|
||||||
Record a completed call for analytics.
|
|
||||||
|
|
||||||
Called when a call ends (from CallManager).
|
|
||||||
"""
|
|
||||||
record = CallRecord(
|
|
||||||
call_id=call.id,
|
|
||||||
remote_number=call.remote_number,
|
|
||||||
mode=call.mode,
|
|
||||||
status=call.status,
|
|
||||||
intent=call.intent,
|
|
||||||
started_at=call.created_at,
|
|
||||||
duration_seconds=call.duration,
|
|
||||||
hold_time_seconds=call.hold_time,
|
|
||||||
classification_history=[
|
|
||||||
r.audio_type.value for r in call.classification_history
|
|
||||||
],
|
|
||||||
transcript_chunks=list(call.transcript_chunks),
|
|
||||||
services=list(call.services),
|
|
||||||
)
|
|
||||||
|
|
||||||
self._call_records.append(record)
|
|
||||||
|
|
||||||
# Trim history
|
|
||||||
if len(self._call_records) > self._max_history:
|
|
||||||
self._call_records = self._call_records[-self._max_history :]
|
|
||||||
|
|
||||||
# Update company stats
|
|
||||||
company_key = self._normalize_number(call.remote_number)
|
|
||||||
self._company_stats[company_key].update(record)
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
f"📊 Recorded call {call.id}: "
|
|
||||||
f"{call.status.value}, {call.duration}s, hold={call.hold_time}s"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# Aggregate Stats
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
def get_summary(self, hours: int = 24) -> dict[str, Any]:
|
|
||||||
"""Get summary statistics for the last N hours."""
|
|
||||||
cutoff = datetime.now() - timedelta(hours=hours)
|
|
||||||
recent = [r for r in self._call_records if r.started_at >= cutoff]
|
|
||||||
|
|
||||||
if not recent:
|
|
||||||
return {
|
|
||||||
"period_hours": hours,
|
|
||||||
"total_calls": 0,
|
|
||||||
"success_rate": 0.0,
|
|
||||||
"avg_hold_time": 0.0,
|
|
||||||
"avg_duration": 0.0,
|
|
||||||
}
|
|
||||||
|
|
||||||
total = len(recent)
|
|
||||||
successful = sum(1 for r in recent if r.status in (
|
|
||||||
CallStatus.COMPLETED, CallStatus.BRIDGED, CallStatus.HUMAN_DETECTED
|
|
||||||
))
|
|
||||||
failed = sum(1 for r in recent if r.status == CallStatus.FAILED)
|
|
||||||
|
|
||||||
hold_times = [r.hold_time_seconds for r in recent if r.hold_time_seconds > 0]
|
|
||||||
durations = [r.duration_seconds for r in recent if r.duration_seconds > 0]
|
|
||||||
|
|
||||||
hold_slayer_calls = [r for r in recent if r.mode == CallMode.HOLD_SLAYER]
|
|
||||||
hold_slayer_success = sum(
|
|
||||||
1 for r in hold_slayer_calls
|
|
||||||
if r.status in (CallStatus.BRIDGED, CallStatus.HUMAN_DETECTED)
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"period_hours": hours,
|
|
||||||
"total_calls": total,
|
|
||||||
"successful": successful,
|
|
||||||
"failed": failed,
|
|
||||||
"success_rate": round(successful / total, 3) if total else 0.0,
|
|
||||||
"avg_duration": round(sum(durations) / len(durations), 1) if durations else 0.0,
|
|
||||||
"max_duration": max(durations) if durations else 0,
|
|
||||||
"hold_time": {
|
|
||||||
"avg": round(sum(hold_times) / len(hold_times), 1) if hold_times else 0.0,
|
|
||||||
"min": min(hold_times) if hold_times else 0,
|
|
||||||
"max": max(hold_times) if hold_times else 0,
|
|
||||||
"p95": self._percentile(hold_times, 95) if hold_times else 0,
|
|
||||||
"total": sum(hold_times),
|
|
||||||
},
|
|
||||||
"hold_slayer": {
|
|
||||||
"total": len(hold_slayer_calls),
|
|
||||||
"success": hold_slayer_success,
|
|
||||||
"success_rate": round(
|
|
||||||
hold_slayer_success / len(hold_slayer_calls), 3
|
|
||||||
) if hold_slayer_calls else 0.0,
|
|
||||||
},
|
|
||||||
"by_mode": self._group_by_mode(recent),
|
|
||||||
"by_hour": self._group_by_hour(recent),
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_company_stats(self, number: str) -> dict[str, Any]:
|
|
||||||
"""Get stats for a specific company/number."""
|
|
||||||
key = self._normalize_number(number)
|
|
||||||
stats = self._company_stats.get(key)
|
|
||||||
if not stats:
|
|
||||||
return {"number": number, "total_calls": 0}
|
|
||||||
return stats.to_dict(number)
|
|
||||||
|
|
||||||
def get_top_numbers(self, limit: int = 10) -> list[dict[str, Any]]:
|
|
||||||
"""Get the most-called numbers with their stats."""
|
|
||||||
sorted_stats = sorted(
|
|
||||||
self._company_stats.items(),
|
|
||||||
key=lambda x: x[1].total_calls,
|
|
||||||
reverse=True,
|
|
||||||
)[:limit]
|
|
||||||
return [stats.to_dict(number) for number, stats in sorted_stats]
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# Hold Time Trends
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
def get_hold_time_trend(
|
|
||||||
self,
|
|
||||||
number: Optional[str] = None,
|
|
||||||
days: int = 7,
|
|
||||||
) -> list[dict]:
|
|
||||||
"""
|
|
||||||
Get hold time trend data for graphing.
|
|
||||||
|
|
||||||
Returns daily average hold times for the last N days.
|
|
||||||
"""
|
|
||||||
cutoff = datetime.now() - timedelta(days=days)
|
|
||||||
records = [r for r in self._call_records if r.started_at >= cutoff]
|
|
||||||
|
|
||||||
if number:
|
|
||||||
key = self._normalize_number(number)
|
|
||||||
records = [r for r in records if self._normalize_number(r.remote_number) == key]
|
|
||||||
|
|
||||||
# Group by day
|
|
||||||
by_day: dict[str, list[int]] = defaultdict(list)
|
|
||||||
for r in records:
|
|
||||||
day = r.started_at.strftime("%Y-%m-%d")
|
|
||||||
if r.hold_time_seconds > 0:
|
|
||||||
by_day[day].append(r.hold_time_seconds)
|
|
||||||
|
|
||||||
trend = []
|
|
||||||
for i in range(days):
|
|
||||||
date = (datetime.now() - timedelta(days=days - 1 - i)).strftime("%Y-%m-%d")
|
|
||||||
times = by_day.get(date, [])
|
|
||||||
trend.append({
|
|
||||||
"date": date,
|
|
||||||
"avg_hold_time": round(sum(times) / len(times), 1) if times else 0,
|
|
||||||
"call_count": len(times),
|
|
||||||
"max_hold_time": max(times) if times else 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
return trend
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# Helpers
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _normalize_number(number: str) -> str:
|
|
||||||
"""Normalize phone number for grouping."""
|
|
||||||
# Strip formatting, keep last 10 digits
|
|
||||||
digits = "".join(c for c in number if c.isdigit())
|
|
||||||
return digits[-10:] if len(digits) >= 10 else digits
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _percentile(values: list, pct: int) -> float:
|
|
||||||
"""Calculate percentile value."""
|
|
||||||
if not values:
|
|
||||||
return 0.0
|
|
||||||
sorted_vals = sorted(values)
|
|
||||||
idx = int(len(sorted_vals) * pct / 100)
|
|
||||||
idx = min(idx, len(sorted_vals) - 1)
|
|
||||||
return float(sorted_vals[idx])
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _group_by_mode(records: list["CallRecord"]) -> dict[str, int]:
|
|
||||||
"""Group call counts by mode."""
|
|
||||||
by_mode: dict[str, int] = defaultdict(int)
|
|
||||||
for r in records:
|
|
||||||
by_mode[r.mode.value] += 1
|
|
||||||
return dict(by_mode)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _group_by_hour(records: list["CallRecord"]) -> dict[int, int]:
|
|
||||||
"""Group call counts by hour of day."""
|
|
||||||
by_hour: dict[int, int] = defaultdict(int)
|
|
||||||
for r in records:
|
|
||||||
by_hour[r.started_at.hour] += 1
|
|
||||||
return dict(sorted(by_hour.items()))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def total_calls_recorded(self) -> int:
|
|
||||||
return len(self._call_records)
|
|
||||||
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# Data Models
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
class CallRecord:
|
|
||||||
"""A completed call record for analytics."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
call_id: str,
|
|
||||||
remote_number: str,
|
|
||||||
mode: CallMode,
|
|
||||||
status: CallStatus,
|
|
||||||
intent: Optional[str] = None,
|
|
||||||
started_at: Optional[datetime] = None,
|
|
||||||
duration_seconds: int = 0,
|
|
||||||
hold_time_seconds: int = 0,
|
|
||||||
classification_history: Optional[list[str]] = None,
|
|
||||||
transcript_chunks: Optional[list[str]] = None,
|
|
||||||
services: Optional[list[str]] = None,
|
|
||||||
):
|
|
||||||
self.call_id = call_id
|
|
||||||
self.remote_number = remote_number
|
|
||||||
self.mode = mode
|
|
||||||
self.status = status
|
|
||||||
self.intent = intent
|
|
||||||
self.started_at = started_at or datetime.now()
|
|
||||||
self.duration_seconds = duration_seconds
|
|
||||||
self.hold_time_seconds = hold_time_seconds
|
|
||||||
self.classification_history = classification_history or []
|
|
||||||
self.transcript_chunks = transcript_chunks or []
|
|
||||||
self.services = services or []
|
|
||||||
|
|
||||||
|
|
||||||
class CompanyStats:
|
|
||||||
"""Aggregated stats for a specific company/phone number."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.total_calls = 0
|
|
||||||
self.successful_calls = 0
|
|
||||||
self.failed_calls = 0
|
|
||||||
self.total_hold_time = 0
|
|
||||||
self.hold_times: list[int] = []
|
|
||||||
self.total_duration = 0
|
|
||||||
self.last_called: Optional[datetime] = None
|
|
||||||
self.intents: dict[str, int] = defaultdict(int)
|
|
||||||
|
|
||||||
def update(self, record: CallRecord) -> None:
|
|
||||||
"""Update stats with a new call record."""
|
|
||||||
self.total_calls += 1
|
|
||||||
self.total_duration += record.duration_seconds
|
|
||||||
self.last_called = record.started_at
|
|
||||||
|
|
||||||
if record.status in (CallStatus.COMPLETED, CallStatus.BRIDGED, CallStatus.HUMAN_DETECTED):
|
|
||||||
self.successful_calls += 1
|
|
||||||
elif record.status == CallStatus.FAILED:
|
|
||||||
self.failed_calls += 1
|
|
||||||
|
|
||||||
if record.hold_time_seconds > 0:
|
|
||||||
self.total_hold_time += record.hold_time_seconds
|
|
||||||
self.hold_times.append(record.hold_time_seconds)
|
|
||||||
|
|
||||||
if record.intent:
|
|
||||||
self.intents[record.intent] += 1
|
|
||||||
|
|
||||||
def to_dict(self, number: str) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"number": number,
|
|
||||||
"total_calls": self.total_calls,
|
|
||||||
"successful_calls": self.successful_calls,
|
|
||||||
"failed_calls": self.failed_calls,
|
|
||||||
"success_rate": round(
|
|
||||||
self.successful_calls / self.total_calls, 3
|
|
||||||
) if self.total_calls else 0.0,
|
|
||||||
"avg_hold_time": round(
|
|
||||||
self.total_hold_time / len(self.hold_times), 1
|
|
||||||
) if self.hold_times else 0.0,
|
|
||||||
"max_hold_time": max(self.hold_times) if self.hold_times else 0,
|
|
||||||
"avg_duration": round(
|
|
||||||
self.total_duration / self.total_calls, 1
|
|
||||||
) if self.total_calls else 0.0,
|
|
||||||
"last_called": self.last_called.isoformat() if self.last_called else None,
|
|
||||||
"top_intents": dict(
|
|
||||||
sorted(self.intents.items(), key=lambda x: x[1], reverse=True)[:5]
|
|
||||||
),
|
|
||||||
}
|
|
||||||
@@ -1,70 +1,368 @@
|
|||||||
"""
|
"""
|
||||||
Call Persistence — Writes completed calls and their transcript chunks
|
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.
|
||||||
"""
|
"""
|
||||||
|
await _with_retry(_insert_in_progress_record, call)
|
||||||
|
|
||||||
|
|
||||||
|
async def persist_call_on_end(call: ActiveCall, final_status: CallStatus) -> None:
|
||||||
|
"""Finalize the CallRecord and write transcript chunks for `call`.
|
||||||
|
|
||||||
|
Wired into CallManager as its on_call_ended hook by the
|
||||||
|
composition root in main.py.
|
||||||
|
"""
|
||||||
|
await _with_retry(_finalize_call_record, call, final_status)
|
||||||
|
|
||||||
|
|
||||||
|
async def _with_retry(write, call: ActiveCall, *args) -> None:
|
||||||
|
"""Losing the row means the call never happened as far as history
|
||||||
|
is concerned, so the final failure logs at ERROR with identifiers."""
|
||||||
|
for attempt in range(3):
|
||||||
try:
|
try:
|
||||||
async with get_session_factory()() as session:
|
await write(call, *args)
|
||||||
record = CallRecord(
|
return
|
||||||
|
except Exception as e:
|
||||||
|
if attempt == 2:
|
||||||
|
logger.error(
|
||||||
|
f"Call record lost ({write.__name__}): id={call.id} "
|
||||||
|
f"number={call.remote_number}: {e}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
await asyncio.sleep(2**attempt)
|
||||||
|
|
||||||
|
|
||||||
|
async def _insert_in_progress_record(call: ActiveCall) -> None:
|
||||||
|
async with session_scope() as session:
|
||||||
|
session.add(CallRecord(
|
||||||
id=call.id,
|
id=call.id,
|
||||||
direction=call.direction,
|
direction=call.direction,
|
||||||
remote_number=call.remote_number,
|
remote_number=call.remote_number,
|
||||||
status=final_status.value,
|
status="in_progress",
|
||||||
mode=call.mode.value,
|
mode=call.mode.value,
|
||||||
intent=call.intent,
|
intent=call.intent,
|
||||||
started_at=call.started_at,
|
started_at=call.started_at,
|
||||||
ended_at=datetime.now(),
|
|
||||||
duration=int(call.duration),
|
|
||||||
hold_time=int(call.hold_time),
|
|
||||||
device_used=call.device,
|
device_used=call.device,
|
||||||
call_flow_id=call.call_flow_id,
|
call_flow_id=call.call_flow_id,
|
||||||
classification_timeline=[
|
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)
|
||||||
|
|
||||||
|
record.direction = call.direction
|
||||||
|
record.remote_number = call.remote_number
|
||||||
|
record.status = final_status.value
|
||||||
|
record.mode = call.mode.value
|
||||||
|
record.intent = call.intent
|
||||||
|
record.started_at = call.started_at
|
||||||
|
record.ended_at = datetime.now()
|
||||||
|
record.duration = int(call.duration)
|
||||||
|
record.hold_time = int(call.hold_time)
|
||||||
|
record.device_used = call.device
|
||||||
|
record.call_flow_id = call.call_flow_id
|
||||||
|
record.classification_timeline = [
|
||||||
{
|
{
|
||||||
"timestamp": c.timestamp,
|
"timestamp": c.timestamp,
|
||||||
"audio_type": c.audio_type.value,
|
"audio_type": c.audio_type.value,
|
||||||
"confidence": c.confidence,
|
"confidence": c.confidence,
|
||||||
}
|
}
|
||||||
for c in call.classification_history
|
for c in call.classification_history
|
||||||
],
|
]
|
||||||
metadata_={"services": list(call.services)},
|
metadata = {"services": list(call.services)}
|
||||||
)
|
if call.exploration_steps:
|
||||||
session.add(record)
|
metadata["exploration_steps"] = call.exploration_steps
|
||||||
|
record.metadata_ = metadata
|
||||||
|
|
||||||
# Each transcript chunk gets its own row with a sequence number
|
# Each transcript entry gets its own row with a sequence number
|
||||||
# so the dashboard can render them in order with click-to-seek.
|
# and real offset so the dashboard can render click-to-seek.
|
||||||
for seq, text in enumerate(call.transcript_chunks):
|
for seq, entry in enumerate(call.transcript_chunks):
|
||||||
speaker = "unknown"
|
|
||||||
payload = text
|
|
||||||
if ":" in text:
|
|
||||||
head, rest = text.split(":", 1)
|
|
||||||
head = head.strip().lower()
|
|
||||||
if head in {"caller", "agent", "receptionist", "caller_message"}:
|
|
||||||
speaker = head if head != "caller_message" else "caller"
|
|
||||||
payload = rest.strip()
|
|
||||||
session.add(TranscriptChunk(
|
session.add(TranscriptChunk(
|
||||||
id=f"tc_{uuid.uuid4().hex[:10]}",
|
id=f"tc_{uuid.uuid4().hex[:10]}",
|
||||||
call_id=call.id,
|
call_id=call.id,
|
||||||
seq=seq,
|
seq=seq,
|
||||||
t_offset_ms=0,
|
t_offset_ms=entry.t_offset_ms,
|
||||||
speaker=speaker,
|
speaker=entry.speaker,
|
||||||
text=payload,
|
text=entry.text,
|
||||||
))
|
))
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Could not persist call {call.id}: {e}")
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
@@ -737,8 +736,12 @@ class HoldSlayerService:
|
|||||||
fd, tmp_path = tempfile.mkstemp(suffix=".wav", prefix=f"speak_{call.id}_")
|
fd, tmp_path = tempfile.mkstemp(suffix=".wav", prefix=f"speak_{call.id}_")
|
||||||
os.close(fd)
|
os.close(fd)
|
||||||
|
|
||||||
|
try:
|
||||||
try:
|
try:
|
||||||
ok = await self.tts.synthesize_to_file(text, tmp_path)
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}")
|
|
||||||
|
|||||||
@@ -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,7 +364,13 @@ 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
|
||||||
@@ -264,15 +378,17 @@ class ReceptionistService:
|
|||||||
|
|
||||||
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,
|
||||||
@@ -292,7 +408,7 @@ class ReceptionistService:
|
|||||||
# ----------------------------------------------------------------
|
# ----------------------------------------------------------------
|
||||||
|
|
||||||
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
|
||||||
@@ -302,8 +418,12 @@ 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:
|
try:
|
||||||
ok = await tts.synthesize_to_file(text, tmp_path)
|
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)
|
||||||
|
|||||||
@@ -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,12 +160,18 @@ 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
|
||||||
|
never find the WAV — so failures log at ERROR, not warning.
|
||||||
|
"""
|
||||||
|
import uuid as _uuid
|
||||||
|
|
||||||
|
from db.database import RecordingRecord, session_scope
|
||||||
|
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
|
async with session_scope() as db:
|
||||||
db.add(RecordingRecord(
|
db.add(RecordingRecord(
|
||||||
id=f"rec_{_uuid.uuid4().hex[:10]}",
|
id=f"rec_{_uuid.uuid4().hex[:10]}",
|
||||||
call_id=session.call_id,
|
call_id=session.call_id,
|
||||||
@@ -167,9 +183,15 @@ class RecordingService:
|
|||||||
started_at=session.started_at,
|
started_at=session.started_at,
|
||||||
ended_at=session.stopped_at,
|
ended_at=session.stopped_at,
|
||||||
))
|
))
|
||||||
await db.commit()
|
return
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Recording persistence failed: {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 {
|
||||||
|
|||||||
@@ -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,45 +77,14 @@ class TranscriptionService:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
except Exception:
|
||||||
|
self.available = False
|
||||||
|
raise
|
||||||
|
self.available = True
|
||||||
text = response.text.strip()
|
text = response.text.strip()
|
||||||
logger.debug(f"Transcription: '{text}'")
|
logger.debug(f"Transcription: '{text}'")
|
||||||
return text
|
return text
|
||||||
|
|
||||||
except httpx.HTTPStatusError as e:
|
|
||||||
logger.error(f"Speaches API error: {e.response.status_code} {e.response.text}")
|
|
||||||
return ""
|
|
||||||
except httpx.ConnectError:
|
|
||||||
logger.error(f"Cannot connect to Speaches at {self.settings.url}")
|
|
||||||
return ""
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Transcription failed: {e}")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
async def transcribe_stream(
|
|
||||||
self,
|
|
||||||
audio_data: bytes,
|
|
||||||
language: str = "en",
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Stream transcription — for real-time results.
|
|
||||||
|
|
||||||
Uses Speaches streaming endpoint if available,
|
|
||||||
falls back to chunked transcription.
|
|
||||||
|
|
||||||
Yields:
|
|
||||||
str: Partial transcription chunks
|
|
||||||
"""
|
|
||||||
# For now, do chunked transcription
|
|
||||||
# TODO: Implement WebSocket streaming when Speaches supports it
|
|
||||||
chunk_size = 16000 * 2 * 3 # 3 seconds of 16kHz 16-bit mono
|
|
||||||
|
|
||||||
for i in range(0, len(audio_data), chunk_size):
|
|
||||||
chunk = audio_data[i:i + chunk_size]
|
|
||||||
if len(chunk) > 0:
|
|
||||||
text = await self.transcribe(chunk, language)
|
|
||||||
if text:
|
|
||||||
yield text
|
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
"""Close the HTTP client."""
|
"""Close the HTTP client."""
|
||||||
if self._client and not self._client.is_closed:
|
if self._client and not self._client.is_closed:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
except Exception:
|
||||||
|
self.available = False
|
||||||
|
raise
|
||||||
|
self.available = True
|
||||||
return response.content
|
return response.content
|
||||||
except httpx.HTTPStatusError as e:
|
|
||||||
logger.error(f"Rhema TTS error: {e.response.status_code} {e.response.text}")
|
|
||||||
return b""
|
|
||||||
except httpx.ConnectError:
|
|
||||||
logger.error(f"Cannot connect to Rhema at {self.settings.base_url}")
|
|
||||||
return b""
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"TTS synthesis failed: {e}")
|
|
||||||
return b""
|
|
||||||
|
|
||||||
async def synthesize_to_file(
|
async def synthesize_to_file(
|
||||||
self,
|
self,
|
||||||
|
|||||||
168
tests/test_concurrency.py
Normal file
168
tests/test_concurrency.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"""
|
||||||
|
Thread-ownership and task-hygiene tests.
|
||||||
|
|
||||||
|
Covers the Sippy→loop event funnel (events posted from a foreign
|
||||||
|
thread mutate loop-owned state), the AudioTap thread-safe feed, the
|
||||||
|
off-loop classifier entry point, leg-state propagation into the
|
||||||
|
CallManager, and background-task cancellation on gateway stop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from config import ClassifierSettings, Settings
|
||||||
|
from core.gateway import AIPSTNGateway
|
||||||
|
from core.media_pipeline import AudioTap
|
||||||
|
from core.sippy_engine import SippyEngine
|
||||||
|
from models.call import CallStatus
|
||||||
|
from services.audio_classifier import AudioClassifier
|
||||||
|
|
||||||
|
|
||||||
|
def _engine_on_loop() -> SippyEngine:
|
||||||
|
"""Engine wired to the running loop without starting the SIP stack."""
|
||||||
|
engine = SippyEngine()
|
||||||
|
engine._loop = asyncio.get_running_loop()
|
||||||
|
return engine
|
||||||
|
|
||||||
|
|
||||||
|
def _post_from_thread(engine: SippyEngine, kind: str, data: dict) -> None:
|
||||||
|
"""Post a funnel event from a foreign thread, like the Sippy ED thread."""
|
||||||
|
t = threading.Thread(target=engine._post_from_ed, args=(kind, data))
|
||||||
|
t.start()
|
||||||
|
t.join()
|
||||||
|
|
||||||
|
|
||||||
|
class TestEngineEventFunnel:
|
||||||
|
async def test_register_and_deregister_from_foreign_thread(self):
|
||||||
|
engine = _engine_on_loop()
|
||||||
|
|
||||||
|
_post_from_thread(engine, "register", {
|
||||||
|
"aor": "sip:alice@gw", "contact": "sip:alice@10.0.0.5", "expires": 3600,
|
||||||
|
})
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
assert engine._registered_devices == [
|
||||||
|
{"aor": "sip:alice@gw", "contact": "sip:alice@10.0.0.5", "expires": 3600}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Re-register updates in place instead of duplicating
|
||||||
|
_post_from_thread(engine, "register", {
|
||||||
|
"aor": "sip:alice@gw", "contact": "sip:alice@10.0.0.9", "expires": 60,
|
||||||
|
})
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
assert len(engine._registered_devices) == 1
|
||||||
|
assert engine._registered_devices[0]["contact"] == "sip:alice@10.0.0.9"
|
||||||
|
|
||||||
|
_post_from_thread(engine, "deregister", {"aor": "sip:alice@gw"})
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
assert engine._registered_devices == []
|
||||||
|
|
||||||
|
async def test_incoming_invite_auto_answers_without_callback(self):
|
||||||
|
engine = _engine_on_loop()
|
||||||
|
_post_from_thread(engine, "incoming_invite", {
|
||||||
|
"leg_id": "leg_test1",
|
||||||
|
"from_uri": "sip:caller@pstn",
|
||||||
|
"to_uri": "sip:+15551234567@gw",
|
||||||
|
"sdp": None,
|
||||||
|
})
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
leg = engine._legs["leg_test1"]
|
||||||
|
assert leg.direction == "inbound"
|
||||||
|
assert leg.state == "connected"
|
||||||
|
|
||||||
|
async def test_incoming_call_callback_runs_as_tracked_task(self):
|
||||||
|
engine = _engine_on_loop()
|
||||||
|
seen = asyncio.Event()
|
||||||
|
|
||||||
|
async def on_incoming(from_uri, to_uri, leg_id):
|
||||||
|
seen.set()
|
||||||
|
|
||||||
|
engine._on_incoming_call = on_incoming
|
||||||
|
_post_from_thread(engine, "incoming_invite", {
|
||||||
|
"leg_id": "leg_test2", "from_uri": "a", "to_uri": "b", "sdp": None,
|
||||||
|
})
|
||||||
|
await asyncio.wait_for(seen.wait(), timeout=1.0)
|
||||||
|
assert engine._legs["leg_test2"].state == "init" # not auto-answered
|
||||||
|
|
||||||
|
async def test_bye_terminates_leg_and_notifies(self):
|
||||||
|
engine = _engine_on_loop()
|
||||||
|
states: list[tuple[str, str]] = []
|
||||||
|
engine._on_leg_state_change = lambda leg_id, state: states.append((leg_id, state))
|
||||||
|
|
||||||
|
_post_from_thread(engine, "incoming_invite", {
|
||||||
|
"leg_id": "leg_test3", "from_uri": "a", "to_uri": "b", "sdp": None,
|
||||||
|
})
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
_post_from_thread(engine, "leg_state", {"leg_id": "leg_test3", "state": "terminated"})
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
assert engine._legs["leg_test3"].state == "terminated"
|
||||||
|
assert ("leg_test3", "terminated") in states
|
||||||
|
|
||||||
|
async def test_dtmf_and_trunk_events(self):
|
||||||
|
engine = _engine_on_loop()
|
||||||
|
_post_from_thread(engine, "dtmf", {"leg_id": "leg_test4", "digit": "5"})
|
||||||
|
_post_from_thread(engine, "trunk_registered", {"registered": True})
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
assert engine._trunk_registered is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestAudioTapThreadSafety:
|
||||||
|
async def test_feed_from_foreign_thread_reaches_reader(self):
|
||||||
|
tap = AudioTap("leg_x")
|
||||||
|
frame = b"\x01\x02" * 320
|
||||||
|
|
||||||
|
t = threading.Thread(target=tap.feed, args=(frame,))
|
||||||
|
t.start()
|
||||||
|
t.join()
|
||||||
|
|
||||||
|
received = await tap.read_frame(timeout=1.0)
|
||||||
|
assert received == frame
|
||||||
|
|
||||||
|
|
||||||
|
class TestClassifierOffLoop:
|
||||||
|
async def test_classify_runs_and_records_history(self):
|
||||||
|
classifier = AudioClassifier(ClassifierSettings())
|
||||||
|
silence = np.zeros(16000, dtype=np.int16).tobytes()
|
||||||
|
|
||||||
|
result = await classifier.classify(silence)
|
||||||
|
|
||||||
|
assert result.audio_type.value == "silence"
|
||||||
|
assert classifier._classification_history == [result.audio_type]
|
||||||
|
|
||||||
|
|
||||||
|
class TestLegStatePropagation:
|
||||||
|
async def test_leg_lifecycle_drives_call_status(self):
|
||||||
|
gateway = AIPSTNGateway(settings=Settings(max_concurrent_calls=4))
|
||||||
|
call = await gateway.make_call("+15551234567")
|
||||||
|
assert call.status == CallStatus.RINGING
|
||||||
|
|
||||||
|
(leg_id,) = gateway.call_manager.legs_for_call(call.id)
|
||||||
|
|
||||||
|
await gateway._on_sip_leg_state(leg_id, "connected")
|
||||||
|
assert gateway.get_call(call.id).status == CallStatus.CONNECTED
|
||||||
|
|
||||||
|
await gateway._on_sip_leg_state(leg_id, "terminated")
|
||||||
|
assert gateway.get_call(call.id) is None
|
||||||
|
assert gateway.call_manager.legs_for_call(call.id) == []
|
||||||
|
|
||||||
|
async def test_late_signals_do_not_stomp_service_states(self):
|
||||||
|
gateway = AIPSTNGateway(settings=Settings(max_concurrent_calls=4))
|
||||||
|
call = await gateway.make_call("+15551234567")
|
||||||
|
(leg_id,) = gateway.call_manager.legs_for_call(call.id)
|
||||||
|
|
||||||
|
await gateway.call_manager.update_status(call.id, CallStatus.ON_HOLD)
|
||||||
|
await gateway._on_sip_leg_state(leg_id, "connected")
|
||||||
|
assert gateway.get_call(call.id).status == CallStatus.ON_HOLD
|
||||||
|
|
||||||
|
|
||||||
|
class TestTaskHygiene:
|
||||||
|
async def test_gateway_stop_cancels_spawned_tasks(self):
|
||||||
|
gateway = AIPSTNGateway(settings=Settings(max_concurrent_calls=4))
|
||||||
|
task = gateway.spawn(asyncio.sleep(60), name="test_sleeper")
|
||||||
|
|
||||||
|
await gateway.stop()
|
||||||
|
|
||||||
|
assert task.cancelled()
|
||||||
|
assert gateway._tasks == set()
|
||||||
187
tests/test_data_layer.py
Normal file
187
tests/test_data_layer.py
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
"""
|
||||||
|
Data-layer tests.
|
||||||
|
|
||||||
|
Alembic migrations produce the schema the ORM models declare (and
|
||||||
|
adopt a pre-Alembic database); a call gets a durable in_progress row
|
||||||
|
the moment it starts; transcript entries carry real offsets; the
|
||||||
|
consolidated response models map from the domain in one place.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import Boolean, inspect, text
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
import db.database as dbmod
|
||||||
|
from core.call_manager import CallManager
|
||||||
|
from core.event_bus import EventBus
|
||||||
|
from db.database import Base
|
||||||
|
from models.call import ActiveCall, CallStatus, CallStatusResponse
|
||||||
|
from models.device import Device, DeviceType
|
||||||
|
from services import call_persistence as store
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# Alembic migrations
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
def _run_alembic(connection, revision: str) -> None:
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
|
|
||||||
|
cfg = Config("alembic.ini")
|
||||||
|
cfg.attributes["connection"] = connection
|
||||||
|
command.upgrade(cfg, revision)
|
||||||
|
|
||||||
|
|
||||||
|
def _schema_info(sync_conn) -> dict:
|
||||||
|
inspector = inspect(sync_conn)
|
||||||
|
return {
|
||||||
|
"tables": set(inspector.get_table_names()) - {"alembic_version"},
|
||||||
|
"call_record_cols": {c["name"] for c in inspector.get_columns("call_records")},
|
||||||
|
"is_online_type": next(
|
||||||
|
c["type"] for c in inspector.get_columns("devices")
|
||||||
|
if c["name"] == "is_online"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestMigrations:
|
||||||
|
async def test_upgrade_head_matches_models(self, tmp_path):
|
||||||
|
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/mig.db")
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(dbmod._upgrade_to_head)
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
info = await conn.run_sync(_schema_info)
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
assert info["tables"] == set(Base.metadata.tables)
|
||||||
|
assert "transcript" not in info["call_record_cols"]
|
||||||
|
assert isinstance(info["is_online_type"], Boolean)
|
||||||
|
|
||||||
|
async def test_adopts_pre_alembic_schema(self, tmp_path):
|
||||||
|
"""A create_all-era database (baseline schema, no alembic_version)
|
||||||
|
is stamped and migrated forward instead of failing."""
|
||||||
|
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/legacy.db")
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(
|
||||||
|
lambda c: _run_alembic(c, dbmod._BASELINE_REVISION)
|
||||||
|
)
|
||||||
|
await conn.execute(text("DROP TABLE alembic_version"))
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(dbmod._upgrade_to_head)
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
info = await conn.run_sync(_schema_info)
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
assert "transcript" not in info["call_record_cols"]
|
||||||
|
assert isinstance(info["is_online_type"], Boolean)
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# Durable call rows
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def mem_db(monkeypatch):
|
||||||
|
engine = create_async_engine(
|
||||||
|
"sqlite+aiosqlite:///:memory:",
|
||||||
|
poolclass=StaticPool,
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
monkeypatch.setattr(dbmod, "_engine", engine)
|
||||||
|
monkeypatch.setattr(dbmod, "_session_factory", factory)
|
||||||
|
yield factory
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDurableCallRows:
|
||||||
|
async def test_in_progress_row_from_the_start(self, mem_db):
|
||||||
|
cm = CallManager(
|
||||||
|
EventBus(),
|
||||||
|
on_call_created=store.persist_call_on_create,
|
||||||
|
on_call_ended=store.persist_call_on_end,
|
||||||
|
)
|
||||||
|
call = await cm.create_call("+15551230000", intent="dispute a charge")
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
row = await store.get_record(session, call.id)
|
||||||
|
assert row is not None
|
||||||
|
assert row.status == "in_progress"
|
||||||
|
assert row.ended_at is None
|
||||||
|
assert row.intent == "dispute a charge"
|
||||||
|
|
||||||
|
await cm.add_transcript(call.id, "hello, billing please", speaker="caller")
|
||||||
|
await cm.end_call(call.id, CallStatus.COMPLETED)
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
row = await store.get_record(session, call.id)
|
||||||
|
chunks = await store.get_transcript_chunks(session, call.id)
|
||||||
|
assert row.status == "completed"
|
||||||
|
assert row.ended_at is not None
|
||||||
|
assert [(c.seq, c.speaker, c.text) for c in chunks] == [
|
||||||
|
(0, "caller", "hello, billing please")
|
||||||
|
]
|
||||||
|
|
||||||
|
async def test_end_without_create_still_writes_row(self, mem_db):
|
||||||
|
"""If the create-time insert never happened, finalize inserts."""
|
||||||
|
cm = CallManager(EventBus(), on_call_ended=store.persist_call_on_end)
|
||||||
|
call = await cm.create_call("+15551230001")
|
||||||
|
await cm.end_call(call.id, CallStatus.FAILED)
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
row = await store.get_record(session, call.id)
|
||||||
|
assert row is not None
|
||||||
|
assert row.status == "failed"
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# Transcript offsets
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
class TestTranscriptOffsets:
|
||||||
|
async def test_entries_carry_offset_and_speaker(self):
|
||||||
|
cm = CallManager(EventBus())
|
||||||
|
call = await cm.create_call("+15551230002")
|
||||||
|
|
||||||
|
await cm.add_transcript(call.id, "one")
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
await cm.add_transcript(call.id, "two", speaker="agent")
|
||||||
|
|
||||||
|
first, second = call.transcript_chunks
|
||||||
|
assert first.t_offset_ms >= 0
|
||||||
|
assert second.t_offset_ms > first.t_offset_ms
|
||||||
|
assert first.speaker == "unknown"
|
||||||
|
assert second.speaker == "agent"
|
||||||
|
assert call.transcript == "one\ntwo"
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# Consolidated response models
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
class TestResponseModels:
|
||||||
|
def test_status_response_from_call(self):
|
||||||
|
call = ActiveCall(id="call_x", remote_number="+15550000000", intent="pay bill")
|
||||||
|
resp = CallStatusResponse.from_call(call)
|
||||||
|
assert resp.call_id == "call_x"
|
||||||
|
assert resp.status == "initiating"
|
||||||
|
assert resp.remote_number == "+15550000000"
|
||||||
|
assert resp.intent == "pay bill"
|
||||||
|
|
||||||
|
def test_device_serializes_routability(self):
|
||||||
|
device = Device(
|
||||||
|
id="dev_1",
|
||||||
|
name="Desk Phone",
|
||||||
|
type=DeviceType.SIP_PHONE,
|
||||||
|
sip_uri="sip:desk@gw",
|
||||||
|
is_online=True,
|
||||||
|
)
|
||||||
|
assert device.model_dump()["can_receive_call"] is True
|
||||||
|
device.dnd = True
|
||||||
|
assert device.model_dump()["can_receive_call"] is False
|
||||||
89
tests/test_health_policy.py
Normal file
89
tests/test_health_policy.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"""
|
||||||
|
Engine-mode and event-bus-integrity tests.
|
||||||
|
|
||||||
|
The mock engine must be requested explicitly; a full subscriber queue
|
||||||
|
drops its oldest event but never loses the subscription; history is
|
||||||
|
replayable to late joiners.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from config import Settings
|
||||||
|
from core.event_bus import EventBus
|
||||||
|
from core.gateway import build_sip_engine
|
||||||
|
from core.media_pipeline import MediaPipeline
|
||||||
|
from core.sip_engine import MockSIPEngine
|
||||||
|
from models.events import EventType, GatewayEvent
|
||||||
|
|
||||||
|
|
||||||
|
def _noop(*args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TestEngineMode:
|
||||||
|
def _build(self, settings: Settings):
|
||||||
|
return build_sip_engine(
|
||||||
|
settings,
|
||||||
|
MediaPipeline(sample_rate=16000),
|
||||||
|
on_leg_state_change=_noop,
|
||||||
|
on_device_registered=_noop,
|
||||||
|
on_incoming_call=_noop,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_mock_engine_only_when_asked(self):
|
||||||
|
settings = Settings(use_mock_sip=True)
|
||||||
|
assert isinstance(self._build(settings), MockSIPEngine)
|
||||||
|
|
||||||
|
def test_unconfigured_trunk_refuses_to_build(self):
|
||||||
|
settings = Settings(use_mock_sip=False)
|
||||||
|
settings.sip_trunk.host = "sip.yourprovider.com"
|
||||||
|
with pytest.raises(RuntimeError, match="not configured"):
|
||||||
|
self._build(settings)
|
||||||
|
|
||||||
|
|
||||||
|
def _event(i: int) -> GatewayEvent:
|
||||||
|
return GatewayEvent(
|
||||||
|
type=EventType.CALL_INITIATED,
|
||||||
|
call_id=f"call_{i}",
|
||||||
|
data={},
|
||||||
|
message=f"event {i}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEventBusIntegrity:
|
||||||
|
async def test_overflow_drops_oldest_keeps_subscription(self):
|
||||||
|
bus = EventBus()
|
||||||
|
sub = bus.subscribe(max_size=3)
|
||||||
|
|
||||||
|
for i in range(5):
|
||||||
|
await bus.publish(_event(i))
|
||||||
|
|
||||||
|
assert bus.subscriber_count == 1 # never evicted
|
||||||
|
assert sub.dropped == 2
|
||||||
|
received = [await asyncio.wait_for(sub.get(), 1.0) for _ in range(3)]
|
||||||
|
assert [e.call_id for e in received] == ["call_2", "call_3", "call_4"]
|
||||||
|
|
||||||
|
async def test_replay_last_seeds_history(self):
|
||||||
|
bus = EventBus()
|
||||||
|
for i in range(10):
|
||||||
|
await bus.publish(_event(i))
|
||||||
|
|
||||||
|
sub = bus.subscribe(replay_last=3)
|
||||||
|
received = [await asyncio.wait_for(sub.get(), 1.0) for _ in range(3)]
|
||||||
|
assert [e.call_id for e in received] == ["call_7", "call_8", "call_9"]
|
||||||
|
|
||||||
|
async def test_replay_respects_type_filter(self):
|
||||||
|
bus = EventBus()
|
||||||
|
await bus.publish(_event(1))
|
||||||
|
await bus.publish(GatewayEvent(
|
||||||
|
type=EventType.HUMAN_DETECTED, call_id="call_h", data={}, message="x"
|
||||||
|
))
|
||||||
|
|
||||||
|
sub = bus.subscribe(
|
||||||
|
event_types={EventType.HUMAN_DETECTED}, replay_last=5
|
||||||
|
)
|
||||||
|
event = await asyncio.wait_for(sub.get(), 1.0)
|
||||||
|
assert event.call_id == "call_h"
|
||||||
|
assert sub._queue.empty()
|
||||||
137
tests/test_learner.py
Normal file
137
tests/test_learner.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
"""
|
||||||
|
Call-flow learner tests.
|
||||||
|
|
||||||
|
Exploration discoveries become a linked CallFlow, survive with the
|
||||||
|
persisted call record, and the learn_call_flow MCP tool turns them
|
||||||
|
into a stored flow (refining on subsequent calls).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastmcp import Client
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
import db.database as dbmod
|
||||||
|
import services.llm_client as llm_mod
|
||||||
|
from core.call_manager import CallManager
|
||||||
|
from core.event_bus import EventBus
|
||||||
|
from db.database import Base
|
||||||
|
from mcp_server.server import create_mcp_server
|
||||||
|
from models.call import CallStatus
|
||||||
|
from models.call_flow import ActionType
|
||||||
|
from services import call_persistence as store
|
||||||
|
from services.call_flow_learner import CallFlowLearner
|
||||||
|
|
||||||
|
DISCOVERIES = [
|
||||||
|
{"timestamp": 1.0, "audio_type": "ringing", "confidence": 0.9,
|
||||||
|
"transcript": "", "action_taken": None},
|
||||||
|
{"timestamp": 4.0, "audio_type": "ivr_prompt", "confidence": 0.8,
|
||||||
|
"transcript": "press 1 for english press 2 for french",
|
||||||
|
"action_taken": {"dtmf": "1"}},
|
||||||
|
{"timestamp": 8.0, "audio_type": "ivr_prompt", "confidence": 0.8,
|
||||||
|
"transcript": "press 1 for billing press 2 for support press 0 for an agent",
|
||||||
|
"action_taken": {"dtmf": "0"}},
|
||||||
|
{"timestamp": 12.0, "audio_type": "music", "confidence": 0.9,
|
||||||
|
"transcript": "", "action_taken": None},
|
||||||
|
{"timestamp": 200.0, "audio_type": "live_human", "confidence": 0.85,
|
||||||
|
"transcript": "thank you for holding, how can I help",
|
||||||
|
"action_taken": None},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildFlow:
|
||||||
|
async def test_discoveries_become_linked_steps(self):
|
||||||
|
learner = CallFlowLearner(llm_client=None)
|
||||||
|
flow = await learner.build_flow(
|
||||||
|
phone_number="+18005551234",
|
||||||
|
discovered_steps=DISCOVERIES,
|
||||||
|
intent="dispute a charge",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ringing is skipped; menus/hold/human map to actions in order
|
||||||
|
assert [s.action for s in flow.steps] == [
|
||||||
|
ActionType.DTMF, ActionType.DTMF, ActionType.HOLD, ActionType.TRANSFER,
|
||||||
|
]
|
||||||
|
assert [s.action_value for s in flow.steps[:2]] == ["1", "0"]
|
||||||
|
assert [s.next_step for s in flow.steps[:-1]] == [s.id for s in flow.steps[1:]]
|
||||||
|
assert "auto-learned" in flow.tags
|
||||||
|
assert flow.phone_number == "+18005551234"
|
||||||
|
|
||||||
|
|
||||||
|
class TestExplorationPersistence:
|
||||||
|
async def test_exploration_steps_survive_with_the_record(self, mem_db):
|
||||||
|
cm = CallManager(EventBus(), on_call_ended=store.persist_call_on_end)
|
||||||
|
call = await cm.create_call("+18005551234", intent="dispute a charge")
|
||||||
|
call.exploration_steps.extend(DISCOVERIES)
|
||||||
|
await cm.end_call(call.id, CallStatus.COMPLETED)
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
record = await store.get_record(session, call.id)
|
||||||
|
assert record.metadata_["exploration_steps"] == DISCOVERIES
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def mem_db(monkeypatch):
|
||||||
|
engine = create_async_engine(
|
||||||
|
"sqlite+aiosqlite:///:memory:",
|
||||||
|
poolclass=StaticPool,
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
monkeypatch.setattr(dbmod, "_engine", engine)
|
||||||
|
monkeypatch.setattr(dbmod, "_session_factory", factory)
|
||||||
|
yield factory
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def no_llm(monkeypatch):
|
||||||
|
"""learn_call_flow must work without an LLM (labels stay heuristic)."""
|
||||||
|
monkeypatch.setattr(llm_mod, "_shared_client", None)
|
||||||
|
monkeypatch.setattr(llm_mod, "_shared_failed", True)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLearnCallFlowTool:
|
||||||
|
async def _completed_exploration_call(self, number: str) -> str:
|
||||||
|
cm = CallManager(EventBus(), on_call_ended=store.persist_call_on_end)
|
||||||
|
call = await cm.create_call(number, intent="dispute a charge")
|
||||||
|
call.exploration_steps.extend(DISCOVERIES)
|
||||||
|
await cm.end_call(call.id, CallStatus.COMPLETED)
|
||||||
|
return call.id
|
||||||
|
|
||||||
|
async def test_learns_then_refines(self, mem_db, no_llm):
|
||||||
|
call_id = await self._completed_exploration_call("+18005551234")
|
||||||
|
|
||||||
|
mcp = create_mcp_server(lambda: None)
|
||||||
|
async with Client(mcp) as client:
|
||||||
|
result = await client.call_tool("learn_call_flow", {"call_id": call_id})
|
||||||
|
assert "Learned new flow" in result.content[0].text
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
row = await store.get_flow_by_number(session, "+18005551234")
|
||||||
|
assert row is not None
|
||||||
|
assert len(row.steps) == 4
|
||||||
|
assert "auto-learned" in row.tags
|
||||||
|
|
||||||
|
result = await client.call_tool("learn_call_flow", {"call_id": call_id})
|
||||||
|
assert "Refined existing flow" in result.content[0].text
|
||||||
|
|
||||||
|
async def test_call_without_exploration_data(self, mem_db, no_llm):
|
||||||
|
cm = CallManager(EventBus(), on_call_ended=store.persist_call_on_end)
|
||||||
|
call = await cm.create_call("+15550001111")
|
||||||
|
await cm.end_call(call.id, CallStatus.COMPLETED)
|
||||||
|
|
||||||
|
mcp = create_mcp_server(lambda: None)
|
||||||
|
async with Client(mcp) as client:
|
||||||
|
result = await client.call_tool("learn_call_flow", {"call_id": call.id})
|
||||||
|
assert "no exploration data" in result.content[0].text
|
||||||
|
|
||||||
|
async def test_unknown_call(self, mem_db, no_llm):
|
||||||
|
mcp = create_mcp_server(lambda: None)
|
||||||
|
async with Client(mcp) as client:
|
||||||
|
result = await client.call_tool(
|
||||||
|
"learn_call_flow", {"call_id": "call_nope"}
|
||||||
|
)
|
||||||
|
assert "No record found" in result.content[0].text
|
||||||
@@ -25,6 +25,7 @@ EXPECTED_TOOLS = {
|
|||||||
"get_call_recording",
|
"get_call_recording",
|
||||||
"get_call_summary",
|
"get_call_summary",
|
||||||
"search_call_history",
|
"search_call_history",
|
||||||
|
"learn_call_flow",
|
||||||
"list_devices",
|
"list_devices",
|
||||||
"gateway_status",
|
"gateway_status",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
227
tests/test_structure.py
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
"""
|
||||||
|
Composition and API-surface tests.
|
||||||
|
|
||||||
|
Covers the gateway composed the way main.py's lifespan composes it
|
||||||
|
(mode handlers, on_call_ended hook, receptionist-owned inbound
|
||||||
|
policy) and the REST routes running against a real (SQLite) database
|
||||||
|
through the shared data layer in services/call_persistence.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from pydantic import SecretStr
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
import main
|
||||||
|
from config import ReceptionistSettings, Settings, get_settings
|
||||||
|
from core.gateway import AIPSTNGateway
|
||||||
|
from db.database import Base, CallRecord, get_db
|
||||||
|
from models.call import CallMode, CallStatus
|
||||||
|
from models.routing import RoutingAction, RoutingActionType, RoutingDecision
|
||||||
|
from services.receptionist import ReceptionistService
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# Gateway composition
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
class TestGatewayComposition:
|
||||||
|
async def test_mode_handler_launches_per_call(self):
|
||||||
|
gateway = AIPSTNGateway(settings=Settings(max_concurrent_calls=4))
|
||||||
|
launched: list[tuple] = []
|
||||||
|
gateway.register_mode_handler(
|
||||||
|
CallMode.HOLD_SLAYER,
|
||||||
|
lambda call, leg_id, flow_id: launched.append((call.id, leg_id, flow_id)),
|
||||||
|
)
|
||||||
|
|
||||||
|
call = await gateway.make_call("+15551234567", mode=CallMode.HOLD_SLAYER,
|
||||||
|
call_flow_id="acme-main")
|
||||||
|
|
||||||
|
assert launched == [(call.id, gateway.call_manager.legs_for_call(call.id)[0], "acme-main")]
|
||||||
|
|
||||||
|
async def test_direct_mode_needs_no_handler(self):
|
||||||
|
gateway = AIPSTNGateway(settings=Settings(max_concurrent_calls=4))
|
||||||
|
call = await gateway.make_call("+15551234567")
|
||||||
|
assert call.status == CallStatus.RINGING
|
||||||
|
|
||||||
|
async def test_on_call_ended_hook_from_constructor(self):
|
||||||
|
ended: list[tuple] = []
|
||||||
|
|
||||||
|
async def hook(call, status):
|
||||||
|
ended.append((call.id, status))
|
||||||
|
|
||||||
|
gateway = AIPSTNGateway(
|
||||||
|
settings=Settings(max_concurrent_calls=4), on_call_ended=hook
|
||||||
|
)
|
||||||
|
call = await gateway.make_call("+15551234567")
|
||||||
|
await gateway.hangup_call(call.id)
|
||||||
|
|
||||||
|
assert ended == [(call.id, CallStatus.COMPLETED)]
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# Receptionist-owned inbound policy
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
class _StubRouting:
|
||||||
|
def __init__(self, decision):
|
||||||
|
self._decision = decision
|
||||||
|
|
||||||
|
async def evaluate(self, caller_number, dnis):
|
||||||
|
return self._decision
|
||||||
|
|
||||||
|
|
||||||
|
class TestInboundPolicy:
|
||||||
|
def _gateway(self) -> AIPSTNGateway:
|
||||||
|
settings = Settings(max_concurrent_calls=4)
|
||||||
|
settings.receptionist = ReceptionistSettings(enabled=False)
|
||||||
|
return AIPSTNGateway(settings=settings)
|
||||||
|
|
||||||
|
async def test_inbound_call_answered_and_tracked(self):
|
||||||
|
gateway = self._gateway()
|
||||||
|
receptionist = ReceptionistService(gateway)
|
||||||
|
|
||||||
|
await receptionist.on_inbound_call(
|
||||||
|
"sip:+16135550100@pstn", "sip:+15551234567@gw", "leg_in1"
|
||||||
|
)
|
||||||
|
|
||||||
|
calls = list(gateway.call_manager.active_calls.values())
|
||||||
|
assert len(calls) == 1
|
||||||
|
call = calls[0]
|
||||||
|
assert call.direction == "inbound"
|
||||||
|
assert call.remote_number == "+16135550100"
|
||||||
|
assert call.status == CallStatus.CONNECTED
|
||||||
|
assert gateway.call_manager.legs_for_call(call.id) == ["leg_in1"]
|
||||||
|
|
||||||
|
async def test_reject_rule_declines_before_answer(self):
|
||||||
|
gateway = self._gateway()
|
||||||
|
decision = RoutingDecision(
|
||||||
|
action=RoutingAction(type=RoutingActionType.REJECT),
|
||||||
|
matched_rule_id="rule_x",
|
||||||
|
matched_rule_name="block",
|
||||||
|
reason="matched rule 'block'",
|
||||||
|
)
|
||||||
|
receptionist = ReceptionistService(gateway, routing=_StubRouting(decision))
|
||||||
|
|
||||||
|
await receptionist.on_inbound_call(
|
||||||
|
"sip:+18005550100@pstn", "sip:+15551234567@gw", "leg_in2"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert gateway.call_manager.active_calls == {}
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# REST routes on the shared data layer (real SQLite)
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client(monkeypatch):
|
||||||
|
monkeypatch.setattr(get_settings(), "api_token", SecretStr(""))
|
||||||
|
|
||||||
|
engine = create_async_engine(
|
||||||
|
"sqlite+aiosqlite:///:memory:",
|
||||||
|
poolclass=StaticPool,
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
async def _get_db():
|
||||||
|
async with factory() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
main.app.dependency_overrides[get_db] = _get_db
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
||||||
|
c.db_factory = factory
|
||||||
|
yield c
|
||||||
|
main.app.dependency_overrides.pop(get_db, None)
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
FLOW_PAYLOAD = {
|
||||||
|
"name": "Acme Main Line",
|
||||||
|
"phone_number": "+18005551234",
|
||||||
|
"description": "Main IVR",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"id": "step1",
|
||||||
|
"description": "Press 2 for billing",
|
||||||
|
"action": "dtmf",
|
||||||
|
"action_value": "2",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": ["test"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestCallFlowRoutes:
|
||||||
|
async def test_crud_round_trip(self, client):
|
||||||
|
resp = await client.post("/api/call-flows/", json=FLOW_PAYLOAD)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
flow_id = resp.json()["id"]
|
||||||
|
assert flow_id == "acme-main-line"
|
||||||
|
|
||||||
|
resp = await client.post("/api/call-flows/", json=FLOW_PAYLOAD)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
resp = await client.get("/api/call-flows/")
|
||||||
|
assert [f["id"] for f in resp.json()] == [flow_id]
|
||||||
|
|
||||||
|
resp = await client.get(f"/api/call-flows/{flow_id}")
|
||||||
|
assert resp.json()["steps"][0]["action_value"] == "2"
|
||||||
|
|
||||||
|
resp = await client.get("/api/call-flows/by-number/+18005551234")
|
||||||
|
assert resp.json()["id"] == flow_id
|
||||||
|
|
||||||
|
resp = await client.put(
|
||||||
|
f"/api/call-flows/{flow_id}", json={"notes": "updated"}
|
||||||
|
)
|
||||||
|
assert resp.json()["notes"] == "updated"
|
||||||
|
|
||||||
|
resp = await client.delete(f"/api/call-flows/{flow_id}")
|
||||||
|
assert resp.json()["status"] == "deleted"
|
||||||
|
|
||||||
|
resp = await client.get(f"/api/call-flows/{flow_id}")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestCallHistoryRoutes:
|
||||||
|
async def test_history_and_record(self, client):
|
||||||
|
resp = await client.get("/api/calls/history")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == []
|
||||||
|
|
||||||
|
async with client.db_factory() as session:
|
||||||
|
session.add(CallRecord(
|
||||||
|
id="call_hist1",
|
||||||
|
direction="outbound",
|
||||||
|
remote_number="+18005551234",
|
||||||
|
status="completed",
|
||||||
|
mode="hold_slayer",
|
||||||
|
intent="dispute charge",
|
||||||
|
duration=120,
|
||||||
|
hold_time=90,
|
||||||
|
))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
resp = await client.get("/api/calls/history")
|
||||||
|
assert [r["id"] for r in resp.json()] == ["call_hist1"]
|
||||||
|
|
||||||
|
resp = await client.get("/api/calls/history?number=%2B18005551234")
|
||||||
|
assert len(resp.json()) == 1
|
||||||
|
|
||||||
|
resp = await client.get("/api/calls/call_hist1/record")
|
||||||
|
assert resp.json()["intent"] == "dispute charge"
|
||||||
|
|
||||||
|
resp = await client.get("/api/calls/call_missing/record")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
resp = await client.get("/api/calls/call_hist1/transcript")
|
||||||
|
assert resp.json() == []
|
||||||
67
tests/test_websocket.py
Normal file
67
tests/test_websocket.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""
|
||||||
|
WebSocket event-stream tests.
|
||||||
|
|
||||||
|
The socket is refused (4401) without the bearer token, and an
|
||||||
|
authorized client immediately receives the synthetic trunk-status
|
||||||
|
event followed by the replayed recent history.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import SecretStr
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
|
import main
|
||||||
|
from config import Settings, get_settings
|
||||||
|
from core.gateway import AIPSTNGateway
|
||||||
|
from models.events import EventType, GatewayEvent
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ws_app(monkeypatch):
|
||||||
|
monkeypatch.setattr(get_settings(), "api_token", SecretStr("tok"))
|
||||||
|
gateway = AIPSTNGateway(settings=Settings())
|
||||||
|
main.app.state.gateway = gateway
|
||||||
|
yield gateway
|
||||||
|
del main.app.state.gateway
|
||||||
|
|
||||||
|
|
||||||
|
def _publish(gateway, call_id: str) -> None:
|
||||||
|
asyncio.run(gateway.event_bus.publish(GatewayEvent(
|
||||||
|
type=EventType.CALL_INITIATED,
|
||||||
|
call_id=call_id,
|
||||||
|
data={},
|
||||||
|
message=f"call {call_id}",
|
||||||
|
)))
|
||||||
|
|
||||||
|
|
||||||
|
class TestEventStream:
|
||||||
|
def test_refused_without_token(self, ws_app):
|
||||||
|
client = TestClient(main.app)
|
||||||
|
with pytest.raises(WebSocketDisconnect) as exc:
|
||||||
|
with client.websocket_connect("/ws/events"):
|
||||||
|
pass
|
||||||
|
assert exc.value.code == 4401
|
||||||
|
|
||||||
|
def test_trunk_status_then_replayed_history(self, ws_app):
|
||||||
|
_publish(ws_app, "call_ws1")
|
||||||
|
_publish(ws_app, "call_ws2")
|
||||||
|
|
||||||
|
client = TestClient(main.app)
|
||||||
|
with client.websocket_connect("/ws/events?token=tok") as ws:
|
||||||
|
first = ws.receive_json()
|
||||||
|
assert first["type"] == EventType.SIP_TRUNK_REGISTRATION_FAILED.value
|
||||||
|
replayed = [ws.receive_json() for _ in range(2)]
|
||||||
|
assert [m["call_id"] for m in replayed] == ["call_ws1", "call_ws2"]
|
||||||
|
|
||||||
|
def test_per_call_stream_filters(self, ws_app):
|
||||||
|
client = TestClient(main.app)
|
||||||
|
with client.websocket_connect(
|
||||||
|
"/ws/calls/call_target/events?token=tok"
|
||||||
|
) as ws:
|
||||||
|
_publish(ws_app, "call_other")
|
||||||
|
_publish(ws_app, "call_target")
|
||||||
|
msg = ws.receive_json()
|
||||||
|
assert msg["call_id"] == "call_target"
|
||||||
Reference in New Issue
Block a user