← back to Butlr
scripts/call-quality-report.js
204 lines
#!/usr/bin/env node
// One-shot quality report for any call_id. Cross-checks local DB state vs
// Twilio API vs filesystem and grades each leg green/yellow/red.
//
// Usage (local against prod):
// ssh root@45.61.58.125 'cd /root/public-projects/butlr && node scripts/call-quality-report.js <call_id>'
//
// Or run with --remote <call_id> to ssh into prod for you:
// node scripts/call-quality-report.js --remote n87ResHr
const path = require('path');
const fs = require('fs');
if (process.argv.includes('--remote')) {
const id = process.argv[process.argv.indexOf('--remote') + 1];
if (!/^[A-Za-z0-9_-]{6,16}$/.test(id || '')) { console.error('bad --remote call_id'); process.exit(64); }
const { execSync } = require('child_process');
const out = execSync(
`ssh root@45.61.58.125 'cd /root/public-projects/butlr && node scripts/call-quality-report.js ${id}'`,
{ encoding: 'utf8', stdio: ['inherit', 'pipe', 'inherit'] }
);
process.stdout.write(out);
process.exit(0);
}
const callId = process.argv[2];
if (!callId || !/^[A-Za-z0-9_-]{6,16}$/.test(callId)) {
console.error('usage: node scripts/call-quality-report.js <call_id> | --remote <call_id>');
process.exit(64);
}
// Local prod-side path resolution
try { require('dotenv').config({ path: path.join(__dirname, '..', '.env') }); } catch {}
const data = require('../lib/data');
const transcribe = require('../lib/transcribe');
function pad(s, n) { return String(s).padEnd(n); }
function kbOf(p) { try { return (fs.statSync(p).size / 1024).toFixed(1) + 'KB'; } catch { return null; } }
function chip(level) { return level === 'green' ? '✓' : level === 'yellow' ? '~' : '✗'; }
const RESULTS = [];
function record(check, level, detail) { RESULTS.push({ check, level, detail }); }
async function main() {
// ── 1. DB record ─────────────────────────────────────────────────────
const c = data.getCallUnscoped(callId, true);
if (!c) {
console.error(`✗ call ${callId} not found in calls.json`);
process.exit(1);
}
// Auto-detect call provider — Vapi calls have vapi_call_id, Twilio has twilio_call_sid.
const provider = c.vapi_call_id ? 'vapi' : c.twilio_call_sid ? 'twilio' : 'unknown';
console.log(`\n═══ CALL QUALITY REPORT — ${callId} (${provider.toUpperCase()}) ═══`);
console.log(` business : ${c.business_name}`);
console.log(` phone : ${c.business_phone}`);
console.log(` goal : ${(c.goal || '').slice(0, 100)}${c.goal && c.goal.length > 100 ? '…' : ''}`);
console.log(` created : ${c.created_at} → ${c.updated_at || '(no update)'}`);
console.log(` status : ${c.status}`);
console.log(` user_id : ${c.user_id || '(none)'}`);
if (provider === 'vapi') console.log(` vapi sid : ${c.vapi_call_id}`);
if (provider === 'twilio') console.log(` twilio : ${c.twilio_call_sid}`);
record('db-record', 'green', 'present');
record('user-scope', c.user_id ? 'green' : 'yellow', c.user_id || 'no user_id (legacy)');
record('provider', provider === 'unknown' ? 'red' : 'green', provider);
// ── 2. Provider cross-check (Twilio API or Vapi recording URL) ──────
if (provider === 'twilio') {
const sid = process.env.TWILIO_ACCOUNT_SID;
const tok = process.env.TWILIO_AUTH_TOKEN;
if (sid && tok) {
try {
const r = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${sid}/Calls/${c.twilio_call_sid}.json`, {
headers: { Authorization: 'Basic ' + Buffer.from(`${sid}:${tok}`).toString('base64') },
});
const j = await r.json();
const tStatus = j.status || '(unknown)';
const dur = j.duration || '0';
console.log(` twilio : status=${tStatus} duration=${dur}s price=${j.price || '?'}${j.price_unit || ''}`);
const map = { completed: 'done', busy: 'failed', failed: 'failed', 'no-answer': 'failed', canceled: 'failed' };
const expected = map[tStatus];
if (expected && expected !== c.status) {
record('status-sync', 'yellow', `db=${c.status} twilio=${tStatus} (expected db=${expected})`);
} else {
record('status-sync', 'green', `db=${c.status} ↔ twilio=${tStatus}`);
}
if (Number(dur) > 0) record('duration', 'green', `${dur}s`);
else record('duration', 'red', '0s — call never connected');
} catch (e) {
record('provider-api', 'red', e.message);
}
} else {
record('provider-api', 'yellow', 'no TWILIO_ACCOUNT_SID/AUTH_TOKEN in env');
}
} else if (provider === 'vapi') {
// Vapi pushes status via /vapi/webhook — we trust the DB if updated_at moved.
// Validate the recording URL is fetchable (HEAD).
record('status-sync', c.status === 'done' || c.status === 'failed' ? 'green' : 'yellow', `db=${c.status}`);
} else {
record('provider-api', 'red', 'no provider sid — call never dialed');
}
// ── 3. Recording ─────────────────────────────────────────────────────
if (provider === 'vapi') {
if (c.recording_url) {
try {
const r = await fetch(c.recording_url, { method: 'HEAD' });
if (r.ok) {
const len = r.headers.get('content-length');
const kb = len ? (Number(len) / 1024).toFixed(1) + 'KB' : '?';
const ct = r.headers.get('content-type') || '?';
record('recording', 'green', `${kb} ${ct} @ ${new URL(c.recording_url).hostname}`);
} else {
record('recording', 'red', `HEAD ${r.status} on recording_url`);
}
} catch (e) {
record('recording', 'red', `recording_url fetch failed: ${e.message}`);
}
} else {
record('recording', 'yellow', 'no recording_url yet (end-of-call-report pending?)');
}
} else {
// Twilio path: local MP3 / PCM in data/recordings/
const mp3Path = path.join(transcribe.RECORDINGS_DIR, `${callId}.mp3`);
const pcmPath = path.join(transcribe.RECORDINGS_DIR, `${callId}.pcm`);
const mp3Sz = kbOf(mp3Path);
const pcmSz = kbOf(pcmPath);
if (mp3Sz) {
record('recording', 'green', `${mp3Sz} MP3 archived locally`);
} else if (pcmSz) {
record('recording', 'yellow', `${pcmSz} PCM not yet finalized — run scripts/finalize-recordings.js`);
} else {
record('recording', 'red', 'no audio captured');
}
}
// ── 4. Transcript ────────────────────────────────────────────────────
if (provider === 'vapi') {
if (c.transcript_path && String(c.transcript_path).startsWith('vapi:')) {
record('transcript', 'green', `stored at Vapi (${c.transcript_path})`);
} else {
record('transcript', 'yellow', 'no transcript_path yet');
}
} else {
const trnPath = path.join(transcribe.TRANSCRIPTS_DIR, `${callId}.json`);
const mp3PathLocal = path.join(transcribe.RECORDINGS_DIR, `${callId}.mp3`);
const mp3ExistsLocal = fs.existsSync(mp3PathLocal);
if (fs.existsSync(trnPath)) {
try {
const t = JSON.parse(fs.readFileSync(trnPath, 'utf8'));
const txt = (t.text || JSON.stringify(t)).slice(0, 200);
record('transcript', 'green', `${kbOf(trnPath)} · "${txt.replace(/\n/g, ' ').slice(0, 80)}…"`);
} catch (e) {
record('transcript', 'red', `parse failed: ${e.message}`);
}
} else {
record('transcript', mp3ExistsLocal ? 'yellow' : 'red', mp3ExistsLocal ? 'Whisper not run yet' : 'no audio to transcribe');
}
}
// ── 5. Provider endpoint contract ────────────────────────────────────
// Twilio: validate TwiML (track=both_tracks, no Redirect, etc.). Vapi:
// we trust the assistant config; the /vapi/webhook route was unit-
// tested elsewhere. Skip the endpoint check entirely for Vapi calls.
if (provider === 'twilio') {
try {
const base = process.env.PUBLIC_URL || 'https://butlr.agentabrams.com';
const r = await fetch(`${base}/twilio/twiml/${callId}`, { method: 'POST' });
const xml = await r.text();
const checks = [
[/<Response>[\s\S]*<\/Response>/.test(xml), '<Response> wrapper'],
[/<Start>\s*<Stream\b[^>]*track="both_tracks"/.test(xml), 'track="both_tracks"'],
[/<Stream[^>]*\burl="wss:\/\//.test(xml), 'wss:// stream url'],
[(xml.match(/<Gather\b/g) || []).length === 1, 'one <Gather>'],
[!/<Redirect\b/.test(xml), 'no <Redirect>'],
[/<Play>|<Say\b/.test(xml), 'announce present'],
];
const failed = checks.filter(([ok]) => !ok).map(([, name]) => name);
if (failed.length === 0) record('twiml', 'green', '6/6');
else record('twiml', 'red', `failed: ${failed.join(', ')}`);
} catch (e) {
record('twiml', 'red', `fetch failed: ${e.message}`);
}
} else if (provider === 'vapi') {
record('vapi-webhook', 'green', 'route mounted (see test/vapi-webhook.test.js)');
}
// ── 6. Print scorecard ───────────────────────────────────────────────
console.log();
for (const r of RESULTS) {
const color = r.level === 'red' ? '\x1b[31m' : r.level === 'yellow' ? '\x1b[33m' : '\x1b[32m';
console.log(` ${color}${chip(r.level)}\x1b[0m ${pad(r.check, 18)} ${r.detail}`);
}
const reds = RESULTS.filter(r => r.level === 'red').length;
const yellows = RESULTS.filter(r => r.level === 'yellow').length;
const greens = RESULTS.filter(r => r.level === 'green').length;
console.log();
console.log(` ${greens} green · ${yellows} yellow · ${reds} red`);
console.log();
process.exit(reds > 0 ? 1 : 0);
}
main().catch(e => { console.error('fatal:', e.message); process.exit(2); });