collabSupport/services/voiceReport/joinCallQuality.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

199 lines
5.9 KiB
JavaScript

// services/voiceReport/joinCallQuality.js
// Join CDR legs, Media Quality rows, and Prisma WAN buckets.
import { isWithinWindow } from './businessWindow.js';
import {
cdrCalledNumber,
cdrCallingNumber,
cdrDirectionValue,
cdrDispositionValue,
cdrDurationSeconds,
cdrField,
cdrStartTime,
} from '../cdrFeedParser.js';
import { groupCdrIntoCalls, summarizeCalls } from './groupCdrCalls.js';
export function phoneDigits(value) {
const d = String(value || '').replace(/\D/g, '');
if (d.length === 10) return `1${d}`;
if (d.length === 11 && d.startsWith('1')) return d;
return d;
}
function cdrDisposition(item) {
return cdrDispositionValue(item);
}
function cdrDirection(item) {
return cdrDirectionValue(item);
}
function cdrStart(item) {
return cdrStartTime(item);
}
function cdrDuration(item) {
return cdrDurationSeconds(item);
}
function normalizeCdrLeg(item) {
return {
start: cdrStart(item),
direction: cdrDirection(item),
duration: cdrDuration(item),
disposition: cdrDisposition(item),
callingNumber: cdrCallingNumber(item),
calledNumber: cdrCalledNumber(item),
correlationId: cdrField(item, 'correlationId', 'Correlation ID', 'correlationID'),
raw: item,
};
}
function mqCorrelation(row) {
const n = row._norm || {};
return n.correlationid || n.finalcorrelationid || row['Correlation ID'] || null;
}
function mqStart(row) {
const n = row._norm || {};
return n.starttime || n.callstarttime || row['Start Time'] || null;
}
function mqNumbers(row) {
const n = row._norm || {};
return [
n.callingnumber, n.callednumber, n.remoteparty,
row['Calling Number'], row['Called Number'],
].filter(Boolean).map(phoneDigits);
}
function mqQuality(row) {
const n = row._norm || {};
const mos = Number(n.mos || n.meanopinionscore || n.audiomos || NaN);
const jitter = Number(n.jitter || n.jitterms || NaN);
const loss = Number(n.packetloss || n.packetlosspct || n.loss || NaN);
return {
mos: Number.isFinite(mos) ? mos : null,
jitter: Number.isFinite(jitter) ? jitter : null,
loss: Number.isFinite(loss) ? loss : null,
};
}
function scoreMqMatch(call, row) {
const callCorr = call.correlationId ? String(call.correlationId).toLowerCase() : '';
const rowCorr = mqCorrelation(row);
if (callCorr && rowCorr && String(rowCorr).toLowerCase() === callCorr) return 100;
const legStart = call.start ? new Date(call.start).getTime() : NaN;
const rowStart = mqStart(row) ? new Date(mqStart(row)).getTime() : NaN;
if (!Number.isFinite(legStart) || !Number.isFinite(rowStart)) return 0;
const delta = Math.abs(legStart - rowStart);
if (delta > 120_000) return 0;
let score = 50;
if (delta < 15_000) score += 30;
else if (delta < 60_000) score += 10;
const legNums = new Set([
phoneDigits(call.callingNumber),
phoneDigits(call.calledNumber),
].filter(Boolean));
const rowNums = mqNumbers(row);
if (rowNums.some((n) => legNums.has(n))) score += 20;
return score;
}
export function matchMediaQualityToCall(call, mqRows) {
let best = null;
let bestScore = 0;
for (const row of mqRows || []) {
const s = scoreMqMatch(call, row);
if (s > bestScore) {
bestScore = s;
best = row;
}
}
if (!best || bestScore < 50) return null;
return { ...mqQuality(best), score: bestScore };
}
/** @deprecated use matchMediaQualityToCall */
export function matchMediaQualityToLeg(leg, mqRows) {
return matchMediaQualityToCall(leg, mqRows);
}
/**
* Find worst Prisma 5-min bucket overlapping [start, end].
*/
export function worstPrismaBucketForCall(call, appAudio, window) {
if (!appAudio?.mos?.values?.length) return null;
const startMs = call.start ? new Date(call.start).getTime() : NaN;
const endMs = Number.isFinite(startMs)
? startMs + (call.duration || 0) * 1000
: NaN;
if (!Number.isFinite(startMs)) return null;
const intervalMs = 5 * 60 * 1000;
const winStart = new Date(window.startTime).getTime();
const idx0 = Math.max(0, Math.floor((startMs - winStart) / intervalMs));
const idx1 = Math.max(idx0, Math.ceil((endMs - winStart) / intervalMs));
const mosVals = appAudio.mos.values;
const lossVals = appAudio.loss?.values || [];
const jitterVals = appAudio.jitter?.values || [];
let worstMos = null;
let worstLoss = null;
let worstJitter = null;
for (let i = idx0; i <= idx1 && i < mosVals.length; i++) {
const m = mosVals[i];
if (typeof m === 'number' && (worstMos == null || m < worstMos)) worstMos = m;
const l = lossVals[i];
if (typeof l === 'number' && (worstLoss == null || l > worstLoss)) worstLoss = l;
const j = jitterVals[i];
if (typeof j === 'number' && (worstJitter == null || j > worstJitter)) worstJitter = j;
}
if (worstMos == null && worstLoss == null && worstJitter == null) return null;
return {
siteLevelApprox: true,
mos: worstMos,
loss: worstLoss,
jitter: worstJitter,
};
}
export function summarizeDispositions(legs) {
const counts = {};
for (const leg of legs) {
const key = String(leg.disposition || 'unknown').toLowerCase();
counts[key] = (counts[key] || 0) + 1;
}
return counts;
}
export function joinCallQuality({ cdrItems, mqRows, appAudio, window }) {
const calls = groupCdrIntoCalls(cdrItems);
const joined = calls.map((call) => {
const media = matchMediaQualityToCall(call, mqRows);
const wan = worstPrismaBucketForCall(call, appAudio, window);
return { ...call, media, wan };
});
return {
calls: joined,
legs: joined, // backward compat for callers expecting .legs
summary: summarizeCalls(joined),
};
}
export function filterCdrToWindow(items, window) {
return (items || []).filter((item) => {
const start = cdrStartTime(item);
// cdr_feed query params already scope by start time; keep rows when the
// report column label differs from our camelCase guesses.
if (!start) return true;
return isWithinWindow(start, window);
});
}