collabSupport/services/renderers/voiceReportRenderer.js
jmcqueen 1eaabbcee1 Add /voicereport store digest and harden Webex CDR feed handling.
Groups cdr_feed legs by Correlation ID for call-level summaries, fixes report-column field parsing and Docker proxy routing, and adds /calltest post-call Twilio and CDR enrichment.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 13:43:11 -04:00

161 lines
5.9 KiB
JavaScript

// services/renderers/voiceReportRenderer.js
function envInt(name, fallback) {
const v = Number(process.env[name]);
return Number.isFinite(v) ? v : fallback;
}
function fmtTime(iso) {
if (!iso) return '—';
try {
return new Date(iso).toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC');
} catch {
return String(iso);
}
}
function countPoorQuality(calls) {
const mosWarn = Number(process.env.WAN_STANDARD_APP_MOS_WARN || 4.0);
const lossWarn = Number(process.env.WAN_STANDARD_APP_LOSS_WARN_PCT || 5);
const jitterWarn = Number(process.env.WAN_STANDARD_APP_JITTER_WARN_MS || 30);
let poor = 0;
for (const call of calls) {
const q = call.media || call.wan || {};
if (q.mos != null && q.mos < mosWarn) poor++;
else if (q.loss != null && q.loss > lossWarn) poor++;
else if (q.jitter != null && q.jitter > jitterWarn) poor++;
}
return poor;
}
function renderOutcomeBreakdown(outcomes) {
const entries = Object.entries(outcomes || {});
if (!entries.length) return '_No outcome data._';
return entries
.sort((a, b) => b[1] - a[1])
.map(([k, v]) => `- **${k}:** ${v}`)
.join('\n');
}
function renderReachSummary(summary) {
const lines = [];
if (summary.inbound > 0) {
lines.push(
`- **Inbound reached a phone:** ${summary.inboundReachedPhone} of ${summary.inbound}`,
);
if (summary.inboundAaOnly > 0) {
lines.push(`- **Inbound stopped at auto-attendant:** ${summary.inboundAaOnly}`);
}
}
if (summary.outbound > 0) {
lines.push(
`- **Outbound connected:** ${summary.outboundConnected} of ${summary.outbound}`,
);
}
if (summary.abnormal > 0) {
lines.push(`- **Abnormal outcomes:** ${summary.abnormal}`);
}
return lines.join('\n');
}
function renderCallReachLine(call) {
if (call.direction === 'inbound') {
if (call.reachedPhone) {
const who = [call.endpointUser, call.endpointModel].filter(Boolean).join(' / ');
return `reached ${who || 'phone'}`;
}
if (call.reachedAttendant) {
const key = call.aaKeyPress ? ` (AA key ${call.aaKeyPress})` : '';
return `auto-attendant only${key}`;
}
return 'not answered';
}
if (call.direction === 'outbound') {
return call.connected || call.reachedPhone ? 'connected' : 'not connected';
}
return call.outcome || '—';
}
/**
* @param {object} report from collectVoiceReport
* @param {{ detail?: boolean }} opts
*/
export function renderVoiceReportMarkdown(report, opts = {}) {
if (!report?.ok) {
let md = `**Voice report** — Store ${report?.store?.storeNum || '?'}\n\n`;
md += `_${report?.reason || report?.window?.reason || 'Unable to build report.'}_\n`;
return md.trim();
}
const { store, window, cdr, mediaQuality, prisma, joined } = report;
const calls = joined.calls || joined.legs || [];
const summary = joined.summary || {};
const maxRows = envInt('VOICEREPORT_MAX_DETAIL_ROWS', 25);
const detail = Boolean(opts.detail);
let md = `**Voice report — Store ${store.storeNum}**\n\n`;
md += `- **Date:** ${window.label} (${window.mode})\n`;
md += `- **Timezone:** ${window.timeZone}\n`;
md += `- **Window:** ${fmtTime(window.startTime)}${fmtTime(window.endTime)}\n`;
md += `- **Location:** ${store.locationName}\n`;
if (store.dialNumber) md += `- **Main:** ${store.dialNumber}\n`;
md += '\n**Call volume (CDR)**\n';
if (!cdr.available) {
md += `_CDR unavailable:_ ${cdr.reason || 'unknown'}\n`;
} else {
md += `- **Total calls:** ${summary.total ?? calls.length}\n`;
md += `- **Inbound:** ${summary.inbound ?? 0} | **Outbound:** ${summary.outbound ?? 0}\n`;
md += `- **CDR legs fetched:** ${cdr.rawCount ?? '—'} → **${summary.total ?? calls.length}** correlated call(s)\n`;
const reach = renderReachSummary(summary);
if (reach) md += `${reach}\n`;
md += '\n**Call outcomes**\n';
md += `${renderOutcomeBreakdown(summary.outcomes)}\n`;
}
md += '\n**Media quality (Webex report)**\n';
if (!mediaQuality.available) {
md += `_Unavailable:_ ${mediaQuality.reason || 'not generated'}\n`;
} else {
md += `- **Rows in window:** ${mediaQuality.rows?.length ?? 0} (from ${mediaQuality.rawCount ?? '?'} downloaded)\n`;
const poor = countPoorQuality(calls.filter((c) => c.media));
md += `- **Calls with quality flags:** ${poor}\n`;
}
md += '\n**WAN voice (Prisma DPI)**\n';
if (!prisma.available) {
md += `_Unavailable:_ ${prisma.reason || 'not configured'}\n`;
} else {
const m = prisma.appAudio?.mos;
md += `- **App:** ${prisma.appAudio.appName}\n`;
if (m?.min != null) md += `- **Worst MOS in window:** ${m.min}\n`;
if (m?.max != null) md += `- **Best MOS in window:** ${m.max}\n`;
if (prisma.appAudio?.detailsUrl) {
md += `- [View in Prisma SCM](${prisma.appAudio.detailsUrl})\n`;
}
}
if (detail && calls.length) {
md += '\n**Call detail**\n';
const show = calls.slice(0, maxRows);
for (const call of show) {
const dir = String(call.direction || 'unknown').toUpperCase();
const outcome = [call.outcome, call.outcomeReason].filter(Boolean).join(' / ');
const flag = call.abnormal || !call.normalOutcome ? ' ⚠️' : '';
md += `\n- **${fmtTime(call.start)}** ${dir} ${call.duration || 0}s — ${outcome || '—'}${flag}\n`;
md += ` ${call.callingNumber || '?'}${call.calledNumber || '?'} (${call.legCount} leg(s), ${renderCallReachLine(call)})\n`;
if (call.media) {
md += ` Media: MOS ${call.media.mos ?? '—'}, jitter ${call.media.jitter ?? '—'}, loss ${call.media.loss ?? '—'}\n`;
}
if (call.wan) {
md += ` WAN≈: MOS ${call.wan.mos ?? '—'}, jitter ${call.wan.jitter ?? '—'}, loss ${call.wan.loss ?? '—'}\n`;
}
}
if (calls.length > maxRows) {
md += `\n_${calls.length - maxRows} more calls — use Control Hub Detailed Call History for full export._\n`;
}
}
md += `\n*Generated in ${report.elapsedMs}ms*`;
return md.trim();
}