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