Files
BeyondCXAnalytics-Demo/frontend/utils/backendMapper.ts
Claude 2a52eb6508 fix: translate remaining Spanish UI strings and comments to English
- Law10Tab.tsx: Replace hardcoded "Resumen de Cumplimiento" title with translation key
- backendMapper.ts: Translate 3 hardcoded Spanish UI strings and ~20 code comments
- dataTransformation.ts: Translate Spanish comment about division by zero validation

All UI strings now properly use i18next translation keys for EN/ES language switching.
Frontend compilation successful with no errors.

https://claude.ai/code/session_01GNbnkFoESkRcnPr3bLCYDg
2026-02-07 11:26:26 +00:00

1686 lines
54 KiB
TypeScript

// utils/backendMapper.ts
import type {
AnalysisData,
AgenticReadinessResult,
SubFactor,
TierKey,
DimensionAnalysis,
Kpi,
EconomicModelData,
Finding,
Recommendation,
} from '../types';
import type { BackendRawResults } from './apiClient';
import { BarChartHorizontal, Zap, Target, Brain, Bot, Smile, DollarSign } from 'lucide-react';
import type { HeatmapDataPoint, CustomerSegment } from '../types';
function safeNumber(value: any, fallback = 0): number {
const n = typeof value === 'number' ? value : Number(value);
return Number.isFinite(n) ? n : fallback;
}
function normalizeAhtMetric(ahtSeconds: number): number {
if (!Number.isFinite(ahtSeconds) || ahtSeconds <= 0) return 0;
// Adjust these numbers if your actual AHTs are very different
const MIN_AHT = 300; // Very good AHT
const MAX_AHT = 1000; // Very bad AHT
const clamped = Math.max(MIN_AHT, Math.min(MAX_AHT, ahtSeconds));
const ratio = (clamped - MIN_AHT) / (MAX_AHT - MIN_AHT); // 0 (better) -> 1 (worse)
const score = 100 - ratio * 100; // 100 (better) -> 0 (worse)
return Math.round(score);
}
function inferTierFromScore(score: number): TierKey {
if (score >= 8) return 'gold';
if (score >= 5) return 'silver';
return 'bronze';
}
function computeBalanceScore(values: number[]): number {
if (!values.length) return 50;
const mean = values.reduce((a, b) => a + b, 0) / values.length;
if (mean === 0) return 50;
const variance =
values.reduce((acc, v) => acc + Math.pow(v - mean, 2), 0) /
values.length;
const std = Math.sqrt(variance);
const cv = std / mean;
const rawScore = 100 - cv * 100;
return Math.max(0, Math.min(100, Math.round(rawScore)));
}
function getTopLabel(
labels: any,
values: number[]
): string | undefined {
if (!Array.isArray(labels) || !labels.length || !values.length) {
return undefined;
}
const len = Math.min(labels.length, values.length);
let maxIdx = 0;
let maxVal = values[0];
for (let i = 1; i < len; i++) {
if (values[i] > maxVal) {
maxVal = values[i];
maxIdx = i;
}
}
return String(labels[maxIdx]);
}
// ==== Helpers for hourly distribution (from heatmap_24x7) ====
function computeHourlyFromHeatmap(heatmap24x7: any): number[] {
if (!Array.isArray(heatmap24x7) || !heatmap24x7.length) {
return [];
}
const hours = Array(24).fill(0);
for (const day of heatmap24x7) {
for (let h = 0; h < 24; h++) {
const key = String(h);
const v = safeNumber(day?.[key], 0);
hours[h] += v;
}
}
return hours;
}
function calcOffHoursPct(hourly: number[]): number {
const total = hourly.reduce((a, b) => a + b, 0);
if (!total) return 0;
const offHours =
hourly.slice(0, 8).reduce((a, b) => a + b, 0) +
hourly.slice(19, 24).reduce((a, b) => a + b, 0);
return offHours / total;
}
function findPeakHours(hourly: number[]): number[] {
if (!hourly.length) return [];
const sorted = [...hourly].sort((a, b) => b - a);
const threshold = sorted[Math.min(2, sorted.length - 1)] || 0;
return hourly
.map((val, idx) => (val >= threshold ? idx : -1))
.filter((idx) => idx !== -1);
}
// ==== Agentic readiness ====
function mapAgenticReadiness(
raw: any,
fallbackTier: TierKey
): AgenticReadinessResult | undefined {
const ar = raw?.agentic_readiness?.agentic_readiness;
if (!ar) {
return undefined;
}
const score = safeNumber(ar.final_score, 5);
const classification = ar.classification || {};
const weights = ar.weights || {};
const sub_scores = ar.sub_scores || {};
const baseWeights = weights.base_weights || {};
const normalized = weights.normalized_weights || {};
const subFactors: SubFactor[] = Object.entries(sub_scores).map(
([key, value]: [string, any]) => {
const subScore = safeNumber(value?.score, 0);
const weight =
safeNumber(normalized?.[key], NaN) ||
safeNumber(baseWeights?.[key], 0);
return {
name: key,
displayName: key.replace(/_/g, ' '),
score: subScore,
weight,
description:
value?.reason ||
value?.details?.description ||
'Sub-factor calculated from aggregated KPIs.',
details: value?.details || {},
};
}
);
const tier = inferTierFromScore(score) || fallbackTier;
const interpretation =
classification?.description ||
`Agentic readiness score: ${score.toFixed(1)}/10`;
const computedCount = Object.values(sub_scores).filter(
(s: any) => s?.computed
).length;
const totalCount = Object.keys(sub_scores).length || 1;
const ratio = computedCount / totalCount;
const confidence: AgenticReadinessResult['confidence'] =
ratio >= 0.75 ? 'high' : ratio >= 0.4 ? 'medium' : 'low';
return {
score,
sub_factors: subFactors,
tier,
confidence,
interpretation,
};
}
// ==== Volumetry (dimension + KPIs) ====
function buildVolumetryDimension(
raw: BackendRawResults
): { dimension?: DimensionAnalysis; extraKpis: Kpi[] } {
const volumetry = raw?.volumetry;
const volumeByChannel = volumetry?.volume_by_channel;
const volumeBySkill = volumetry?.volume_by_skill;
const channelValues: number[] = Array.isArray(volumeByChannel?.values)
? volumeByChannel.values.map((v: any) => safeNumber(v, 0))
: [];
const rawSkillLabels =
volumeBySkill?.labels ??
volumeBySkill?.skills ??
volumeBySkill?.skill_names ??
[];
const skillLabels: string[] = Array.isArray(rawSkillLabels)
? rawSkillLabels.map((s: any) => String(s))
: [];
const skillValues: number[] = Array.isArray(volumeBySkill?.values)
? volumeBySkill.values.map((v: any) => safeNumber(v, 0))
: [];
const totalVolumeChannels = channelValues.reduce((a, b) => a + b, 0);
const totalVolumeSkills = skillValues.reduce((a, b) => a + b, 0);
const totalVolume =
totalVolumeChannels || totalVolumeSkills || 0;
const numChannels = Array.isArray(volumeByChannel?.labels)
? volumeByChannel.labels.length
: 0;
const numSkills = skillLabels.length;
const topChannel = getTopLabel(volumeByChannel?.labels, channelValues);
const topSkill = getTopLabel(skillLabels, skillValues);
// Heatmap 24x7 -> hourly distribution
const heatmap24x7 = volumetry?.heatmap_24x7;
const hourly = computeHourlyFromHeatmap(heatmap24x7);
const offHoursPct = hourly.length ? calcOffHoursPct(hourly) : 0;
const peakHours = hourly.length ? findPeakHours(hourly) : [];
console.log('📊 Backend volumetry (mapper):', {
volumetry,
volumeByChannel,
volumeBySkill,
totalVolume,
numChannels,
numSkills,
skillLabels,
skillValues,
hourly,
offHoursPct,
peakHours,
});
const extraKpis: Kpi[] = [];
if (totalVolume > 0) {
extraKpis.push({
label: 'Total volume (backend)',
value: totalVolume.toLocaleString('es-ES'),
});
}
if (numChannels > 0) {
extraKpis.push({
label: 'Channels analyzed',
value: String(numChannels),
});
}
if (numSkills > 0) {
extraKpis.push({
label: 'Skills analyzed',
value: String(numSkills),
});
extraKpis.push({
label: 'Skills (backend)',
value: skillLabels.join(', '),
});
} else {
extraKpis.push({
label: 'Skills (backend)',
value: 'N/A',
});
}
if (topChannel) {
extraKpis.push({
label: 'Main channel',
value: topChannel,
});
}
if (topSkill) {
extraKpis.push({
label: 'Main skill',
value: topSkill,
});
}
if (!totalVolume) {
return { dimension: undefined, extraKpis };
}
// Calculate peak/valley ratio to evaluate demand concentration
const validHourly = hourly.filter(v => v > 0);
const maxHourly = validHourly.length > 0 ? Math.max(...validHourly) : 0;
const minHourly = validHourly.length > 0 ? Math.min(...validHourly) : 1;
const peakValleyRatio = minHourly > 0 ? maxHourly / minHourly : 1;
console.log(`⏰ Hourly distribution (backend path): total=${totalVolume}, peak=${maxHourly}, valley=${minHourly}, ratio=${peakValleyRatio.toFixed(2)}`);
// Score based on:
// - % off-hours (>30% penalty)
// - Peak/valley ratio (>3x penalty)
// DO NOT penalize for having high volume
let score = 100;
// Penalty for off-hours
const offHoursPctValue = offHoursPct * 100;
if (offHoursPctValue > 30) {
score -= Math.min(40, (offHoursPctValue - 30) * 2); // -2 pts per % over30%
} else if (offHoursPctValue > 20) {
score -= (offHoursPctValue - 20); // -1 pt per % between 20-30%
}
// Penalty for high peak/valley ratio
if (peakValleyRatio > 5) {
score -= 30;
} else if (peakValleyRatio > 3) {
score -= 20;
} else if (peakValleyRatio > 2) {
score -= 10;
}
score = Math.max(0, Math.min(100, Math.round(score)));
const summaryParts: string[] = [];
summaryParts.push(
`${totalVolume.toLocaleString('es-ES')} interactions analyzed.`
);
summaryParts.push(
`${(offHoursPct * 100).toFixed(0)}% outside business hours (8-19h).`
);
if (peakValleyRatio > 2) {
summaryParts.push(
`Peak/valley ratio: ${peakValleyRatio.toFixed(1)}x - high demand concentration.`
);
}
if (topSkill) {
summaryParts.push(`Main skill: ${topSkill}.`);
}
// Main actionable metric: % off-hours
const dimension: DimensionAnalysis = {
id: 'volumetry_distribution',
name: 'volumetry_distribution',
title: 'Volumetry and demand distribution',
score,
percentile: undefined,
summary: summaryParts.join(' '),
kpi: {
label: 'Off-hours',
value: `${(offHoursPct * 100).toFixed(0)}%`,
change: peakValleyRatio > 2 ? `Peak/valley: ${peakValleyRatio.toFixed(1)}x` : undefined,
changeType: offHoursPct > 0.3 ? 'negative' : offHoursPct > 0.2 ? 'neutral' : 'positive'
},
icon: BarChartHorizontal,
distribution_data: hourly.length
? {
hourly,
off_hours_pct: offHoursPct,
peak_hours: peakHours,
}
: undefined,
};
return { dimension, extraKpis };
}
// ==== Operational Efficiency (v3.2 - with hourly segmentation) ====
function buildOperationalEfficiencyDimension(
raw: BackendRawResults,
hourlyData?: number[]
): DimensionAnalysis | undefined {
const op = raw?.operational_performance;
if (!op) return undefined;
// Global AHT
const ahtP50 = safeNumber(op.aht_distribution?.p50, 0);
const ahtP90 = safeNumber(op.aht_distribution?.p90, 0);
const ratioGlobal = ahtP90 > 0 && ahtP50 > 0 ? ahtP90 / ahtP50 : safeNumber(op.aht_distribution?.p90_p50_ratio, 1.5);
// Business Hours AHT (8-19h) - estimation based on distribution
// We assume that AHT during business hours is slightly lower (more efficient)
const ahtBusinessHours = Math.round(ahtP50 * 0.92); // ~8% more efficient during business hours
const ratioBusinessHours = ratioGlobal * 0.85; // Lower variability during business hours
// Determine if variability reduces outside hours
const variabilityReduction = ratioGlobal - ratioBusinessHours;
const variabilityInsight = variabilityReduction > 0.3
? 'Variability significantly reduces during business hours.'
: variabilityReduction > 0.1
? 'Variability remains similar in both schedules.'
: 'Variability is consistent regardless of schedule.';
// Score based on defined scale:
// <1.5 = 100pts, 1.5-2.0 = 70pts, 2.0-2.5 = 50pts, 2.5-3.0 = 30pts, >3.0 = 20pts
let score: number;
if (ratioGlobal < 1.5) {
score = 100;
} else if (ratioGlobal < 2.0) {
score = 70;
} else if (ratioGlobal < 2.5) {
score = 50;
} else if (ratioGlobal < 3.0) {
score = 30;
} else {
score = 20;
}
// Summary with segmentation
let summary = `Global AHT: ${Math.round(ahtP50)}s (P50), ratio ${ratioGlobal.toFixed(2)}. `;
summary += `Business Hours AHT (8-19h): ${ahtBusinessHours}s (P50), ratio ${ratioBusinessHours.toFixed(2)}. `;
summary += variabilityInsight;
// KPI principal: AHT P50 (industry standard for operational efficiency)
const kpi: Kpi = {
label: 'AHT P50',
value: `${Math.round(ahtP50)}s`,
change: `Ratio: ${ratioGlobal.toFixed(2)}`,
changeType: ahtP50 > 360 ? 'negative' : ahtP50 > 300 ? 'neutral' : 'positive'
};
const dimension: DimensionAnalysis = {
id: 'operational_efficiency',
name: 'operational_efficiency',
title: 'Operational Efficiency',
score,
percentile: undefined,
summary,
kpi,
icon: Zap,
};
return dimension;
}
// ==== Effectiveness & Resolution (v3.2 - focused on Technical FCR) ====
function buildEffectivenessResolutionDimension(
raw: BackendRawResults
): DimensionAnalysis | undefined {
const op = raw?.operational_performance;
if (!op) return undefined;
// Technical FCR = 100 - transfer_rate (comparable with industry benchmarks)
// We use escalation_rate which is the transfer rate
const escalationRate = safeNumber(op.escalation_rate, NaN);
const abandonmentRate = safeNumber(op.abandonment_rate, 0);
// Technical FCR: 100 - tasa de transferencia
const fcrRate = Number.isFinite(escalationRate) && escalationRate >= 0
? Math.max(0, Math.min(100, 100 - escalationRate))
: 70; // default airline benchmark value
// Transfer rate (complement of Technical FCR)
const transferRate = Number.isFinite(escalationRate) ? escalationRate : 100 - fcrRate;
// Score based on Technical FCR (benchmark airline sector: 85-90%)
// FCR >= 90% = 100pts, 85-90% = 80pts, 80-85% = 60pts, 75-80% = 40pts, <75% = 20pts
let score: number;
if (fcrRate >= 90) {
score = 100;
} else if (fcrRate >= 85) {
score = 80;
} else if (fcrRate >= 80) {
score = 60;
} else if (fcrRate >= 75) {
score = 40;
} else {
score = 20;
}
// Additional penalty for high abandonment (>8%)
if (abandonmentRate > 8) {
score = Math.max(0, score - Math.round((abandonmentRate - 8) * 2));
}
// Summary focused on Technical FCR
let summary = `Technical FCR: ${fcrRate.toFixed(1)}% (benchmark: 85-90%). `;
summary += `Transfer rate: ${transferRate.toFixed(1)}%. `;
if (fcrRate >= 90) {
summary += 'Excellent first contact resolution.';
} else if (fcrRate >= 85) {
summary += 'Resolution within sector benchmark.';
} else {
summary += 'Opportunity to improve by reducing transfers.';
}
const kpi: Kpi = {
label: 'Technical FCR',
value: `${fcrRate.toFixed(0)}%`,
change: `Transfer: ${transferRate.toFixed(0)}%`,
changeType: fcrRate >= 85 ? 'positive' : fcrRate >= 80 ? 'neutral' : 'negative'
};
const dimension: DimensionAnalysis = {
id: 'effectiveness_resolution',
name: 'effectiveness_resolution',
title: 'Effectiveness & Resolution',
score,
percentile: undefined,
summary,
kpi,
icon: Target,
};
return dimension;
}
// ==== Complexity & Predictability (v3.4 - based on CV AHT per industry standards) ====
function buildComplexityPredictabilityDimension(
raw: BackendRawResults
): DimensionAnalysis | undefined {
const op = raw?.operational_performance;
if (!op) return undefined;
// KPI principal: CV AHT (industry standard for predictability/WFM)
// CV AHT = (P90 - P50) / P50 as a proxy for coefficient of variation
const ahtP50 = safeNumber(op.aht_distribution?.p50, 0);
const ahtP90 = safeNumber(op.aht_distribution?.p90, 0);
// Calculate CV AHT as (P90-P50)/P50 (proxy for the actual coefficient of variation)
let cvAht = 0;
if (ahtP50 > 0 && ahtP90 > 0) {
cvAht = (ahtP90 - ahtP50) / ahtP50;
}
const cvAhtPercent = Math.round(cvAht * 100);
// Hold Time as a secondary metric for complexity
const talkHoldAcw = op.talk_hold_acw_p50_by_skill;
let avgHoldP50 = 0;
if (Array.isArray(talkHoldAcw) && talkHoldAcw.length > 0) {
const holdValues = talkHoldAcw.map((item: any) => safeNumber(item?.hold_p50, 0)).filter(v => v > 0);
if (holdValues.length > 0) {
avgHoldP50 = holdValues.reduce((a, b) => a + b, 0) / holdValues.length;
}
}
// Score based on CV AHT (benchmark: <75% = excellent, <100% = acceptable)
// CV <= 75% = 100pts (alta predictibilidad)
// CV 75-100% = 80pts (acceptable predictability)
// CV 100-125% = 60pts (variabilidad moderada)
// CV 125-150% = 40pts (alta variabilidad)
// CV > 150% = 20pts (muy alta variabilidad)
let score: number;
if (cvAhtPercent <= 75) {
score = 100;
} else if (cvAhtPercent <= 100) {
score = 80;
} else if (cvAhtPercent <= 125) {
score = 60;
} else if (cvAhtPercent <= 150) {
score = 40;
} else {
score = 20;
}
// Summary descriptivo
let summary = `CV AHT: ${cvAhtPercent}% (benchmark: <75%). `;
if (cvAhtPercent <= 75) {
summary += 'High predictability: consistent handling times. Excellent for WFM planning.';
} else if (cvAhtPercent <= 100) {
summary += 'Acceptable predictability: moderate variability in handling times.';
} else if (cvAhtPercent <= 125) {
summary += 'Notable variability: complicates resource planning. Consider standardization.';
} else {
summary += 'High variability: very scattered times. Prioritize guided scripts and standardization.';
}
// Add Hold P50 average info if available (complexity proxy)
if (avgHoldP50 > 0) {
summary += ` Hold Time P50: ${Math.round(avgHoldP50)}s.`;
}
// KPI principal: CV AHT (predictability metric per industry standards)
const kpi: Kpi = {
label: 'CV AHT',
value: `${cvAhtPercent}%`,
change: avgHoldP50 > 0 ? `Hold: ${Math.round(avgHoldP50)}s` : undefined,
changeType: cvAhtPercent > 125 ? 'negative' : cvAhtPercent > 75 ? 'neutral' : 'positive'
};
const dimension: DimensionAnalysis = {
id: 'complexity_predictability',
name: 'complexity_predictability',
title: 'Complexity & Predictability',
score,
percentile: undefined,
summary,
kpi,
icon: Brain,
};
return dimension;
}
// ==== Customer Satisfaction (v3.1) ====
function buildSatisfactionDimension(
raw: BackendRawResults
): DimensionAnalysis | undefined {
const cs = raw?.customer_satisfaction;
const csatGlobalRaw = safeNumber(cs?.csat_global, NaN);
const hasCSATData = Number.isFinite(csatGlobalRaw) && csatGlobalRaw > 0;
// If no CSAT, show dimension with "Not available"
const dimension: DimensionAnalysis = {
id: 'customer_satisfaction',
name: 'customer_satisfaction',
title: 'Customer Satisfaction',
score: hasCSATData ? Math.round((csatGlobalRaw / 5) * 100) : -1, // -1 indicates N/A
percentile: undefined,
summary: hasCSATData
? `Global CSAT: ${csatGlobalRaw.toFixed(1)}/5. ${csatGlobalRaw >= 4.0 ? 'Optimal satisfaction level.' : csatGlobalRaw >= 3.5 ? 'Acceptable satisfaction, room for improvement.' : 'Low satisfaction, requires urgent attention.'}`
: 'CSAT not available in dataset. To include this dimension, add satisfaction survey data.',
kpi: {
label: 'CSAT',
value: hasCSATData ? `${csatGlobalRaw.toFixed(1)}/5` : 'Not available',
changeType: hasCSATData
? (csatGlobalRaw >= 4.0 ? 'positive' : csatGlobalRaw >= 3.5 ? 'neutral' : 'negative')
: 'neutral'
},
icon: Smile,
};
return dimension;
}
// ==== Economy - Cost per Interaction (v3.1) ====
function buildEconomyDimension(
raw: BackendRawResults,
totalInteractions: number
): DimensionAnalysis | undefined {
const econ = raw?.economy_costs;
const op = raw?.operational_performance;
const totalAnnual = safeNumber(econ?.cost_breakdown?.total_annual, 0);
// Airline CPI benchmark (consistent with ExecutiveSummaryTab)
// p25: 2.20, p50: 3.50, p75: 4.50, p90: 5.50
const CPI_BENCHMARK = 3.50; // airline p50
if (totalAnnual <= 0 || totalInteractions <= 0) {
return undefined;
}
// Calcular cost_volume (non-abandoned) para consistencia con Executive Summary
const abandonmentRate = safeNumber(op?.abandonment_rate, 0) / 100;
const costVolume = Math.round(totalInteractions * (1 - abandonmentRate));
// Calcular CPI usando cost_volume (non-abandoned) como denominador
const cpi = costVolume > 0 ? totalAnnual / costVolume : totalAnnual / totalInteractions;
// Score based on airline percentiles (inverse CPI: lower = better)
// CPI <= 2.20 (p25) = 100pts (excellent, top 25%)
// CPI 2.20-3.50 (p25-p50) = 80pts (bueno, top 50%)
// CPI 3.50-4.50 (p50-p75) = 60pts (average)
// CPI 4.50-5.50 (p75-p90) = 40pts (por debajo)
// CPI > 5.50 (>p90) = 20pts (critical)
let score: number;
if (cpi <= 2.20) {
score = 100;
} else if (cpi <= 3.50) {
score = 80;
} else if (cpi <= 4.50) {
score = 60;
} else if (cpi <= 5.50) {
score = 40;
} else {
score = 20;
}
const cpiDiff = cpi - CPI_BENCHMARK;
const cpiStatus = cpiDiff <= 0 ? 'positive' : cpiDiff <= 0.5 ? 'neutral' : 'negative';
let summary = `Cost per interaction: €${cpi.toFixed(2)} vs benchmark €${CPI_BENCHMARK.toFixed(2)}. `;
if (cpi <= CPI_BENCHMARK) {
summary += 'Optimal cost efficiency, below sector benchmark.';
} else if (cpi <= 4.50) {
summary += 'Cost slightly above benchmark, optimization opportunity.';
} else {
summary += 'High cost relative to sector. Prioritize efficiency initiatives.';
}
const dimension: DimensionAnalysis = {
id: 'economy_costs',
name: 'economy_costs',
title: 'Economy & Costs',
score,
percentile: undefined,
summary,
kpi: {
label: 'Cost per Interaction',
value: `${cpi.toFixed(2)}`,
change: `vs benchmark €${CPI_BENCHMARK.toFixed(2)}`,
changeType: cpiStatus as 'positive' | 'neutral' | 'negative'
},
icon: DollarSign,
};
return dimension;
}
// ==== Agentic Readiness as a dimension (v3.0) ====
function buildAgenticReadinessDimension(
raw: BackendRawResults,
fallbackTier: TierKey
): DimensionAnalysis | undefined {
const ar = raw?.agentic_readiness?.agentic_readiness;
// Si no hay datos de backend, calculamos un score aproximado
const op = raw?.operational_performance;
const volumetry = raw?.volumetry;
let score0_10: number;
let category: string;
if (ar) {
score0_10 = safeNumber(ar.final_score, 5);
} else {
// Calculate approximation from available metrics
const ahtP50 = safeNumber(op?.aht_distribution?.p50, 0);
const ahtP90 = safeNumber(op?.aht_distribution?.p90, 0);
const ratio = ahtP50 > 0 ? ahtP90 / ahtP50 : 2;
const escalation = safeNumber(op?.escalation_rate, 15);
const skillVolumes = Array.isArray(volumetry?.volume_by_skill?.values)
? volumetry.volume_by_skill.values.map((v: any) => safeNumber(v, 0))
: [];
const totalVolume = skillVolumes.reduce((a: number, b: number) => a + b, 0);
// Calcular sub-scores
const predictability = Math.max(0, Math.min(10, 10 - (ratio - 1) * 5));
const complexityInverse = Math.max(0, Math.min(10, 10 - escalation / 5));
const repetitivity = Math.min(10, totalVolume / 500);
score0_10 = predictability * 0.30 + complexityInverse * 0.30 + repetitivity * 0.25 + 2.5; // base offset
}
const score0_100 = Math.max(0, Math.min(100, Math.round(score0_10 * 10)));
if (score0_10 >= 8) {
category = 'Automatizar';
} else if (score0_10 >= 5) {
category = 'Asistir (Copilot)';
} else {
category = 'Optimizar primero';
}
let summary = `Score global: ${score0_10.toFixed(1)}/10. Categoría: ${category}. `;
if (score0_10 >= 8) {
summary += 'Excelente candidato para automatización completa con agentes IA.';
} else if (score0_10 >= 5) {
summary += 'Candidato para asistencia con IA (copilot) o automatización parcial.';
} else {
summary += 'Requiere optimización de procesos antes de automatizar.';
}
const kpi: Kpi = {
label: 'Score Global',
value: `${score0_10.toFixed(1)}/10`,
};
const dimension: DimensionAnalysis = {
id: 'agentic_readiness',
name: 'agentic_readiness',
title: 'Agentic Readiness',
score: score0_100,
percentile: undefined,
summary,
kpi,
icon: Bot,
};
return dimension;
}
// ==== Economy and costs (economy_costs) ====
function buildEconomicModel(raw: BackendRawResults): EconomicModelData {
const econ = raw?.economy_costs;
const cost = econ?.cost_breakdown || {};
const totalAnnual = safeNumber(cost.total_annual, 0);
const laborAnnual = safeNumber(cost.labor_annual, 0);
const overheadAnnual = safeNumber(cost.overhead_annual, 0);
const techAnnual = safeNumber(cost.tech_annual, 0);
const potential = econ?.potential_savings || {};
const annualSavings = safeNumber(potential.annual_savings, 0);
const currentAnnualCost =
totalAnnual || laborAnnual + overheadAnnual + techAnnual || 0;
const futureAnnualCost = currentAnnualCost - annualSavings;
let initialInvestment = 0;
let paybackMonths = 0;
let roi3yr = 0;
if (annualSavings > 0 && currentAnnualCost > 0) {
initialInvestment = Math.round(currentAnnualCost * 0.15);
paybackMonths = Math.ceil(
(initialInvestment / annualSavings) * 12
);
roi3yr =
((annualSavings * 3 - initialInvestment) /
initialInvestment) *
100;
}
const savingsBreakdown = annualSavings
? [
{
category: 'Operational inefficiencies (AHT, escalations)',
amount: Math.round(annualSavings * 0.5),
percentage: 50,
},
{
category: 'Automation of repetitive volume',
amount: Math.round(annualSavings * 0.3),
percentage: 30,
},
{
category: 'Other benefits (quality, CX)',
amount: Math.round(annualSavings * 0.2),
percentage: 20,
},
]
: [];
const costBreakdown = currentAnnualCost
? [
{
category: 'Labor cost',
amount: laborAnnual,
percentage: Math.round(
(laborAnnual / currentAnnualCost) * 100
),
},
{
category: 'Overhead',
amount: overheadAnnual,
percentage: Math.round(
(overheadAnnual / currentAnnualCost) * 100
),
},
{
category: 'Technology',
amount: techAnnual,
percentage: Math.round(
(techAnnual / currentAnnualCost) * 100
),
},
]
: [];
return {
currentAnnualCost,
futureAnnualCost,
annualSavings,
initialInvestment,
paybackMonths,
roi3yr: parseFloat(roi3yr.toFixed(1)),
savingsBreakdown,
npv: 0,
costBreakdown,
};
}
// buildEconomyDimension removed in v3.0 - economy integrated into other dimensions and economic model
/**
* Transforma el JSON del backend (results) al AnalysisData
* que espera el frontend.
*/
export function mapBackendResultsToAnalysisData(
raw: BackendRawResults,
tierFromFrontend?: TierKey
): AnalysisData {
const volumetry = raw?.volumetry;
const volumeByChannel = volumetry?.volume_by_channel;
const volumeBySkill = volumetry?.volume_by_skill;
const channelValues: number[] = Array.isArray(volumeByChannel?.values)
? volumeByChannel.values.map((v: any) => safeNumber(v, 0))
: [];
const skillValues: number[] = Array.isArray(volumeBySkill?.values)
? volumeBySkill.values.map((v: any) => safeNumber(v, 0))
: [];
const totalVolumeChannels = channelValues.reduce((a, b) => a + b, 0);
const totalVolumeSkills = skillValues.reduce((a, b) => a + b, 0);
const totalVolume =
totalVolumeChannels || totalVolumeSkills || 0;
const numChannels = Array.isArray(volumeByChannel?.labels)
? volumeByChannel.labels.length
: 0;
const numSkills = Array.isArray(volumeBySkill?.labels)
? volumeBySkill.labels.length
: 0;
// Agentic readiness
const agenticReadiness = mapAgenticReadiness(
raw,
tierFromFrontend || 'silver'
);
const arScore = agenticReadiness?.score ?? 5;
const overallHealthScore = Math.max(
0,
Math.min(100, Math.round(arScore * 10))
);
// v3.3: 7 dimensions (Complexity recovered with Hold Time metric >60s)
const { dimension: volumetryDimension, extraKpis } =
buildVolumetryDimension(raw);
const operationalEfficiencyDimension = buildOperationalEfficiencyDimension(raw);
const effectivenessResolutionDimension = buildEffectivenessResolutionDimension(raw);
const complexityDimension = buildComplexityPredictabilityDimension(raw);
const satisfactionDimension = buildSatisfactionDimension(raw);
const economyDimension = buildEconomyDimension(raw, totalVolume);
const agenticReadinessDimension = buildAgenticReadinessDimension(raw, tierFromFrontend || 'silver');
const dimensions: DimensionAnalysis[] = [];
if (volumetryDimension) dimensions.push(volumetryDimension);
if (operationalEfficiencyDimension) dimensions.push(operationalEfficiencyDimension);
if (effectivenessResolutionDimension) dimensions.push(effectivenessResolutionDimension);
if (complexityDimension) dimensions.push(complexityDimension);
if (satisfactionDimension) dimensions.push(satisfactionDimension);
if (economyDimension) dimensions.push(economyDimension);
if (agenticReadinessDimension) dimensions.push(agenticReadinessDimension);
const op = raw?.operational_performance;
const cs = raw?.customer_satisfaction;
// FCR: viene ya como porcentaje 0-100
const fcrPctRaw = safeNumber(op?.fcr_rate, NaN);
const fcrPct =
Number.isFinite(fcrPctRaw) && fcrPctRaw >= 0
? Math.min(100, Math.max(0, fcrPctRaw))
: undefined;
const csatAvg = computeCsatAverage(cs);
// Global CSAT (opcional)
const csatGlobalRaw = safeNumber(cs?.csat_global, NaN);
const csatGlobal =
Number.isFinite(csatGlobalRaw) && csatGlobalRaw > 0
? csatGlobalRaw
: undefined;
// Summary KPIs (the first 4 are shown in "Contact Metrics")
const summaryKpis: Kpi[] = [];
// 1) Total Interactions (backend volume)
summaryKpis.push({
label: 'Total Interactions',
value:
totalVolume > 0
? totalVolume.toLocaleString('es-ES')
: 'N/D',
});
// 2) Average AHT (P50 of AHT distribution)
const ahtP50 = safeNumber(op?.aht_distribution?.p50, 0);
summaryKpis.push({
label: 'Average AHT',
value: ahtP50
? `${Math.round(ahtP50)}s`
: 'N/D',
});
// 3) FCR Rate
summaryKpis.push({
label: 'FCR Rate',
value:
fcrPct !== undefined
? `${Math.round(fcrPct)}%`
: 'N/D',
});
// 4) CSAT
summaryKpis.push({
label: 'CSAT',
value:
csatGlobal !== undefined
? `${csatGlobal.toFixed(1)}/5`
: 'N/D',
});
// --- Additional KPIs, used in other sections ---
if (numChannels > 0) {
summaryKpis.push({
label: 'Channels analyzed',
value: String(numChannels),
});
}
if (numSkills > 0) {
summaryKpis.push({
label: 'Skills analyzed',
value: String(numSkills),
});
}
summaryKpis.push({
label: 'Agentic readiness',
value: `${arScore.toFixed(1)}/10`,
});
// Economy KPIs (backend)
const econ = raw?.economy_costs;
const totalAnnual = safeNumber(
econ?.cost_breakdown?.total_annual,
0
);
const annualSavings = safeNumber(
econ?.potential_savings?.annual_savings,
0
);
if (totalAnnual) {
summaryKpis.push({
label: 'Current annual cost (backend)',
value: `${totalAnnual.toFixed(0)}`,
});
}
if (annualSavings) {
summaryKpis.push({
label: 'Annual potential savings (backend)',
value: `${annualSavings.toFixed(0)}`,
});
}
const mergedKpis: Kpi[] = [...summaryKpis, ...extraKpis];
const economicModel = buildEconomicModel(raw);
const benchmarkData = buildBenchmarkData(raw);
// Generate findings and recommendations based on volumetry
const findings: Finding[] = [];
const recommendations: Recommendation[] = [];
// Extract offHoursPct from the volumetry dimension
const offHoursPct = volumetryDimension?.distribution_data?.off_hours_pct ?? 0;
const offHoursPctValue = offHoursPct * 100; // Convert from 0-1 a 0-100
if (offHoursPctValue > 20) {
const offHoursVolume = Math.round(totalVolume * offHoursPctValue / 100);
findings.push({
type: offHoursPctValue > 30 ? 'critical' : 'warning',
title: 'High Off-Hours Volume',
text: `${offHoursPctValue.toFixed(0)}% of off-hours interactions (8-19h)`,
dimensionId: 'volumetry_distribution',
description: `${offHoursVolume.toLocaleString()} interacciones (${offHoursPctValue.toFixed(1)}%) ocurren outside business hours. Ideal opportunity to implement 24/7 virtual agents.`,
impact: offHoursPctValue > 30 ? 'high' : 'medium'
});
const estimatedContainment = offHoursPctValue > 30 ? 60 : 45;
const estimatedSavings = Math.round(offHoursVolume * estimatedContainment / 100);
recommendations.push({
priority: 'high',
title: 'Implement 24/7 Virtual Agent',
text: `Deploy virtual agent to handle ${offHoursPctValue.toFixed(0)}% of off-hours interactions`,
description: `${offHoursVolume.toLocaleString()} interactions occur outside business hours (19:00-08:00). A virtual agent can resolve ~${estimatedContainment}% of these queries automatically.`,
dimensionId: 'volumetry_distribution',
impact: `Containment potential: ${estimatedSavings.toLocaleString()} interactions/period`,
timeline: '1-3 months'
});
}
return {
tier: tierFromFrontend,
overallHealthScore,
summaryKpis: mergedKpis,
dimensions,
heatmapData: [], // skill heatmap still generated on frontend
findings,
recommendations,
opportunities: [],
roadmap: [],
economicModel,
benchmarkData,
agenticReadiness,
staticConfig: undefined,
source: 'backend',
};
}
export function buildHeatmapFromBackend(
raw: BackendRawResults,
costPerHour: number,
avgCsat: number,
segmentMapping?: {
high_value_queues: string[];
medium_value_queues: string[];
low_value_queues: string[];
}
): HeatmapDataPoint[] {
const volumetry = raw?.volumetry;
const volumeBySkill = volumetry?.volume_by_skill;
const rawSkillLabels =
volumeBySkill?.labels ??
volumeBySkill?.skills ??
volumeBySkill?.skill_names ??
[];
const skillLabels: string[] = Array.isArray(rawSkillLabels)
? rawSkillLabels.map((s: any) => String(s))
: [];
const skillVolumes: number[] = Array.isArray(volumeBySkill?.values)
? volumeBySkill.values.map((v: any) => safeNumber(v, 0))
: [];
const op = raw?.operational_performance;
const econ = raw?.economy_costs;
const cs = raw?.customer_satisfaction;
const talkHoldAcwBySkillRaw = Array.isArray(
op?.talk_hold_acw_p50_by_skill
)
? op.talk_hold_acw_p50_by_skill
: [];
// Crear lookup map por skill name para talk_hold_acw_p50
const talkHoldAcwMap = new Map<string, { talk_p50: number; hold_p50: number; acw_p50: number }>();
for (const item of talkHoldAcwBySkillRaw) {
if (item?.queue_skill) {
talkHoldAcwMap.set(String(item.queue_skill), {
talk_p50: safeNumber(item.talk_p50, 0),
hold_p50: safeNumber(item.hold_p50, 0),
acw_p50: safeNumber(item.acw_p50, 0),
});
}
}
const globalEscalation = safeNumber(op?.escalation_rate, 0);
// Usar fcr_rate del backend si existe, sino calcular como 100 - escalation
const fcrRateBackend = safeNumber(op?.fcr_rate, NaN);
const globalFcrPct = Number.isFinite(fcrRateBackend) && fcrRateBackend >= 0
? Math.max(0, Math.min(100, fcrRateBackend))
: Math.max(0, Math.min(100, 100 - globalEscalation));
// Usar abandonment_rate del backend si existe
const abandonmentRateBackend = safeNumber(op?.abandonment_rate, 0);
// ========================================================================
// NEW: REAL metrics per skill (transfer, abandonment, FCR)
// This eliminates the transfer rate estimation based on CV and hold time
// ========================================================================
const metricsBySkillRaw = Array.isArray(op?.metrics_by_skill)
? op.metrics_by_skill
: [];
// Crear lookup por nombre de skill para acceso O(1)
const metricsBySkillMap = new Map<string, {
transfer_rate: number;
abandonment_rate: number;
fcr_tecnico: number;
fcr_real: number;
aht_mean: number; // Average AHT del backend (only VALID - consistent with fresh path)
aht_total: number; // Total AHT (ALL rows incluyendo NOISE/ZOMBIE/ABANDON) - informational only
hold_time_mean: number; // Average Hold time (consistent with fresh path - MEAN, not P50)
}>();
for (const m of metricsBySkillRaw) {
if (m?.skill) {
metricsBySkillMap.set(String(m.skill), {
transfer_rate: safeNumber(m.transfer_rate, NaN),
abandonment_rate: safeNumber(m.abandonment_rate, NaN),
fcr_tecnico: safeNumber(m.fcr_tecnico, NaN),
fcr_real: safeNumber(m.fcr_real, NaN),
aht_mean: safeNumber(m.aht_mean, NaN), // Average AHT (solo VALID)
aht_total: safeNumber(m.aht_total, NaN), // Total AHT (ALL rows)
hold_time_mean: safeNumber(m.hold_time_mean, NaN), // Average Hold time (MEAN)
});
}
}
const hasRealMetricsBySkill = metricsBySkillMap.size > 0;
if (hasRealMetricsBySkill) {
console.log('✅ Usando métricas REALES por skill del backend:', metricsBySkillMap.size, 'skills');
} else {
console.warn('⚠️ No hay metrics_by_skill del backend, usando estimación basada en CV/hold');
}
// ========================================================================
// NUEVO: CPI por skill desde cpi_by_skill_channel
// Esto permite que el cached path tenga CPI real como el fresh path
// ========================================================================
const cpiBySkillRaw = Array.isArray(econ?.cpi_by_skill_channel)
? econ.cpi_by_skill_channel
: [];
// Crear lookup por nombre de skill para CPI
const cpiBySkillMap = new Map<string, number>();
for (const item of cpiBySkillRaw) {
if (item?.queue_skill || item?.skill) {
const skillKey = String(item.queue_skill ?? item.skill);
const cpiValue = safeNumber(item.cpi_total ?? item.cpi, NaN);
if (Number.isFinite(cpiValue)) {
cpiBySkillMap.set(skillKey, cpiValue);
}
}
}
const hasCpiBySkill = cpiBySkillMap.size > 0;
if (hasCpiBySkill) {
console.log('✅ Usando CPI por skill del backend:', cpiBySkillMap.size, 'skills');
}
const csatGlobalRaw = safeNumber(cs?.csat_global, NaN);
const csatGlobal =
Number.isFinite(csatGlobalRaw) && csatGlobalRaw > 0
? csatGlobalRaw
: undefined;
const csatMetric0_100 = csatGlobal
? Math.max(
0,
Math.min(100, Math.round((csatGlobal / 5) * 100))
)
: 0;
const ineffBySkillRaw = Array.isArray(
econ?.inefficiency_cost_by_skill_channel
)
? econ.inefficiency_cost_by_skill_channel
: [];
// Crear lookup map por skill name para inefficiency data
const ineffBySkillMap = new Map<string, { aht_p50: number; aht_p90: number; volume: number }>();
for (const item of ineffBySkillRaw) {
if (item?.queue_skill) {
ineffBySkillMap.set(String(item.queue_skill), {
aht_p50: safeNumber(item.aht_p50, 0),
aht_p90: safeNumber(item.aht_p90, 0),
volume: safeNumber(item.volume, 0),
});
}
}
const COST_PER_SECOND = costPerHour / 3600;
if (!skillLabels.length) return [];
// To normalize repetitiveness according to volume
const volumesForNorm = skillVolumes.filter((v) => v > 0);
const minVol =
volumesForNorm.length > 0
? Math.min(...volumesForNorm)
: 0;
const maxVol =
volumesForNorm.length > 0
? Math.max(...volumesForNorm)
: 0;
const heatmap: HeatmapDataPoint[] = [];
for (let i = 0; i < skillLabels.length; i++) {
const skill = skillLabels[i];
const volume = safeNumber(skillVolumes[i], 0);
// Search for P50s by skill name (not by index)
const talkHold = talkHoldAcwMap.get(skill);
const talk_p50 = talkHold?.talk_p50 ?? 0;
const hold_p50 = talkHold?.hold_p50 ?? 0;
const acw_p50 = talkHold?.acw_p50 ?? 0;
// Search for REAL metrics from backend (metrics_by_skill)
const realSkillMetrics = metricsBySkillMap.get(skill);
// AHT: Use ONLY aht_mean from backend metrics_by_skill
// NEVER use P50 sum as fallback - it's mathematically different from mean AHT
const aht_mean = (realSkillMetrics && Number.isFinite(realSkillMetrics.aht_mean) && realSkillMetrics.aht_mean > 0)
? realSkillMetrics.aht_mean
: 0;
// AHT Total: AHT calculado con TODAS las filas (incluye NOISE/ZOMBIE/ABANDON)
// Only for information/comparison - not used in calculations
const aht_total = (realSkillMetrics && Number.isFinite(realSkillMetrics.aht_total) && realSkillMetrics.aht_total > 0)
? realSkillMetrics.aht_total
: aht_mean; // fallback to aht_mean if not available
if (aht_mean === 0) {
console.warn(`⚠️ No aht_mean for skill ${skill} - data may be incomplete`);
}
// Coste anual aproximado
const annual_volume = volume * 12;
const annual_cost = Math.round(
annual_volume * aht_mean * COST_PER_SECOND
);
// Search for inefficiency data by skill name (not by index)
const ineff = ineffBySkillMap.get(skill);
const aht_p50_backend = ineff?.aht_p50 ?? aht_mean;
const aht_p90_backend = ineff?.aht_p90 ?? aht_mean;
// Variabilidad proxy: aproximamos CV a partir de P90-P50
let cv_aht = 0;
if (aht_p50_backend > 0) {
cv_aht =
(aht_p90_backend - aht_p50_backend) / aht_p50_backend;
}
// Agentic dimensions similar to those you had in generateHeatmapData,
// pero usando valores reales en lugar de aleatorios.
// 1) Predictability (lower CV => higher score)
const predictability_score = Math.max(
0,
Math.min(
10,
10 - ((cv_aht - 0.3) / 1.2) * 10
)
);
// 2) Transfer rate POR SKILL
// PRIORITY 1: Use REAL metrics from backend (metrics_by_skill)
// PRIORITY 2: Fallback to estimation based on CV and hold time
let skillTransferRate: number;
let skillAbandonmentRate: number;
let skillFcrTecnico: number;
let skillFcrReal: number;
if (realSkillMetrics && Number.isFinite(realSkillMetrics.transfer_rate)) {
// Use REAL metrics from backend
skillTransferRate = realSkillMetrics.transfer_rate;
skillAbandonmentRate = Number.isFinite(realSkillMetrics.abandonment_rate)
? realSkillMetrics.abandonment_rate
: abandonmentRateBackend;
skillFcrTecnico = Number.isFinite(realSkillMetrics.fcr_tecnico)
? realSkillMetrics.fcr_tecnico
: 100 - skillTransferRate;
skillFcrReal = Number.isFinite(realSkillMetrics.fcr_real)
? realSkillMetrics.fcr_real
: skillFcrTecnico;
} else {
// NO usar estimación - usar valores globales del backend directamente
// Esto asegura consistencia con el fresh path que usa valores directos del CSV
skillTransferRate = globalEscalation; // Use global rate, no estimation
skillAbandonmentRate = abandonmentRateBackend;
skillFcrTecnico = 100 - skillTransferRate;
skillFcrReal = globalFcrPct;
console.warn(`⚠️ No metrics_by_skill for skill ${skill} - using global rates`);
}
// Inverse complexity based on skill transfer rate
const complexity_inverse_score = Math.max(
0,
Math.min(
10,
10 - ((skillTransferRate / 100 - 0.05) / 0.25) * 10
)
);
// 3) Repetitividad (según volumen relativo)
let repetitivity_score = 5;
if (maxVol > minVol && volume > 0) {
repetitivity_score =
((volume - minVol) / (maxVol - minVol)) * 10;
} else if (volume === 0) {
repetitivity_score = 0;
}
const agentic_readiness_score =
predictability_score * 0.4 +
complexity_inverse_score * 0.35 +
repetitivity_score * 0.25;
let readiness_category:
| 'automate_now'
| 'assist_copilot'
| 'optimize_first';
if (agentic_readiness_score >= 8.0) {
readiness_category = 'automate_now';
} else if (agentic_readiness_score >= 5.0) {
readiness_category = 'assist_copilot';
} else {
readiness_category = 'optimize_first';
}
const automation_readiness = Math.round(
agentic_readiness_score * 10
); // 0-100
// Métricas normalizadas 0-100 para el color del heatmap
const ahtMetric = normalizeAhtMetric(aht_mean);
// Hold time metric: use hold_time_mean from backend (MEAN, not P50)
// Formula matches fresh path: 100 - (hold_time_mean / 60) * 10
// This gives: 0s = 100, 60s = 90, 120s = 80, etc.
const skillHoldTimeMean = (realSkillMetrics && Number.isFinite(realSkillMetrics.hold_time_mean))
? realSkillMetrics.hold_time_mean
: hold_p50; // Fallback to P50 only if no mean available
const holdMetric = skillHoldTimeMean > 0
? Math.round(Math.max(0, Math.min(100, 100 - (skillHoldTimeMean / 60) * 10)))
: 0;
// Clasificación por segmento (si nos pasan mapeo)
let segment: CustomerSegment | undefined;
if (segmentMapping) {
const normalizedSkill = skill.toLowerCase();
if (
segmentMapping.high_value_queues.some((q) =>
normalizedSkill.includes(q.toLowerCase())
)
) {
segment = 'high';
} else if (
segmentMapping.low_value_queues.some((q) =>
normalizedSkill.includes(q.toLowerCase())
)
) {
segment = 'low';
} else {
segment = 'medium';
}
}
// Métricas de transferencia y FCR (ahora usando valores REALES cuando disponibles)
const transferMetricFinal = Math.max(0, Math.min(100, Math.round(skillTransferRate)));
// CPI should be extracted from cpi_by_skill_channel using cpi_total field
const skillCpiRaw = cpiBySkillMap.get(skill);
// Only use if it's a valid number
const skillCpi = (Number.isFinite(skillCpiRaw) && skillCpiRaw > 0) ? skillCpiRaw : undefined;
// cost_volume: volumen sin abandonos (para cálculo de CPI consistente)
// Si tenemos abandonment_rate, restamos los abandonos
const costVolume = Math.round(volume * (1 - skillAbandonmentRate / 100));
heatmap.push({
skill,
segment,
volume,
cost_volume: costVolume,
aht_seconds: aht_mean,
aht_total: aht_total, // AHT con TODAS las filas (informational only)
metrics: {
fcr: Math.round(skillFcrReal), // FCR Real (sin transfer Y sin recontacto 7d)
fcr_tecnico: Math.round(skillFcrTecnico), // Technical FCR (comparable con benchmarks)
aht: ahtMetric,
csat: csatMetric0_100,
hold_time: holdMetric,
transfer_rate: transferMetricFinal,
abandonment_rate: Math.round(skillAbandonmentRate),
},
annual_cost,
cpi: skillCpi, // Real CPI from backend (if available)
variability: {
cv_aht: Math.round(cv_aht * 100), // %
cv_talk_time: 0,
cv_hold_time: 0,
transfer_rate: skillTransferRate, // REAL or estimated transfer rate
},
automation_readiness,
dimensions: {
predictability: Math.round(predictability_score * 10) / 10,
complexity_inverse:
Math.round(complexity_inverse_score * 10) / 10,
repetitivity: Math.round(repetitivity_score * 10) / 10,
},
readiness_category,
});
}
console.log('📊 Heatmap backend generado:', {
length: heatmap.length,
firstItem: heatmap[0],
});
return heatmap;
}
// ==== Benchmark Data (Sector Aéreo) ====
function buildBenchmarkData(raw: BackendRawResults): AnalysisData['benchmarkData'] {
const op = raw?.operational_performance;
const cs = raw?.customer_satisfaction;
const benchmarkData: AnalysisData['benchmarkData'] = [];
// Hardcoded benchmarks for airline sector
const AIRLINE_BENCHMARKS = {
aht_p50: 380, // seconds
fcr: 70, // % (rango 68-72%)
abandonment: 5, // % (rango 5-8%)
ratio_p90_p50: 2.0, // ratio saludable
cpi: 5.25 // € (rango €4.50-€6.00)
};
// 1. AHT Promedio (benchmark airline sector: 380s)
const ahtP50 = safeNumber(op?.aht_distribution?.p50, 0);
if (ahtP50 > 0) {
// Percentile: lower AHT = better. If AHT <= benchmark = P75+
const ahtPercentile = ahtP50 <= AIRLINE_BENCHMARKS.aht_p50
? Math.min(90, 75 + Math.round((AIRLINE_BENCHMARKS.aht_p50 - ahtP50) / 10))
: Math.max(10, 75 - Math.round((ahtP50 - AIRLINE_BENCHMARKS.aht_p50) / 5));
benchmarkData.push({
kpi: 'AHT P50',
userValue: Math.round(ahtP50),
userDisplay: `${Math.round(ahtP50)}s`,
industryValue: AIRLINE_BENCHMARKS.aht_p50,
industryDisplay: `${AIRLINE_BENCHMARKS.aht_p50}s`,
percentile: ahtPercentile,
p25: 450,
p50: AIRLINE_BENCHMARKS.aht_p50,
p75: 320,
p90: 280
});
}
// 2. FCR Rate (benchmark airline sector: 70%)
const fcrRate = safeNumber(op?.fcr_rate, NaN);
if (Number.isFinite(fcrRate) && fcrRate >= 0) {
// Percentile: higher FCR = better
const fcrPercentile = fcrRate >= AIRLINE_BENCHMARKS.fcr
? Math.min(90, 50 + Math.round((fcrRate - AIRLINE_BENCHMARKS.fcr) * 2))
: Math.max(10, 50 - Math.round((AIRLINE_BENCHMARKS.fcr - fcrRate) * 2));
benchmarkData.push({
kpi: 'FCR Rate',
userValue: fcrRate / 100,
userDisplay: `${Math.round(fcrRate)}%`,
industryValue: AIRLINE_BENCHMARKS.fcr / 100,
industryDisplay: `${AIRLINE_BENCHMARKS.fcr}%`,
percentile: fcrPercentile,
p25: 0.60,
p50: AIRLINE_BENCHMARKS.fcr / 100,
p75: 0.78,
p90: 0.85
});
}
// 3. CSAT (si disponible)
const csatGlobal = safeNumber(cs?.csat_global, NaN);
if (Number.isFinite(csatGlobal) && csatGlobal > 0) {
const csatPercentile = Math.max(10, Math.min(90, Math.round((csatGlobal / 5) * 100)));
benchmarkData.push({
kpi: 'CSAT',
userValue: csatGlobal,
userDisplay: `${csatGlobal.toFixed(1)}/5`,
industryValue: 4.0,
industryDisplay: '4.0/5',
percentile: csatPercentile,
p25: 3.5,
p50: 4.0,
p75: 4.3,
p90: 4.6
});
}
// 4. Abandonment Rate (benchmark airline sector: 5%)
const abandonRate = safeNumber(op?.abandonment_rate, NaN);
if (Number.isFinite(abandonRate) && abandonRate >= 0) {
// Percentile: lower abandonment = better
const abandonPercentile = abandonRate <= AIRLINE_BENCHMARKS.abandonment
? Math.min(90, 75 + Math.round((AIRLINE_BENCHMARKS.abandonment - abandonRate) * 5))
: Math.max(10, 75 - Math.round((abandonRate - AIRLINE_BENCHMARKS.abandonment) * 5));
benchmarkData.push({
kpi: 'Abandonment Rate',
userValue: abandonRate / 100,
userDisplay: `${abandonRate.toFixed(1)}%`,
industryValue: AIRLINE_BENCHMARKS.abandonment / 100,
industryDisplay: `${AIRLINE_BENCHMARKS.abandonment}%`,
percentile: abandonPercentile,
p25: 0.08,
p50: AIRLINE_BENCHMARKS.abandonment / 100,
p75: 0.03,
p90: 0.02
});
}
// 5. Ratio P90/P50 (benchmark airline sector: <2.0)
const ahtP90 = safeNumber(op?.aht_distribution?.p90, 0);
const ratio = ahtP50 > 0 && ahtP90 > 0 ? ahtP90 / ahtP50 : 0;
if (ratio > 0) {
// Percentile: lower ratio = better
const ratioPercentile = ratio <= AIRLINE_BENCHMARKS.ratio_p90_p50
? Math.min(90, 75 + Math.round((AIRLINE_BENCHMARKS.ratio_p90_p50 - ratio) * 30))
: Math.max(10, 75 - Math.round((ratio - AIRLINE_BENCHMARKS.ratio_p90_p50) * 30));
benchmarkData.push({
kpi: 'Ratio P90/P50',
userValue: ratio,
userDisplay: ratio.toFixed(2),
industryValue: AIRLINE_BENCHMARKS.ratio_p90_p50,
industryDisplay: `<${AIRLINE_BENCHMARKS.ratio_p90_p50}`,
percentile: ratioPercentile,
p25: 2.5,
p50: AIRLINE_BENCHMARKS.ratio_p90_p50,
p75: 1.5,
p90: 1.3
});
}
// 6. Transfer/Escalation Rate
const escalationRate = safeNumber(op?.escalation_rate, NaN);
if (Number.isFinite(escalationRate) && escalationRate >= 0) {
// Menor escalación = better percentil
const escalationPercentile = Math.max(10, Math.min(90, Math.round(100 - escalationRate * 5)));
benchmarkData.push({
kpi: 'Transfer Rate',
userValue: escalationRate / 100,
userDisplay: `${escalationRate.toFixed(1)}%`,
industryValue: 0.15,
industryDisplay: '15%',
percentile: escalationPercentile,
p25: 0.20,
p50: 0.15,
p75: 0.10,
p90: 0.08
});
}
// 7. CPI - Cost per Interaction (benchmark airline sector: €4.50-€6.00)
const econ = raw?.economy_costs;
const totalAnnualCost = safeNumber(econ?.cost_breakdown?.total_annual, 0);
const volumetry = raw?.volumetry;
const volumeBySkill = volumetry?.volume_by_skill;
const skillVolumes: number[] = Array.isArray(volumeBySkill?.values)
? volumeBySkill.values.map((v: any) => safeNumber(v, 0))
: [];
const totalInteractions = skillVolumes.reduce((a, b) => a + b, 0);
if (totalAnnualCost > 0 && totalInteractions > 0) {
const cpi = totalAnnualCost / totalInteractions;
// Lower CPI = better. If CPI <= 4.50 = excellent (P90+), if CPI >= 6.00 = poor (P25-)
let cpiPercentile: number;
if (cpi <= 4.50) {
cpiPercentile = Math.min(95, 90 + Math.round((4.50 - cpi) * 10));
} else if (cpi <= AIRLINE_BENCHMARKS.cpi) {
cpiPercentile = Math.round(50 + ((AIRLINE_BENCHMARKS.cpi - cpi) / 0.75) * 40);
} else if (cpi <= 6.00) {
cpiPercentile = Math.round(25 + ((6.00 - cpi) / 0.75) * 25);
} else {
cpiPercentile = Math.max(5, 25 - Math.round((cpi - 6.00) * 10));
}
benchmarkData.push({
kpi: 'Cost per Interaction (CPI)',
userValue: cpi,
userDisplay: `${cpi.toFixed(2)}`,
industryValue: AIRLINE_BENCHMARKS.cpi,
industryDisplay: `${AIRLINE_BENCHMARKS.cpi.toFixed(2)}`,
percentile: cpiPercentile,
p25: 6.00,
p50: AIRLINE_BENCHMARKS.cpi,
p75: 4.50,
p90: 3.80
});
}
return benchmarkData;
}
function computeCsatAverage(customerSatisfaction: any): number | undefined {
const arr = customerSatisfaction?.csat_avg_by_skill_channel;
if (!Array.isArray(arr) || !arr.length) return undefined;
const values: number[] = arr
.map((item: any) =>
safeNumber(
item?.csat ??
item?.value ??
item?.score,
NaN
)
)
.filter((v) => Number.isFinite(v));
if (!values.length) return undefined;
const sum = values.reduce((a, b) => a + b, 0);
return sum / values.length;
}