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>
161 lines
5.9 KiB
JavaScript
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();
|
|
}
|