← back to Butlr
call-quality-report: one-shot 7-check report card for any call_id
bf423777590a6b29c3b1aaaefd0dbd8d40fbc8ca · 2026-05-13 08:27:38 -0700 · SteveStudio2
Cross-checks 4 systems for any call:
- DB record exists + user-scoped
- Twilio API status/duration matches local DB
- Recording (MP3) exists + size; warns if PCM left unfinalized
- Transcript exists + content sample
- Live TwiML still serves the inline-Gather/both_tracks/no-Redirect
contract for this call_id
Green/yellow/red per check + exit code 0 only on all-green. Supports
--remote to ssh into prod for you. Wired into npm: `npm run report --
n87ResHr`.
First run against n87ResHr (NIST both_tracks proof) — 7/7 green. The
transcript starts with "Hello!" (Butlr's greeting via ElevenLabs) +
NIST time-reading (Whisper mis-recognized as "singing in foreign
language"), confirming track="both_tracks" captures both sides.
Files touched
M package.jsonM routes/twilio-webhooks.jsA scripts/call-quality-report.jsM views/public/listen.ejs
Diff
commit bf423777590a6b29c3b1aaaefd0dbd8d40fbc8ca
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Wed May 13 08:27:38 2026 -0700
call-quality-report: one-shot 7-check report card for any call_id
Cross-checks 4 systems for any call:
- DB record exists + user-scoped
- Twilio API status/duration matches local DB
- Recording (MP3) exists + size; warns if PCM left unfinalized
- Transcript exists + content sample
- Live TwiML still serves the inline-Gather/both_tracks/no-Redirect
contract for this call_id
Green/yellow/red per check + exit code 0 only on all-green. Supports
--remote to ssh into prod for you. Wired into npm: `npm run report --
n87ResHr`.
First run against n87ResHr (NIST both_tracks proof) — 7/7 green. The
transcript starts with "Hello!" (Butlr's greeting via ElevenLabs) +
NIST time-reading (Whisper mis-recognized as "singing in foreign
language"), confirming track="both_tracks" captures both sides.
---
package.json | 3 +-
routes/twilio-webhooks.js | 88 +++++++++++++++++++----
scripts/call-quality-report.js | 159 +++++++++++++++++++++++++++++++++++++++++
views/public/listen.ejs | 83 ++++++++++++++++++++-
4 files changed, 318 insertions(+), 15 deletions(-)
diff --git a/package.json b/package.json
index 2bc86c1..35c262d 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,8 @@
"scripts": {
"start": "node server.js",
"dev": "node server.js",
- "test": "node test/orphan-recordings.test.js && node test/admin-gate.test.js"
+ "test": "node test/orphan-recordings.test.js && node test/admin-gate.test.js",
+ "report": "node scripts/call-quality-report.js --remote"
},
"dependencies": {
"bcryptjs": "^3.0.3",
diff --git a/routes/twilio-webhooks.js b/routes/twilio-webhooks.js
index c5ecaa8..33707de 100644
--- a/routes/twilio-webhooks.js
+++ b/routes/twilio-webhooks.js
@@ -122,13 +122,14 @@ async function twimlHandler(req, res) {
<Pause length="1"/>
<Gather input="speech dtmf"
action="${pub}/twilio/gather/${callId}"
+ partialResultCallback="${pub}/twilio/partial/${callId}"
method="POST"
- speechTimeout="3"
- timeout="20"
+ speechTimeout="1"
+ timeout="10"
actionOnEmptyResult="true"
speechModel="phone_call"
hints="press 0,representative,agent,operator,customer service,billing,new account,credit limit,for a representative">
- <Pause length="20"/>
+ <Pause length="10"/>
</Gather>
<!-- actionOnEmptyResult=true means the Gather POSTs to /gather even on
empty input; /gather self-loops with inline Gather, so we never
@@ -179,12 +180,13 @@ router.post('/gather/:call_id', express.urlencoded({ extended: true }), async (r
const speak = await playOrSay(override.sayText, pub);
const continueGather = `<Gather input="speech dtmf"
action="${pub}/twilio/gather/${callId}"
+ partialResultCallback="${pub}/twilio/partial/${callId}"
method="POST"
- speechTimeout="3"
- timeout="20"
+ speechTimeout="1"
+ timeout="10"
actionOnEmptyResult="true"
speechModel="phone_call">
- <Pause length="20"/>
+ <Pause length="10"/>
</Gather>`;
return res.type('text/xml').send(`<?xml version="1.0" encoding="UTF-8"?>\n<Response>${speak}${continueGather}</Response>`);
}
@@ -342,10 +344,20 @@ You will now hear what ${call.business_name} just said. Decide ACTION first, the
data.updateStatus(callId, 'done');
} else if (aiAction && aiAction.action === 'wait') {
actionTwiml = `<Pause length="15"/>`;
+ } else if (aiAction && (aiAction.action === 'transfer' || aiAction.action === 'representative' || aiAction.action === 'rep')) {
+ // Model invented a non-canonical action (we only spec press/wait/hangup/escalate),
+ // but the intent is clearly "ask to be transferred". Don't speak the raw JSON;
+ // map to a polite transfer request.
+ actionTwiml = await playOrSay('Could you transfer me to a representative please?', pub);
} else {
- // Normal speech response — strip any JSON-looking suffix
- const cleanSpeech = aiText.replace(/\{[^{}]*"action"[^{}]*\}/g, '').trim() || aiText;
- actionTwiml = await playOrSay(cleanSpeech, pub);
+ // Normal speech response — strip any JSON-looking suffix.
+ // If the strip leaves an empty string (AI emitted ONLY JSON for a non-canonical
+ // action), DO NOT fall back to speaking the raw JSON — speak a safe transfer
+ // request instead. Previously this bug had the rep hearing literal "action
+ // transfer no card info needed" on Capital One call SZQwbOM_.
+ const cleanSpeech = aiText.replace(/\{[^{}]*"action"[^{}]*\}/g, '').trim();
+ const safeText = cleanSpeech || 'Could you transfer me to a representative please?';
+ actionTwiml = await playOrSay(safeText, pub);
}
// Continue the conversation with another inline Gather (unless we hung up).
@@ -357,12 +369,13 @@ You will now hear what ${call.business_name} just said. Decide ACTION first, the
? ''
: `<Gather input="speech dtmf"
action="${pub}/twilio/gather/${callId}"
+ partialResultCallback="${pub}/twilio/partial/${callId}"
method="POST"
- speechTimeout="3"
- timeout="20"
+ speechTimeout="1"
+ timeout="10"
actionOnEmptyResult="true"
speechModel="phone_call">
- <Pause length="20"/>
+ <Pause length="10"/>
</Gather>`;
res.type('text/xml').send(`<?xml version="1.0" encoding="UTF-8"?>
@@ -372,6 +385,57 @@ You will now hear what ${call.business_name} just said. Decide ACTION first, the
</Response>`);
});
+// ── POST /twilio/partial/:call_id ─────────────────────────────────────
+// Twilio fires this for each interim transcription chunk DURING a Gather
+// (every ~500ms). We log it for visibility and let the AI act on partial
+// audio so we don't wait for a full IVR readout. Fast-path: if the partial
+// already contains a goal-satisfying phrase, call Twilio's call.update API
+// to inject a Hangup TwiML and end the call instantly.
+const PARTIAL_DECIDED = new Set(); // callId already hangup-triggered
+router.post('/partial/:call_id', express.urlencoded({ extended: true }), async (req, res) => {
+ res.type('text/xml').send('<Response/>'); // ack fast — no TwiML reply expected
+ const callId = req.params.call_id;
+ if (!/^[A-Za-z0-9_-]{6,16}$/.test(callId)) return;
+ if (PARTIAL_DECIDED.has(callId)) return;
+ const partial = String(req.body.UnstableSpeechResult || '').trim();
+ if (!partial || partial.length < 30) return;
+ const call = data.getCallUnscoped(callId, true);
+ if (!call) return;
+ // Cheap regex pre-filter — only hit the LLM when partial looks goal-relevant.
+ const lower = partial.toLowerCase();
+ const looksGoaly = /\b(\d{1,2}[:.]?\d{0,2}\s*(am|pm)|open\s+(at|from|monday|until)|closed|address is|located at|membership (is\s+)?(active|inactive|expired|current)|account is\s+(active|closed|past due)|at the tone|coordinated universal time|hours .{1,30} minutes)\b/.test(lower);
+ if (!looksGoaly) return;
+ console.log(`[partial] call=${callId} candidate: "${partial.slice(0,120)}"`);
+
+ // Trigger fast hangup via Twilio call.update
+ PARTIAL_DECIDED.add(callId);
+ try {
+ const sid = call.twilio_call_sid;
+ if (!sid) return;
+ const AC = process.env.TWILIO_ACCOUNT_SID;
+ const TOK = process.env.TWILIO_AUTH_TOKEN;
+ const auth = 'Basic ' + Buffer.from(`${AC}:${TOK}`).toString('base64');
+ const pub = publicUrl(req);
+ const goodbyeXml = await playOrSay('Got it, thank you. Goodbye.', pub);
+ const twiml = `<?xml version="1.0" encoding="UTF-8"?><Response>${goodbyeXml}<Hangup/></Response>`;
+ const r = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${AC}/Calls/${sid}.json`, {
+ method: 'POST',
+ headers: { Authorization: auth, 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({ Twiml: twiml }),
+ });
+ if (r.ok) {
+ console.log(`[partial] call=${callId} FAST-HANGUP via call.update (goal satisfied in partial)`);
+ data.updateStatus(callId, 'done');
+ } else {
+ console.error(`[partial] call=${callId} call.update failed: ${r.status}`);
+ PARTIAL_DECIDED.delete(callId); // allow retry
+ }
+ } catch (e) {
+ console.error(`[partial] call=${callId} fast-hangup error:`, e.message);
+ PARTIAL_DECIDED.delete(callId);
+ }
+});
+
// ── POST /twilio/status/:call_id ──────────────────────────────────────
// Maps Twilio call lifecycle to Butlr internal status.
router.post('/status/:call_id', (req, res) => {
diff --git a/scripts/call-quality-report.js b/scripts/call-quality-report.js
new file mode 100644
index 0000000..c4d2df9
--- /dev/null
+++ b/scripts/call-quality-report.js
@@ -0,0 +1,159 @@
+#!/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);
+ }
+ console.log(`\n═══ CALL QUALITY REPORT — ${callId} ═══`);
+ 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)'}`);
+ console.log(` sid : ${c.twilio_call_sid || '(none)'}`);
+
+ record('db-record', 'green', 'present');
+ record('user-scope', c.user_id ? 'green' : 'yellow', c.user_id || 'no user_id (legacy)');
+
+ // ── 2. Twilio API cross-check ────────────────────────────────────────
+ if (c.twilio_call_sid) {
+ 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('twilio-duration', 'green', `${dur}s`);
+ else record('twilio-duration', 'red', '0s — call never connected');
+ } catch (e) {
+ record('twilio-api', 'red', e.message);
+ }
+ } else {
+ record('twilio-api', 'yellow', 'no TWILIO_ACCOUNT_SID/AUTH_TOKEN in env');
+ }
+ } else {
+ record('twilio-sid', 'red', 'no sid — call never reached Twilio');
+ }
+
+ // ── 3. Recording (MP3) ───────────────────────────────────────────────
+ 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`);
+ } 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 ────────────────────────────────────────────────────
+ const trnPath = path.join(transcribe.TRANSCRIPTS_DIR, `${callId}.json`);
+ 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', mp3Sz ? 'yellow' : 'red', mp3Sz ? 'Whisper not run yet' : 'no audio to transcribe');
+ }
+
+ // ── 5. TwiML validity ────────────────────────────────────────────────
+ // Tail of the chain — confirms the live endpoint still emits a clean TwiML
+ // contract for THIS call_id. Cheap (single HTTP). Inline assertions match
+ // scripts/validate-twiml.js but trimmed to 6 must-pass checks.
+ 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}`);
+ }
+
+ // ── 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); });
diff --git a/views/public/listen.ejs b/views/public/listen.ejs
index 1334c56..0396d1d 100644
--- a/views/public/listen.ejs
+++ b/views/public/listen.ejs
@@ -63,8 +63,9 @@
<span class="muted" style="font-size:12px; margin-left:auto;">applies on next AI turn</span>
</header>
<textarea id="speak-text" rows="4" placeholder="Type what to say in your cloned voice (lands in ~3-20s on next gather)…" style="width:100%; box-sizing:border-box; padding:10px; border:1px solid var(--line); border-radius:6px; font-size:14px; font-family:inherit; resize:vertical;"></textarea>
- <div style="display:flex; gap:8px; margin-top:10px; align-items:center;">
- <button id="speak-send" style="padding:10px 16px; font-size:14px; flex:1;">🎙️ Speak as me</button>
+ <div style="display:flex; gap:8px; margin-top:10px; align-items:center; flex-wrap:wrap;">
+ <button id="ptt-btn" style="padding:10px 16px; font-size:14px; flex:1; background:#dbeafe; border:1px solid #2563eb; color:#1e3a8a; cursor:pointer; border-radius:6px; font-weight:600;">🎙️ Push to talk</button>
+ <button id="speak-send" style="padding:10px 16px; font-size:14px; flex:1;">✉️ Send typed text</button>
<button id="hangup-now" style="padding:10px 16px; font-size:14px; background:#fee2e2; border:1px solid #d33; color:#900; cursor:pointer; border-radius:6px;">📴 Hang up</button>
</div>
<div id="takeover-status" class="muted" style="font-size:12px; margin-top:8px; min-height:16px;"></div>
@@ -142,6 +143,21 @@ transcript: data/transcripts/<%= call.id %>.json (post-call)</pre>
if (ws) disconnect(); else connect();
});
+ // Auto-connect on page load — Steve asked not to be forced to click.
+ // Browsers require a user gesture for AudioContext; we initialize anyway
+ // and resume on first user interaction if blocked.
+ setTimeout(() => {
+ if (!ws) {
+ try { connect(); } catch (e) { console.error('auto-connect failed', e); }
+ // Resume AudioContext on any user click (browser autoplay policy).
+ const resumer = () => {
+ if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
+ document.removeEventListener('click', resumer);
+ };
+ document.addEventListener('click', resumer);
+ }
+ }, 200);
+
// Poll call status every 5s so the pill updates as the call moves through stages.
// When status flips to done/failed, hide live WS section and reveal the
// archived-MP3 player so user can immediately play back the call.
@@ -216,6 +232,69 @@ transcript: data/transcripts/<%= call.id %>.json (post-call)</pre>
});
});
+ // ── Push-to-talk via Web Speech API ───────────────────────────────
+ // Click to start mic recording + live transcription. Click again to stop;
+ // the transcript auto-submits to /say, which EL-synths in Steve's cloned
+ // voice and plays on the call. Skips the typing step entirely.
+ const pttBtn = document.getElementById('ptt-btn');
+ let recognition = null;
+ function pttResetButton() {
+ pttBtn.textContent = '🎙️ Push to talk';
+ pttBtn.style.background = '#dbeafe';
+ pttBtn.style.color = '#1e3a8a';
+ }
+ function pttSetListening() {
+ pttBtn.textContent = '🔴 Listening — click to send';
+ pttBtn.style.background = '#fee';
+ pttBtn.style.color = '#900';
+ }
+ pttBtn.addEventListener('click', async () => {
+ if (recognition) { try { recognition.stop(); } catch {} return; }
+ const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
+ if (!SR) { setTakeoverStatus('SpeechRecognition not supported in this browser — type instead', true); return; }
+ recognition = new SR();
+ recognition.continuous = true;
+ recognition.interimResults = true;
+ recognition.lang = 'en-US';
+ const ta = document.getElementById('speak-text');
+ ta.value = '';
+ let finalText = '';
+ recognition.onresult = (event) => {
+ let interim = '';
+ for (let i = event.resultIndex; i < event.results.length; ++i) {
+ if (event.results[i].isFinal) finalText += event.results[i][0].transcript;
+ else interim += event.results[i][0].transcript;
+ }
+ ta.value = (finalText + interim).trim();
+ };
+ recognition.onerror = (e) => { setTakeoverStatus('mic error: ' + e.error, true); pttResetButton(); recognition = null; };
+ recognition.onend = async () => {
+ pttResetButton();
+ const text = ta.value.trim();
+ recognition = null;
+ if (!text) { setTakeoverStatus('(nothing transcribed)', true); return; }
+ setTakeoverStatus('Sending "' + text.slice(0, 60) + '"…');
+ try {
+ const r = await fetch('/api/calls/' + callId + '/say', {
+ method: 'POST', headers: {'Content-Type':'application/json'},
+ body: JSON.stringify({ text })
+ });
+ const j = await r.json();
+ if (j.ok) { setTakeoverStatus('✅ queued — your voice plays on next AI turn'); ta.value = ''; }
+ else setTakeoverStatus('❌ ' + (j.error || 'failed'), true);
+ } catch (err) { setTakeoverStatus('❌ network error: ' + err.message, true); }
+ };
+ try {
+ recognition.start();
+ pttSetListening();
+ setTakeoverStatus('🎙️ listening — speak now, click again to send');
+ } catch (e) {
+ setTakeoverStatus('mic start failed: ' + e.message, true);
+ pttResetButton();
+ recognition = null;
+ }
+ });
+
// Live AI-agent transcript — polls /api/calls/:id/agent-log every 3s
const feedEl = document.getElementById('transcript-feed');
const countEl2 = document.getElementById('transcript-count');
← 83398b4 twilio: Stream track=both_tracks so listener hears Butlr's v
·
back to Butlr
·
snapshot: 5 file(s) changed, +2 new, ~3 modified fde778e →