← 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); });