// services/callReport/voicePathAttribution.js // // Phase 2 helpers: annotate a call's WAN quality with the *likely* // path_type and/or waninterface when Prisma DPI is broken out. // // Confidence levels: // - site — only site-wide appAudio (legacy siteLevelApprox) // - path_type — byPathType uniquely implicates one path_type // - path — byPath uniquely implicates one waninterface id // - endpoint — reserved; requires flow/session join keys // (not available on this tenant yet — see // services/voiceDiag/README.md § Voice path attribution) // // Never claims "confirmed" without flow records. const PATH_TYPE_ORDER = ['VPN', 'PrivateWAN', 'PrivateVPN', 'DirectInternet', 'ServiceLink']; /** * Score a path-type / path summary for "how bad during this call". * Higher = worse. Uses loss max and jitter max when present. */ function badnessScore(summary) { if (!summary) return null; const loss = summary.loss?.max ?? summary.loss?.avg; const jitter = summary.jitter?.max ?? summary.jitter?.avg; const mos = summary.mos?.min ?? summary.mos?.avg; let score = 0; let parts = 0; if (typeof loss === 'number') { score += loss; parts += 1; } if (typeof jitter === 'number') { score += jitter / 10; parts += 1; } if (typeof mos === 'number') { score += Math.max(0, 5 - mos) * 5; parts += 1; } return parts > 0 ? score : null; } /** * Pick the uniquely-worst entry from a map of summaries. * Returns null when tie / empty / insufficient data. * * @param {Record} map * @returns {{ key: string, score: number, margin: number }|null} */ export function pickUniqueWorst(map) { if (!map || typeof map !== 'object') return null; const scored = Object.entries(map) .map(([key, summary]) => ({ key, score: badnessScore(summary) })) .filter((e) => e.score != null) .sort((a, b) => b.score - a.score); if (scored.length === 0) return null; if (scored.length === 1) { return { key: scored[0].key, score: scored[0].score, margin: scored[0].score }; } const margin = scored[0].score - scored[1].score; // Require a meaningful gap so we don't flip-flop on noise. if (margin < 0.5) return null; return { key: scored[0].key, score: scored[0].score, margin }; } /** * Annotate a site-level wan bucket with likely path attribution. * * @param {object|null} wanBucket from worstPrismaBucketForCall * @param {object|null} appAudio collectSdwanForStore().appAudio * @returns {object|null} */ export function annotateWanWithPath(wanBucket, appAudio) { if (!wanBucket) return null; const base = { ...wanBucket, siteLevelApprox: wanBucket.siteLevelApprox !== false, confidence: 'site', pathType: null, pathId: null, attributionNote: null, }; // Prefer per-circuit when present (Phase 2 path filter). const byPath = appAudio?.byPath; if (byPath && Object.keys(byPath).length > 0) { const pick = pickUniqueWorst(byPath); if (pick) { return { ...base, siteLevelApprox: false, confidence: 'path', pathId: pick.key, pathType: null, attributionNote: `Likely on WAN circuit ${pick.key} ` + `(worst path DPI during call window; confidence=path).`, }; } } // Fall back to path_type breakout (Phase 1). const byPt = appAudio?.byPathType; if (byPt && Object.keys(byPt).length > 0) { // Prefer known order for stable ties that somehow pass margin. const ordered = {}; for (const k of PATH_TYPE_ORDER) { if (byPt[k]) ordered[k] = byPt[k]; } for (const k of Object.keys(byPt)) { if (!ordered[k]) ordered[k] = byPt[k]; } const pick = pickUniqueWorst(ordered); if (pick) { return { ...base, siteLevelApprox: true, confidence: 'path_type', pathType: pick.key, pathId: null, attributionNote: `Likely path type ${pick.key} ` + `(worst path_type DPI in window; confidence=path_type, still site-level).`, }; } } return base; } /** * Phase 2c stub: per-handset / per-extension attribution. * Returns null until Prisma exposes a join key (client IP / flow). * * @returns {null} */ export function attributeVoicePathToEndpoint(/* call, inventory */) { return null; }