feat: add call history API endpoints and TTS service client

Adds read-only access to persisted call records for the dashboard
and implements a client for the Rhema text-to-speech service.

- api/call_history.py: New router providing paged call lists
  and detailed call records with transcript metadata.
- services/tts.py: Async client for OpenAI-compatible TTS
  endpoints (Rhema/Kokoro) used for call-flow steps.
This commit is contained in:
2026-05-22 06:28:33 -04:00
parent dbdb03beb9
commit 63f1a270bb
28 changed files with 2275 additions and 11 deletions

View File

@@ -1,4 +1,13 @@
import type { CallSummary, DeviceStatus, GatewayEvent, GatewayStatus, HealthStatus } from './types';
import type {
CallHistoryRow,
CallSummary,
DeviceStatus,
GatewayEvent,
GatewayStatus,
HealthStatus,
RoutingRule,
TranscriptRow,
} from './types';
async function get<T>(path: string): Promise<T> {
const res = await fetch(path);
@@ -27,6 +36,67 @@ export async function hangupCall(callId: string): Promise<void> {
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
}
export async function fetchCallHistory(
limit = 50,
offset = 0,
): Promise<CallHistoryRow[]> {
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
return get<CallHistoryRow[]>(`/api/calls/history?${params}`);
}
export async function fetchCallRecord(callId: string): Promise<CallHistoryRow> {
return get<CallHistoryRow>(`/api/calls/${callId}/record`);
}
export async function fetchTranscript(callId: string): Promise<TranscriptRow[]> {
return get<TranscriptRow[]>(`/api/calls/${callId}/transcript`);
}
export function recordingUrl(callId: string): string {
return `/api/calls/${callId}/recording`;
}
export async function fetchRoutingRules(): Promise<RoutingRule[]> {
return get<RoutingRule[]>('/api/routing/rules');
}
export async function createRoutingRule(rule: Partial<RoutingRule>): Promise<RoutingRule> {
const res = await fetch('/api/routing/rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rule),
});
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json() as Promise<RoutingRule>;
}
export async function updateRoutingRule(
ruleId: string,
patch: Partial<RoutingRule>,
): Promise<RoutingRule> {
const res = await fetch(`/api/routing/rules/${ruleId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json() as Promise<RoutingRule>;
}
export async function deleteRoutingRule(ruleId: string): Promise<void> {
const res = await fetch(`/api/routing/rules/${ruleId}`, { method: 'DELETE' });
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
}
export async function setDeviceDnd(deviceId: string, enabled: boolean): Promise<void> {
const res = await fetch(`/api/routing/devices/${deviceId}/dnd`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
});
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
}
export function connectEventStream(
onEvent: (e: GatewayEvent) => void,
onClose: () => void,

View File

@@ -72,3 +72,65 @@ export interface GatewayEvent {
data: Record<string, unknown>;
message: string;
}
export interface CallHistoryRow {
id: string;
direction: string;
remote_number: string;
status: string;
mode: string;
intent?: string;
started_at?: string;
ended_at?: string;
duration: number;
hold_time: number;
device_used?: string;
summary?: string;
}
export interface TranscriptRow {
seq: number;
t_offset_ms: number;
speaker: string;
text: string;
confidence?: number;
}
export type RoutingActionType =
| 'ring_device'
| 'ring_chain'
| 'take_message'
| 'reject'
| 'dnd';
export interface TimeRange {
start: string;
end: string;
tz: string;
days: number[];
}
export interface RoutingMatch {
caller_pattern?: string | null;
dnis?: string | null;
time_range?: TimeRange | null;
}
export interface RoutingAction {
type: RoutingActionType;
device_id?: string | null;
device_ids: string[];
ring_timeout: number;
message?: string | null;
}
export interface RoutingRule {
id: string;
name: string;
priority: number;
enabled: boolean;
match: RoutingMatch;
action: RoutingAction;
created_at?: string;
updated_at?: string;
}

View File

@@ -42,7 +42,11 @@
return () => mq.removeEventListener('change', onSystemChange);
});
const nav = [{ href: '/', label: 'Dashboard' }];
const nav = [
{ href: '/', label: 'Dashboard' },
{ href: '/history', label: 'History' },
{ href: '/routing', label: 'Routing' },
];
</script>
<div class="min-h-screen bg-slate-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100">

View File

@@ -0,0 +1,80 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { fetchCallRecord, fetchTranscript, recordingUrl } from '$lib/api';
import type { CallHistoryRow, TranscriptRow } from '$lib/types';
let record = $state<CallHistoryRow | null>(null);
let transcript = $state<TranscriptRow[]>([]);
let audioEl: HTMLAudioElement | null = null;
let loading = $state(true);
let error = $state<string | null>(null);
const callId = $derived($page.params.call_id);
onMount(async () => {
try {
record = await fetchCallRecord(callId);
transcript = await fetchTranscript(callId);
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
});
function seekTo(ms: number) {
if (audioEl) {
audioEl.currentTime = ms / 1000;
audioEl.play();
}
}
</script>
<a href="/history" class="text-sm text-orange-600 hover:underline">← Back to history</a>
{#if loading}
<div class="text-sm text-gray-500 mt-4">Loading…</div>
{:else if error}
<div class="text-sm text-red-500 mt-4">Failed: {error}</div>
{:else if record}
<header class="mt-4 mb-4">
<h1 class="text-xl font-semibold font-mono">{record.remote_number}</h1>
<div class="text-sm text-gray-500">
{record.direction} · {record.mode} · {record.status} ·
{record.duration}s
{#if record.intent}<span> · {record.intent}</span>{/if}
</div>
</header>
<section class="mb-6">
<audio
bind:this={audioEl}
controls
preload="metadata"
class="w-full"
src={recordingUrl(callId)}
></audio>
</section>
<section>
<h2 class="text-sm uppercase text-gray-500 mb-2">Transcript</h2>
{#if transcript.length === 0}
<div class="text-sm text-gray-500">No transcript stored.</div>
{:else}
<ul class="space-y-1">
{#each transcript as chunk}
<li>
<button
class="text-left w-full px-2 py-1.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800"
onclick={() => seekTo(chunk.t_offset_ms)}
>
<span class="text-xs uppercase text-gray-500 mr-2">{chunk.speaker}</span>
<span>{chunk.text}</span>
</button>
</li>
{/each}
</ul>
{/if}
</section>
{/if}

View File

@@ -0,0 +1,76 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fetchCallHistory } from '$lib/api';
import type { CallHistoryRow } from '$lib/types';
let rows = $state<CallHistoryRow[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
function fmtDuration(s: number): string {
const m = Math.floor(s / 60);
const sec = s % 60;
return `${m}:${String(sec).padStart(2, '0')}`;
}
function fmtDate(iso?: string): string {
if (!iso) return '—';
const d = new Date(iso);
return d.toLocaleString();
}
onMount(async () => {
try {
rows = await fetchCallHistory(100, 0);
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
});
</script>
<h1 class="text-xl font-semibold mb-4">Call History</h1>
{#if loading}
<div class="text-sm text-gray-500">Loading…</div>
{:else if error}
<div class="text-sm text-red-500">Failed to load history: {error}</div>
{:else if rows.length === 0}
<div class="text-sm text-gray-500">No calls yet.</div>
{:else}
<div class="overflow-x-auto rounded border border-gray-200 dark:border-gray-800">
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-900 text-left text-xs uppercase text-gray-500">
<tr>
<th class="px-3 py-2">When</th>
<th class="px-3 py-2">Number</th>
<th class="px-3 py-2">Dir</th>
<th class="px-3 py-2">Mode</th>
<th class="px-3 py-2">Status</th>
<th class="px-3 py-2">Duration</th>
<th class="px-3 py-2">Hold</th>
<th class="px-3 py-2">Intent</th>
</tr>
</thead>
<tbody>
{#each rows as row}
<tr class="border-t border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-900">
<td class="px-3 py-2">
<a class="text-orange-600 hover:underline" href={`/calls/${row.id}`}>
{fmtDate(row.started_at)}
</a>
</td>
<td class="px-3 py-2 font-mono">{row.remote_number}</td>
<td class="px-3 py-2">{row.direction}</td>
<td class="px-3 py-2">{row.mode}</td>
<td class="px-3 py-2">{row.status}</td>
<td class="px-3 py-2">{fmtDuration(row.duration)}</td>
<td class="px-3 py-2">{fmtDuration(row.hold_time)}</td>
<td class="px-3 py-2 max-w-xs truncate">{row.intent ?? ''}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}

View File

@@ -0,0 +1,219 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
createRoutingRule,
deleteRoutingRule,
fetchDevices,
fetchRoutingRules,
setDeviceDnd,
updateRoutingRule,
} from '$lib/api';
import type { DeviceStatus, RoutingAction, RoutingActionType, RoutingRule } from '$lib/types';
let rules = $state<RoutingRule[]>([]);
let devices = $state<DeviceStatus[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let draft = $state({
name: '',
priority: 100,
caller_pattern: '',
dnis: '',
action_type: 'ring_chain' as RoutingActionType,
device_id: '',
ring_timeout: 25,
message: '',
});
const actionTypes: RoutingActionType[] = [
'ring_device',
'ring_chain',
'take_message',
'reject',
'dnd',
];
async function refresh() {
try {
[rules, devices] = await Promise.all([fetchRoutingRules(), fetchDevices()]);
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
onMount(refresh);
async function addRule() {
if (!draft.name.trim()) return;
const action: RoutingAction = {
type: draft.action_type,
device_id: draft.action_type === 'ring_device' ? draft.device_id || null : null,
device_ids: [],
ring_timeout: draft.ring_timeout,
message: draft.message || null,
};
try {
await createRoutingRule({
name: draft.name,
priority: draft.priority,
enabled: true,
match: {
caller_pattern: draft.caller_pattern || null,
dnis: draft.dnis || null,
time_range: null,
},
action,
});
draft = {
name: '',
priority: 100,
caller_pattern: '',
dnis: '',
action_type: 'ring_chain',
device_id: '',
ring_timeout: 25,
message: '',
};
await refresh();
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
}
async function toggleEnabled(rule: RoutingRule) {
await updateRoutingRule(rule.id, { enabled: !rule.enabled });
await refresh();
}
async function removeRule(rule: RoutingRule) {
if (!confirm(`Delete rule "${rule.name}"?`)) return;
await deleteRoutingRule(rule.id);
await refresh();
}
async function toggleDnd(device: DeviceStatus, enabled: boolean) {
await setDeviceDnd(device.id, enabled);
await refresh();
}
</script>
<h1 class="text-xl font-semibold mb-4">Routing Rules</h1>
{#if error}
<div class="text-sm text-red-500 mb-4">{error}</div>
{/if}
<section class="mb-8">
<h2 class="text-sm uppercase text-gray-500 mb-2">Devices · DND</h2>
{#if devices.length === 0}
<div class="text-sm text-gray-500">No devices registered.</div>
{:else}
<ul class="space-y-1">
{#each devices as d}
<li class="flex items-center gap-3 text-sm">
<span class="font-mono">{d.name}</span>
<span class="text-gray-500">({d.type})</span>
<label class="ml-auto flex items-center gap-2">
<input
type="checkbox"
onchange={(e) => toggleDnd(d, (e.target as HTMLInputElement).checked)}
/>
<span>DND</span>
</label>
</li>
{/each}
</ul>
{/if}
</section>
<section class="mb-8">
<h2 class="text-sm uppercase text-gray-500 mb-2">Add Rule</h2>
<div class="grid grid-cols-2 gap-2 max-w-2xl">
<input class="px-2 py-1 border rounded" placeholder="Name" bind:value={draft.name} />
<input
class="px-2 py-1 border rounded"
type="number"
placeholder="Priority"
bind:value={draft.priority}
/>
<input
class="px-2 py-1 border rounded"
placeholder="Caller pattern (e.g. +1800*)"
bind:value={draft.caller_pattern}
/>
<input class="px-2 py-1 border rounded" placeholder="DNIS" bind:value={draft.dnis} />
<select class="px-2 py-1 border rounded" bind:value={draft.action_type}>
{#each actionTypes as t}
<option value={t}>{t}</option>
{/each}
</select>
{#if draft.action_type === 'ring_device'}
<select class="px-2 py-1 border rounded" bind:value={draft.device_id}>
<option value="">— device —</option>
{#each devices as d}
<option value={d.id}>{d.name}</option>
{/each}
</select>
{:else}
<input
class="px-2 py-1 border rounded"
type="number"
placeholder="Ring timeout"
bind:value={draft.ring_timeout}
/>
{/if}
<input
class="px-2 py-1 border rounded col-span-2"
placeholder="Optional message (for reject)"
bind:value={draft.message}
/>
</div>
<button class="mt-2 px-3 py-1.5 rounded bg-orange-600 text-white" onclick={addRule}>
Add rule
</button>
</section>
<section>
<h2 class="text-sm uppercase text-gray-500 mb-2">Rules</h2>
{#if loading}
<div class="text-sm text-gray-500">Loading…</div>
{:else if rules.length === 0}
<div class="text-sm text-gray-500">No rules yet.</div>
{:else}
<table class="w-full text-sm">
<thead class="text-left text-xs uppercase text-gray-500">
<tr>
<th class="px-2 py-1">Pri</th>
<th class="px-2 py-1">Name</th>
<th class="px-2 py-1">Caller</th>
<th class="px-2 py-1">DNIS</th>
<th class="px-2 py-1">Action</th>
<th class="px-2 py-1">Enabled</th>
<th class="px-2 py-1"></th>
</tr>
</thead>
<tbody>
{#each rules as rule}
<tr class="border-t border-gray-100 dark:border-gray-800">
<td class="px-2 py-1 font-mono">{rule.priority}</td>
<td class="px-2 py-1">{rule.name}</td>
<td class="px-2 py-1 font-mono">{rule.match.caller_pattern ?? ''}</td>
<td class="px-2 py-1 font-mono">{rule.match.dnis ?? ''}</td>
<td class="px-2 py-1">{rule.action.type}</td>
<td class="px-2 py-1">
<button onclick={() => toggleEnabled(rule)}>
{rule.enabled ? '✓' : '✗'}
</button>
</td>
<td class="px-2 py-1">
<button class="text-red-500" onclick={() => removeRule(rule)}>delete</button>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>