Initial commit: frontend + backend integration
This commit is contained in:
403
frontend/utils/agenticReadinessV2.ts
Normal file
403
frontend/utils/agenticReadinessV2.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* Agentic Readiness Score v2.0
|
||||
* Algoritmo basado en metodología de 6 dimensiones con normalización continua
|
||||
*/
|
||||
|
||||
import type { TierKey, SubFactor, AgenticReadinessResult, CustomerSegment } from '../types';
|
||||
import { AGENTIC_READINESS_WEIGHTS, AGENTIC_READINESS_THRESHOLDS } from '../constants';
|
||||
|
||||
export interface AgenticReadinessInput {
|
||||
// Datos básicos (SILVER)
|
||||
volumen_mes: number;
|
||||
aht_values: number[];
|
||||
escalation_rate: number;
|
||||
cpi_humano: number;
|
||||
volumen_anual: number;
|
||||
|
||||
// Datos avanzados (GOLD)
|
||||
structured_fields_pct?: number;
|
||||
exception_rate?: number;
|
||||
hourly_distribution?: number[];
|
||||
off_hours_pct?: number;
|
||||
csat_values?: number[];
|
||||
motivo_contacto_entropy?: number;
|
||||
resolucion_entropy?: number;
|
||||
|
||||
// Tier
|
||||
tier: TierKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* SUB-FACTOR 1: REPETITIVIDAD (25%)
|
||||
* Basado en volumen mensual con normalización logística
|
||||
*/
|
||||
function calculateRepetitividadScore(volumen_mes: number): SubFactor {
|
||||
const { k, x0 } = AGENTIC_READINESS_THRESHOLDS.repetitividad;
|
||||
|
||||
// Función logística: score = 10 / (1 + exp(-k * (volumen - x0)))
|
||||
const score = 10 / (1 + Math.exp(-k * (volumen_mes - x0)));
|
||||
|
||||
return {
|
||||
name: 'repetitividad',
|
||||
displayName: 'Repetitividad',
|
||||
score: Math.round(score * 10) / 10,
|
||||
weight: AGENTIC_READINESS_WEIGHTS.repetitividad,
|
||||
description: `Volumen mensual: ${volumen_mes} interacciones`,
|
||||
details: {
|
||||
volumen_mes,
|
||||
threshold_medio: x0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SUB-FACTOR 2: PREDICTIBILIDAD (20%)
|
||||
* Basado en variabilidad AHT + tasa de escalación + variabilidad input/output
|
||||
*/
|
||||
function calculatePredictibilidadScore(
|
||||
aht_values: number[],
|
||||
escalation_rate: number,
|
||||
motivo_contacto_entropy?: number,
|
||||
resolucion_entropy?: number
|
||||
): SubFactor {
|
||||
const thresholds = AGENTIC_READINESS_THRESHOLDS.predictibilidad;
|
||||
|
||||
// 1. VARIABILIDAD AHT (40%)
|
||||
const aht_mean = aht_values.reduce((a, b) => a + b, 0) / aht_values.length;
|
||||
const aht_variance = aht_values.reduce((sum, val) => sum + Math.pow(val - aht_mean, 2), 0) / aht_values.length;
|
||||
const aht_std = Math.sqrt(aht_variance);
|
||||
const cv_aht = aht_std / aht_mean;
|
||||
|
||||
// Normalizar CV a escala 0-10
|
||||
const score_aht = Math.max(0, Math.min(10,
|
||||
10 * (1 - (cv_aht - thresholds.cv_aht_excellent) / (thresholds.cv_aht_poor - thresholds.cv_aht_excellent))
|
||||
));
|
||||
|
||||
// 2. TASA DE ESCALACIÓN (30%)
|
||||
const score_escalacion = Math.max(0, Math.min(10,
|
||||
10 * (1 - escalation_rate / thresholds.escalation_poor)
|
||||
));
|
||||
|
||||
// 3. VARIABILIDAD INPUT/OUTPUT (30%)
|
||||
let score_variabilidad: number;
|
||||
if (motivo_contacto_entropy !== undefined && resolucion_entropy !== undefined) {
|
||||
// Alta entropía input + Baja entropía output = BUENA para automatización
|
||||
const input_normalized = Math.min(motivo_contacto_entropy / 3.0, 1.0);
|
||||
const output_normalized = Math.min(resolucion_entropy / 3.0, 1.0);
|
||||
score_variabilidad = 10 * (input_normalized * (1 - output_normalized));
|
||||
} else {
|
||||
// Si no hay datos de entropía, usar promedio de AHT y escalación
|
||||
score_variabilidad = (score_aht + score_escalacion) / 2;
|
||||
}
|
||||
|
||||
// PONDERACIÓN FINAL
|
||||
const predictibilidad = (
|
||||
0.40 * score_aht +
|
||||
0.30 * score_escalacion +
|
||||
0.30 * score_variabilidad
|
||||
);
|
||||
|
||||
return {
|
||||
name: 'predictibilidad',
|
||||
displayName: 'Predictibilidad',
|
||||
score: Math.round(predictibilidad * 10) / 10,
|
||||
weight: AGENTIC_READINESS_WEIGHTS.predictibilidad,
|
||||
description: `CV AHT: ${(cv_aht * 100).toFixed(1)}%, Escalación: ${(escalation_rate * 100).toFixed(1)}%`,
|
||||
details: {
|
||||
cv_aht: Math.round(cv_aht * 1000) / 1000,
|
||||
escalation_rate,
|
||||
score_aht: Math.round(score_aht * 10) / 10,
|
||||
score_escalacion: Math.round(score_escalacion * 10) / 10,
|
||||
score_variabilidad: Math.round(score_variabilidad * 10) / 10
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SUB-FACTOR 3: ESTRUCTURACIÓN (15%)
|
||||
* Porcentaje de campos estructurados vs texto libre
|
||||
*/
|
||||
function calculateEstructuracionScore(structured_fields_pct: number): SubFactor {
|
||||
const score = structured_fields_pct * 10;
|
||||
|
||||
return {
|
||||
name: 'estructuracion',
|
||||
displayName: 'Estructuración',
|
||||
score: Math.round(score * 10) / 10,
|
||||
weight: AGENTIC_READINESS_WEIGHTS.estructuracion,
|
||||
description: `${(structured_fields_pct * 100).toFixed(0)}% de campos estructurados`,
|
||||
details: {
|
||||
structured_fields_pct
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SUB-FACTOR 4: COMPLEJIDAD INVERSA (15%)
|
||||
* Basado en tasa de excepciones
|
||||
*/
|
||||
function calculateComplejidadInversaScore(exception_rate: number): SubFactor {
|
||||
// Menor tasa de excepciones → Mayor score
|
||||
// < 5% → Excelente (score 10)
|
||||
// > 30% → Muy complejo (score 0)
|
||||
const score_excepciones = Math.max(0, Math.min(10, 10 * (1 - exception_rate / 0.30)));
|
||||
|
||||
return {
|
||||
name: 'complejidad_inversa',
|
||||
displayName: 'Complejidad Inversa',
|
||||
score: Math.round(score_excepciones * 10) / 10,
|
||||
weight: AGENTIC_READINESS_WEIGHTS.complejidad_inversa,
|
||||
description: `${(exception_rate * 100).toFixed(1)}% de excepciones`,
|
||||
details: {
|
||||
exception_rate
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SUB-FACTOR 5: ESTABILIDAD (10%)
|
||||
* Basado en distribución horaria y % llamadas fuera de horas
|
||||
*/
|
||||
function calculateEstabilidadScore(
|
||||
hourly_distribution: number[],
|
||||
off_hours_pct: number
|
||||
): SubFactor {
|
||||
// 1. UNIFORMIDAD DISTRIBUCIÓN HORARIA (60%)
|
||||
// Calcular entropía de Shannon
|
||||
const total = hourly_distribution.reduce((a, b) => a + b, 0);
|
||||
let score_uniformidad = 0;
|
||||
let entropy_normalized = 0;
|
||||
|
||||
if (total > 0) {
|
||||
const probs = hourly_distribution.map(v => v / total).filter(p => p > 0);
|
||||
const entropy = -probs.reduce((sum, p) => sum + p * Math.log2(p), 0);
|
||||
const max_entropy = Math.log2(hourly_distribution.length);
|
||||
entropy_normalized = entropy / max_entropy;
|
||||
score_uniformidad = entropy_normalized * 10;
|
||||
}
|
||||
|
||||
// 2. % LLAMADAS FUERA DE HORAS (40%)
|
||||
// Más llamadas fuera de horas → Mayor necesidad agentes → Mayor score
|
||||
const score_off_hours = Math.min(10, (off_hours_pct / 0.30) * 10);
|
||||
|
||||
// PONDERACIÓN
|
||||
const estabilidad = (
|
||||
0.60 * score_uniformidad +
|
||||
0.40 * score_off_hours
|
||||
);
|
||||
|
||||
return {
|
||||
name: 'estabilidad',
|
||||
displayName: 'Estabilidad',
|
||||
score: Math.round(estabilidad * 10) / 10,
|
||||
weight: AGENTIC_READINESS_WEIGHTS.estabilidad,
|
||||
description: `${(off_hours_pct * 100).toFixed(1)}% fuera de horario`,
|
||||
details: {
|
||||
entropy_normalized: Math.round(entropy_normalized * 1000) / 1000,
|
||||
off_hours_pct,
|
||||
score_uniformidad: Math.round(score_uniformidad * 10) / 10,
|
||||
score_off_hours: Math.round(score_off_hours * 10) / 10
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SUB-FACTOR 6: ROI (15%)
|
||||
* Basado en ahorro potencial anual
|
||||
*/
|
||||
function calculateROIScore(
|
||||
volumen_anual: number,
|
||||
cpi_humano: number,
|
||||
automation_savings_pct: number = 0.70
|
||||
): SubFactor {
|
||||
const ahorro_anual = volumen_anual * cpi_humano * automation_savings_pct;
|
||||
|
||||
// Normalización logística
|
||||
const { k, x0 } = AGENTIC_READINESS_THRESHOLDS.roi;
|
||||
const score = 10 / (1 + Math.exp(-k * (ahorro_anual - x0)));
|
||||
|
||||
return {
|
||||
name: 'roi',
|
||||
displayName: 'ROI',
|
||||
score: Math.round(score * 10) / 10,
|
||||
weight: AGENTIC_READINESS_WEIGHTS.roi,
|
||||
description: `€${(ahorro_anual / 1000).toFixed(0)}K ahorro potencial anual`,
|
||||
details: {
|
||||
ahorro_anual: Math.round(ahorro_anual),
|
||||
volumen_anual,
|
||||
cpi_humano,
|
||||
automation_savings_pct
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* AJUSTE POR DISTRIBUCIÓN CSAT (Opcional, ±10%)
|
||||
* Distribución normal → Proceso estable
|
||||
*/
|
||||
function calculateCSATDistributionAdjustment(csat_values: number[]): number {
|
||||
// Test de normalidad simplificado (basado en skewness y kurtosis)
|
||||
const n = csat_values.length;
|
||||
const mean = csat_values.reduce((a, b) => a + b, 0) / n;
|
||||
const variance = csat_values.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / n;
|
||||
const std = Math.sqrt(variance);
|
||||
|
||||
// Skewness
|
||||
const skewness = csat_values.reduce((sum, val) => sum + Math.pow((val - mean) / std, 3), 0) / n;
|
||||
|
||||
// Kurtosis
|
||||
const kurtosis = csat_values.reduce((sum, val) => sum + Math.pow((val - mean) / std, 4), 0) / n;
|
||||
|
||||
// Normalidad: skewness cercano a 0, kurtosis cercano a 3
|
||||
const skewness_score = Math.max(0, 1 - Math.abs(skewness));
|
||||
const kurtosis_score = Math.max(0, 1 - Math.abs(kurtosis - 3) / 3);
|
||||
const normality_score = (skewness_score + kurtosis_score) / 2;
|
||||
|
||||
// Ajuste: +5% si muy normal, -5% si muy anormal
|
||||
const adjustment = 1 + ((normality_score - 0.5) * 0.10);
|
||||
|
||||
return adjustment;
|
||||
}
|
||||
|
||||
/**
|
||||
* ALGORITMO COMPLETO (Tier GOLD)
|
||||
*/
|
||||
export function calculateAgenticReadinessScoreGold(data: AgenticReadinessInput): AgenticReadinessResult {
|
||||
const sub_factors: SubFactor[] = [];
|
||||
|
||||
// 1. REPETITIVIDAD
|
||||
sub_factors.push(calculateRepetitividadScore(data.volumen_mes));
|
||||
|
||||
// 2. PREDICTIBILIDAD
|
||||
sub_factors.push(calculatePredictibilidadScore(
|
||||
data.aht_values,
|
||||
data.escalation_rate,
|
||||
data.motivo_contacto_entropy,
|
||||
data.resolucion_entropy
|
||||
));
|
||||
|
||||
// 3. ESTRUCTURACIÓN
|
||||
sub_factors.push(calculateEstructuracionScore(data.structured_fields_pct || 0.5));
|
||||
|
||||
// 4. COMPLEJIDAD INVERSA
|
||||
sub_factors.push(calculateComplejidadInversaScore(data.exception_rate || 0.15));
|
||||
|
||||
// 5. ESTABILIDAD
|
||||
sub_factors.push(calculateEstabilidadScore(
|
||||
data.hourly_distribution || Array(24).fill(1),
|
||||
data.off_hours_pct || 0.2
|
||||
));
|
||||
|
||||
// 6. ROI
|
||||
sub_factors.push(calculateROIScore(
|
||||
data.volumen_anual,
|
||||
data.cpi_humano
|
||||
));
|
||||
|
||||
// PONDERACIÓN BASE
|
||||
const agentic_readiness_base = sub_factors.reduce(
|
||||
(sum, factor) => sum + (factor.score * factor.weight),
|
||||
0
|
||||
);
|
||||
|
||||
// AJUSTE POR DISTRIBUCIÓN CSAT (Opcional)
|
||||
let agentic_readiness_final = agentic_readiness_base;
|
||||
if (data.csat_values && data.csat_values.length > 10) {
|
||||
const adjustment = calculateCSATDistributionAdjustment(data.csat_values);
|
||||
agentic_readiness_final = agentic_readiness_base * adjustment;
|
||||
}
|
||||
|
||||
// Limitar a rango 0-10
|
||||
agentic_readiness_final = Math.max(0, Math.min(10, agentic_readiness_final));
|
||||
|
||||
// Interpretación
|
||||
let interpretation = '';
|
||||
let confidence: 'high' | 'medium' | 'low' = 'high';
|
||||
|
||||
if (agentic_readiness_final >= 8) {
|
||||
interpretation = 'Excelente candidato para automatización completa (Automate)';
|
||||
} else if (agentic_readiness_final >= 5) {
|
||||
interpretation = 'Buen candidato para asistencia agéntica (Assist)';
|
||||
} else if (agentic_readiness_final >= 3) {
|
||||
interpretation = 'Candidato para augmentación humana (Augment)';
|
||||
} else {
|
||||
interpretation = 'No recomendado para automatización en este momento';
|
||||
}
|
||||
|
||||
return {
|
||||
score: Math.round(agentic_readiness_final * 10) / 10,
|
||||
sub_factors,
|
||||
tier: 'gold',
|
||||
confidence,
|
||||
interpretation
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ALGORITMO SIMPLIFICADO (Tier SILVER)
|
||||
*/
|
||||
export function calculateAgenticReadinessScoreSilver(data: AgenticReadinessInput): AgenticReadinessResult {
|
||||
const sub_factors: SubFactor[] = [];
|
||||
|
||||
// 1. REPETITIVIDAD (30%)
|
||||
const repetitividad = calculateRepetitividadScore(data.volumen_mes);
|
||||
repetitividad.weight = 0.30;
|
||||
sub_factors.push(repetitividad);
|
||||
|
||||
// 2. PREDICTIBILIDAD SIMPLIFICADA (30%)
|
||||
const predictibilidad = calculatePredictibilidadScore(
|
||||
data.aht_values,
|
||||
data.escalation_rate
|
||||
);
|
||||
predictibilidad.weight = 0.30;
|
||||
sub_factors.push(predictibilidad);
|
||||
|
||||
// 3. ROI (40%)
|
||||
const roi = calculateROIScore(data.volumen_anual, data.cpi_humano);
|
||||
roi.weight = 0.40;
|
||||
sub_factors.push(roi);
|
||||
|
||||
// PONDERACIÓN SIMPLIFICADA
|
||||
const agentic_readiness = sub_factors.reduce(
|
||||
(sum, factor) => sum + (factor.score * factor.weight),
|
||||
0
|
||||
);
|
||||
|
||||
// Interpretación
|
||||
let interpretation = '';
|
||||
if (agentic_readiness >= 7) {
|
||||
interpretation = 'Buen candidato para automatización';
|
||||
} else if (agentic_readiness >= 4) {
|
||||
interpretation = 'Candidato para asistencia agéntica';
|
||||
} else {
|
||||
interpretation = 'Requiere análisis más profundo (considerar GOLD)';
|
||||
}
|
||||
|
||||
return {
|
||||
score: Math.round(agentic_readiness * 10) / 10,
|
||||
sub_factors,
|
||||
tier: 'silver',
|
||||
confidence: 'medium',
|
||||
interpretation
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* FUNCIÓN PRINCIPAL - Selecciona algoritmo según tier
|
||||
*/
|
||||
export function calculateAgenticReadinessScore(data: AgenticReadinessInput): AgenticReadinessResult {
|
||||
if (data.tier === 'gold') {
|
||||
return calculateAgenticReadinessScoreGold(data);
|
||||
} else if (data.tier === 'silver') {
|
||||
return calculateAgenticReadinessScoreSilver(data);
|
||||
} else {
|
||||
// BRONZE: Sin Agentic Readiness
|
||||
return {
|
||||
score: 0,
|
||||
sub_factors: [],
|
||||
tier: 'bronze',
|
||||
confidence: 'low',
|
||||
interpretation: 'Análisis Bronze no incluye Agentic Readiness Score'
|
||||
};
|
||||
}
|
||||
}
|
||||
739
frontend/utils/analysisGenerator.ts
Normal file
739
frontend/utils/analysisGenerator.ts
Normal file
@@ -0,0 +1,739 @@
|
||||
// analysisGenerator.ts - v2.0 con 6 dimensiones
|
||||
import type { AnalysisData, Kpi, DimensionAnalysis, HeatmapDataPoint, Opportunity, RoadmapInitiative, EconomicModelData, BenchmarkDataPoint, Finding, Recommendation, TierKey, CustomerSegment } from '../types';
|
||||
import { generateAnalysisFromRealData } from './realDataAnalysis';
|
||||
import { RoadmapPhase } from '../types';
|
||||
import { BarChartHorizontal, Zap, Smile, DollarSign, Target, Globe } from 'lucide-react';
|
||||
import { calculateAgenticReadinessScore, type AgenticReadinessInput } from './agenticReadinessV2';
|
||||
import { callAnalysisApiRaw } from './apiClient';
|
||||
import { mapBackendResultsToAnalysisData } from './backendMapper';
|
||||
|
||||
|
||||
const randomInt = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
const randomFloat = (min: number, max: number, decimals: number) => parseFloat((Math.random() * (max - min) + min).toFixed(decimals));
|
||||
const randomFromList = <T,>(arr: T[]): T => arr[Math.floor(Math.random() * arr.length)];
|
||||
|
||||
// Distribución normal (Box-Muller transform)
|
||||
const normalRandom = (mean: number, std: number): number => {
|
||||
const u1 = Math.random();
|
||||
const u2 = Math.random();
|
||||
const z0 = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
|
||||
return mean + std * z0;
|
||||
};
|
||||
|
||||
const getScoreColor = (score: number): 'green' | 'yellow' | 'red' => {
|
||||
if (score >= 80) return 'green';
|
||||
if (score >= 60) return 'yellow';
|
||||
return 'red';
|
||||
};
|
||||
|
||||
// v2.0: 6 DIMENSIONES (eliminadas Complejidad y Efectividad)
|
||||
const DIMENSIONS_CONTENT = {
|
||||
volumetry_distribution: {
|
||||
icon: BarChartHorizontal,
|
||||
titles: ["Volumetría y Distribución Horaria", "Análisis de la Demanda"],
|
||||
summaries: {
|
||||
good: ["El volumen de interacciones se alinea con las previsiones, permitiendo una planificación de personal precisa.", "La distribución horaria es uniforme con picos predecibles, facilitando la automatización."],
|
||||
medium: ["Existen picos de demanda imprevistos que generan caídas en el nivel de servicio.", "Alto porcentaje de interacciones fuera de horario laboral (>30%), sugiriendo necesidad de cobertura 24/7."],
|
||||
bad: ["Desajuste crónico entre el forecast y el volumen real, resultando en sobrecostes o mal servicio.", "Distribución horaria muy irregular con múltiples picos impredecibles."]
|
||||
},
|
||||
kpis: [
|
||||
{ label: "Volumen Mensual", value: `${randomInt(5000, 25000).toLocaleString('es-ES')}` },
|
||||
{ label: "% Fuera de Horario", value: `${randomInt(15, 45)}%` },
|
||||
],
|
||||
},
|
||||
performance: {
|
||||
icon: Zap,
|
||||
titles: ["Rendimiento Operativo", "Optimización de Tiempos"],
|
||||
summaries: {
|
||||
good: ["El AHT está bien controlado con baja variabilidad (CV<30%), indicando procesos estandarizados.", "Tiempos de espera y post-llamada (ACW) mínimos, maximizando la productividad del agente."],
|
||||
medium: ["El AHT es competitivo, pero la variabilidad es alta (CV>40%), sugiriendo inconsistencia en procesos.", "El tiempo en espera (Hold Time) es ligeramente elevado, sugiriendo posibles mejoras en el acceso a la información."],
|
||||
bad: ["El AHT excede los benchmarks de la industria con alta variabilidad, impactando directamente en los costes.", "Tiempos de ACW prolongados indican procesos manuales ineficientes o falta de integración de sistemas."]
|
||||
},
|
||||
kpis: [
|
||||
{ label: "AHT Promedio", value: `${randomInt(280, 550)}s` },
|
||||
{ label: "CV AHT", value: `${randomInt(25, 60)}%` },
|
||||
],
|
||||
},
|
||||
satisfaction: {
|
||||
icon: Smile,
|
||||
titles: ["Satisfacción y Experiencia", "Voz del Cliente"],
|
||||
summaries: {
|
||||
good: ["Puntuaciones de CSAT muy positivas con distribución normal, reflejando un proceso estable y consistente.", "El análisis cualitativo muestra un sentimiento mayoritariamente positivo en las interacciones."],
|
||||
medium: ["Los indicadores de satisfacción son neutros. La distribución de CSAT muestra cierta bimodalidad.", "Se observan comentarios mixtos, con puntos fuertes en la amabilidad del agente pero debilidades en los tiempos de respuesta."],
|
||||
bad: ["Bajas puntuaciones de CSAT con distribución anormal, indicando un proceso inconsistente.", "Los clientes se quejan frecuentemente de largos tiempos de espera, repetición de información y falta de resolución."]
|
||||
},
|
||||
kpis: [
|
||||
{ label: "CSAT Promedio", value: `${randomFloat(3.8, 4.9, 1)}/5` },
|
||||
{ label: "NPS", value: `${randomInt(-20, 55)}` },
|
||||
],
|
||||
},
|
||||
economy: {
|
||||
icon: DollarSign,
|
||||
titles: ["Economía y Costes", "Rentabilidad del Servicio"],
|
||||
summaries: {
|
||||
good: ["El coste por interacción está por debajo del promedio de la industria, indicando una operación rentable.", "El ROI potencial de automatización supera los €200K anuales con payback <12 meses."],
|
||||
medium: ["Los costes son estables pero no se observa una tendencia a la baja, sugiriendo un estancamiento en la optimización.", "El ROI potencial es moderado (€100-200K), requiriendo inversión inicial significativa."],
|
||||
bad: ["Coste por interacción elevado, erosionando los márgenes de beneficio de la compañía.", "Bajo ROI potencial (<€100K) debido a volumen insuficiente o procesos ya optimizados."]
|
||||
},
|
||||
kpis: [
|
||||
{ label: "Coste por Interacción", value: `€${randomFloat(2.5, 9.5, 2)}` },
|
||||
{ label: "Ahorro Potencial", value: `€${randomInt(50, 250)}K` },
|
||||
],
|
||||
},
|
||||
efficiency: {
|
||||
icon: Target,
|
||||
titles: ["Eficiencia", "Resolución y Calidad"],
|
||||
summaries: {
|
||||
good: ["Alta tasa de resolución en el primer contacto (FCR>85%), minimizando la repetición de llamadas.", "Bajo índice de transferencias y escalaciones (<10%), demostrando un correcto enrutamiento y alto conocimiento del agente."],
|
||||
medium: ["La tasa de FCR es aceptable (70-85%), aunque se detectan ciertos tipos de consulta que requieren múltiples contactos.", "Las transferencias son moderadas (10-20%), concentradas en departamentos específicos."],
|
||||
bad: ["Bajo FCR (<70%), lo que genera frustración en el cliente y aumenta el volumen de interacciones innecesarias.", "Excesivas transferencias y escalaciones (>20%), creando una experiencia de cliente fragmentada y costosa."]
|
||||
},
|
||||
kpis: [
|
||||
{ label: "Tasa FCR", value: `${randomInt(65, 92)}%` },
|
||||
{ label: "Tasa de Escalación", value: `${randomInt(5, 25)}%` },
|
||||
],
|
||||
},
|
||||
benchmark: {
|
||||
icon: Globe,
|
||||
titles: ["Benchmark de Industria", "Contexto Competitivo"],
|
||||
summaries: {
|
||||
good: ["La operación se sitúa consistentemente por encima del P75 en los KPIs más críticos.", "El rendimiento en eficiencia y calidad es de 'top quartile', representando una ventaja competitiva."],
|
||||
medium: ["El rendimiento general está en línea con la mediana de la industria (P50), sin claras fortalezas o debilidades.", "Se observan algunas áreas por debajo del P50 que representan oportunidades de mejora claras."],
|
||||
bad: ["La mayoría de los KPIs se encuentran por debajo del P25, indicando una necesidad urgente de mejora.", "El AHT y el CPI son significativamente más altos que los benchmarks, impactando la rentabilidad."]
|
||||
},
|
||||
kpis: [
|
||||
{ label: "Posición vs P50 AHT", value: `P${randomInt(30, 70)}` },
|
||||
{ label: "Posición vs P50 FCR", value: `P${randomInt(30, 70)}` },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const KEY_FINDINGS: Finding[] = [
|
||||
{
|
||||
text: "El canal de voz presenta un AHT un 35% superior al del chat, pero una tasa de resolución un 15% mayor.",
|
||||
dimensionId: 'performance',
|
||||
type: 'info',
|
||||
title: 'Diferencia de Canales: Voz vs Chat',
|
||||
description: 'Análisis comparativo entre canales muestra trade-off entre velocidad y resolución.',
|
||||
impact: 'medium'
|
||||
},
|
||||
{
|
||||
text: "Un 22% de las transferencias desde 'Soporte Técnico N1' hacia 'Facturación' son incorrectas.",
|
||||
dimensionId: 'efficiency',
|
||||
type: 'warning',
|
||||
title: 'Enrutamiento Incorrecto',
|
||||
description: 'Existe un problema de routing que genera ineficiencias y experiencia pobre del cliente.',
|
||||
impact: 'high'
|
||||
},
|
||||
{
|
||||
text: "El pico de demanda de los lunes por la mañana provoca una caída del Nivel de Servicio al 65%.",
|
||||
dimensionId: 'volumetry_distribution',
|
||||
type: 'critical',
|
||||
title: 'Crisis de Capacidad (Lunes por la mañana)',
|
||||
description: 'Los lunes 8-11h generan picos impredecibles que agotan la capacidad disponible.',
|
||||
impact: 'high'
|
||||
},
|
||||
{
|
||||
text: "El 28% de las interacciones ocurren fuera del horario laboral estándar (8-18h).",
|
||||
dimensionId: 'volumetry_distribution',
|
||||
type: 'info',
|
||||
title: 'Demanda Fuera de Horario',
|
||||
description: 'Casi 1 de 3 interacciones se produce fuera del horario laboral, requiriendo cobertura extendida.',
|
||||
impact: 'medium'
|
||||
},
|
||||
{
|
||||
text: "Las consultas sobre 'estado del pedido' representan el 30% de las interacciones y tienen alta repetitividad.",
|
||||
dimensionId: 'volumetry_distribution',
|
||||
type: 'info',
|
||||
title: 'Oportunidad de Automatización: Estado de Pedido',
|
||||
description: 'Volumen significativo en consultas altamente repetitivas y automatizables.',
|
||||
impact: 'high'
|
||||
},
|
||||
{
|
||||
text: "Baja puntuación de CSAT en interacciones relacionadas con problemas de facturación.",
|
||||
dimensionId: 'satisfaction',
|
||||
type: 'warning',
|
||||
title: 'Satisfacción Baja en Facturación',
|
||||
description: 'El equipo de facturación tiene desempeño por debajo de la media en satisfacción del cliente.',
|
||||
impact: 'high'
|
||||
},
|
||||
{
|
||||
text: "La variabilidad de AHT (CV=45%) sugiere procesos poco estandarizados.",
|
||||
dimensionId: 'performance',
|
||||
type: 'warning',
|
||||
title: 'Inconsistencia en Procesos',
|
||||
description: 'Alta variabilidad indica falta de estandarización y diferencias significativas entre agentes.',
|
||||
impact: 'medium'
|
||||
},
|
||||
];
|
||||
|
||||
const RECOMMENDATIONS: Recommendation[] = [
|
||||
{
|
||||
text: "Implementar un programa de formación específico para agentes de Facturación sobre los nuevos planes.",
|
||||
dimensionId: 'efficiency',
|
||||
priority: 'high',
|
||||
title: 'Formación en Facturación',
|
||||
description: 'Capacitación intensiva en productos, políticas y procedimientos de facturación.',
|
||||
impact: 'Mejora estimada de satisfacción: 15-25%',
|
||||
timeline: '2-3 semanas'
|
||||
},
|
||||
{
|
||||
text: "Desarrollar un bot de estado de pedido para WhatsApp para desviar el 30% de las consultas.",
|
||||
dimensionId: 'volumetry_distribution',
|
||||
priority: 'high',
|
||||
title: 'Bot Automatizado de Seguimiento de Pedidos',
|
||||
description: 'Implementar ChatBot en WhatsApp para responder consultas de estado de pedido automáticamente.',
|
||||
impact: 'Reducción de volumen: 20-30%, Ahorro anual: €40-60K',
|
||||
timeline: '1-2 meses'
|
||||
},
|
||||
{
|
||||
text: "Revisar la planificación de personal (WFM) para los lunes, añadiendo recursos flexibles.",
|
||||
dimensionId: 'volumetry_distribution',
|
||||
priority: 'high',
|
||||
title: 'Ajuste de Plantilla (WFM)',
|
||||
description: 'Reposicionar agentes y añadir recursos part-time para los lunes 8-11h.',
|
||||
impact: 'Mejora del NSL: +15-20%, Coste adicional: €5-8K/mes',
|
||||
timeline: '1 mes'
|
||||
},
|
||||
{
|
||||
text: "Crear una Knowledge Base más robusta y accesible para reducir el tiempo en espera.",
|
||||
dimensionId: 'performance',
|
||||
priority: 'high',
|
||||
title: 'Mejora de Acceso a Información',
|
||||
description: 'Desarrollar una KB centralizada integrada en el sistema de agentes con búsqueda inteligente.',
|
||||
impact: 'Reducción de AHT: 8-12%, Mejora de FCR: 5-10%',
|
||||
timeline: '6-8 semanas'
|
||||
},
|
||||
{
|
||||
text: "Implementar cobertura 24/7 con agentes virtuales para el 28% de interacciones fuera de horario.",
|
||||
dimensionId: 'volumetry_distribution',
|
||||
priority: 'medium',
|
||||
title: 'Cobertura 24/7 con IA',
|
||||
description: 'Desplegar agentes virtuales para gestionar el 28% de interacciones nocturnas.',
|
||||
impact: 'Captura de demanda: 20-25%, Coste incremental: €15-20K/mes',
|
||||
timeline: '2-3 meses'
|
||||
},
|
||||
{
|
||||
text: "Realizar un análisis de causa raíz sobre las quejas de facturación para mejorar procesos.",
|
||||
dimensionId: 'satisfaction',
|
||||
priority: 'medium',
|
||||
title: 'Análisis de Causa Raíz (Facturación)',
|
||||
description: 'Investigar las 50 últimas quejas de facturación para identificar patrones y causas.',
|
||||
impact: 'Identificación de mejoras de proceso con ROI potencial de €20-50K',
|
||||
timeline: '2-3 semanas'
|
||||
},
|
||||
];
|
||||
|
||||
const generateFindingsFromTemplates = (): Finding[] => {
|
||||
return [
|
||||
...new Set(
|
||||
Array.from({ length: 3 }, () => randomFromList(KEY_FINDINGS))
|
||||
),
|
||||
].map((finding, i): Finding => ({
|
||||
type: finding.type || (i === 0 ? 'warning' : 'info'),
|
||||
title: finding.title || 'Hallazgo',
|
||||
description: finding.description || finding.text,
|
||||
// campos obligatorios:
|
||||
text: finding.text || finding.description || 'Hallazgo relevante',
|
||||
dimensionId: finding.dimensionId || 'overall',
|
||||
impact: finding.impact,
|
||||
}));
|
||||
};
|
||||
|
||||
const generateRecommendationsFromTemplates = (): Recommendation[] => {
|
||||
return [
|
||||
...new Set(
|
||||
Array.from({ length: 3 }, () => randomFromList(RECOMMENDATIONS))
|
||||
),
|
||||
].map((rec, i): Recommendation => ({
|
||||
priority: rec.priority || (i === 0 ? 'high' : 'medium'),
|
||||
title: rec.title || 'Recomendación',
|
||||
description: rec.description || rec.text,
|
||||
impact: rec.impact || 'Mejora estimada del 20-30%',
|
||||
timeline: rec.timeline || '1-2 semanas',
|
||||
// campos obligatorios:
|
||||
text: rec.text || rec.description || 'Recomendación prioritaria',
|
||||
dimensionId: rec.dimensionId || 'overall',
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
// v2.0: Generar distribución horaria realista
|
||||
const generateHourlyDistribution = (): number[] => {
|
||||
// Distribución con picos en 9-11h y 14-17h
|
||||
const distribution = Array(24).fill(0).map((_, hour) => {
|
||||
if (hour >= 9 && hour <= 11) return randomInt(800, 1200); // Pico mañana
|
||||
if (hour >= 14 && hour <= 17) return randomInt(700, 1000); // Pico tarde
|
||||
if (hour >= 8 && hour <= 18) return randomInt(300, 600); // Horario laboral
|
||||
return randomInt(50, 200); // Fuera de horario
|
||||
});
|
||||
return distribution;
|
||||
};
|
||||
|
||||
// v2.0: Calcular % fuera de horario
|
||||
const calculateOffHoursPct = (hourly_distribution: number[]): number => {
|
||||
const total = hourly_distribution.reduce((a, b) => a + b, 0);
|
||||
if (total === 0) return 0; // Evitar división por cero
|
||||
const off_hours = hourly_distribution.slice(0, 8).reduce((a, b) => a + b, 0) +
|
||||
hourly_distribution.slice(19, 24).reduce((a, b) => a + b, 0);
|
||||
return off_hours / total;
|
||||
};
|
||||
|
||||
// v2.0: Identificar horas pico
|
||||
const identifyPeakHours = (hourly_distribution: number[]): number[] => {
|
||||
if (!hourly_distribution || hourly_distribution.length === 0) return [];
|
||||
const sorted = [...hourly_distribution].sort((a, b) => b - a);
|
||||
const threshold = sorted[Math.min(2, sorted.length - 1)] || 0; // Top 3 o máximo disponible
|
||||
return hourly_distribution
|
||||
.map((val, idx) => val >= threshold ? idx : -1)
|
||||
.filter(idx => idx !== -1);
|
||||
};
|
||||
|
||||
// v2.1: Generar heatmap con nueva lógica de transformación (3 dimensiones)
|
||||
const generateHeatmapData = (
|
||||
costPerHour: number = 20,
|
||||
avgCsat: number = 85,
|
||||
segmentMapping?: { high_value_queues: string[]; medium_value_queues: string[]; low_value_queues: string[] }
|
||||
): HeatmapDataPoint[] => {
|
||||
const skills = ['Ventas Inbound', 'Soporte Técnico N1', 'Facturación', 'Retención', 'VIP Support', 'Trial Support'];
|
||||
const COST_PER_SECOND = costPerHour / 3600;
|
||||
|
||||
return skills.map(skill => {
|
||||
const volume = randomInt(800, 5500); // Volumen mensual (ampliado para cubrir rango de repetitividad)
|
||||
|
||||
// Simular raw data: duration_talk, hold_time, wrap_up_time
|
||||
const avg_talk_time = randomInt(240, 450); // segundos
|
||||
const avg_hold_time = randomInt(15, 80); // segundos
|
||||
const avg_wrap_up = randomInt(10, 50); // segundos
|
||||
const aht_mean = avg_talk_time + avg_hold_time + avg_wrap_up; // AHT promedio
|
||||
|
||||
// Simular desviación estándar del AHT (para CV)
|
||||
const aht_std = randomInt(Math.round(aht_mean * 0.15), Math.round(aht_mean * 0.60)); // 15-60% del AHT
|
||||
const cv_aht = aht_std / aht_mean; // Coeficiente de Variación
|
||||
|
||||
// Transfer rate (para complejidad inversa)
|
||||
const transfer_rate = randomInt(5, 35); // %
|
||||
const fcr_approx = 100 - transfer_rate; // FCR aproximado
|
||||
|
||||
// Coste anual
|
||||
const annual_volume = volume * 12;
|
||||
const annual_cost = Math.round(annual_volume * aht_mean * COST_PER_SECOND);
|
||||
|
||||
// === NUEVA LÓGICA: 3 DIMENSIONES ===
|
||||
|
||||
// Dimensión 1: Predictibilidad (Proxy: CV del AHT)
|
||||
// Fórmula: MAX(0, MIN(10, 10 - ((CV - 0.3) / 1.2 * 10)))
|
||||
const predictability_score = Math.max(0, Math.min(10,
|
||||
10 - ((cv_aht - 0.3) / 1.2 * 10)
|
||||
));
|
||||
|
||||
// Dimensión 2: Complejidad Inversa (Proxy: Tasa de Transferencia)
|
||||
// Fórmula: MAX(0, MIN(10, 10 - ((T - 0.05) / 0.25 * 10)))
|
||||
const complexity_inverse_score = Math.max(0, Math.min(10,
|
||||
10 - ((transfer_rate / 100 - 0.05) / 0.25 * 10)
|
||||
));
|
||||
|
||||
// Dimensión 3: Repetitividad/Impacto (Proxy: Volumen)
|
||||
// > 5,000 = 10, < 100 = 0, interpolación lineal entre 100-5000
|
||||
let repetitivity_score: number;
|
||||
if (volume >= 5000) {
|
||||
repetitivity_score = 10;
|
||||
} else if (volume <= 100) {
|
||||
repetitivity_score = 0;
|
||||
} else {
|
||||
repetitivity_score = ((volume - 100) / (5000 - 100)) * 10;
|
||||
}
|
||||
|
||||
// Agentic Readiness Score (Promedio ponderado)
|
||||
// Pesos: Predictibilidad 40%, Complejidad 35%, Repetitividad 25%
|
||||
const agentic_readiness_score =
|
||||
predictability_score * 0.40 +
|
||||
complexity_inverse_score * 0.35 +
|
||||
repetitivity_score * 0.25;
|
||||
|
||||
// Categoría de readiness
|
||||
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); // Escala 0-100 para compatibilidad
|
||||
|
||||
// Clasificar segmento si hay 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';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
skill,
|
||||
segment,
|
||||
volume,
|
||||
aht_seconds: aht_mean, // Renombrado para compatibilidad
|
||||
metrics: {
|
||||
fcr: isNaN(fcr_approx) ? 0 : Math.max(0, Math.min(100, Math.round(fcr_approx))),
|
||||
aht: isNaN(aht_mean) ? 0 : Math.max(0, Math.min(100, Math.round(100 - ((aht_mean - 240) / 310) * 100))),
|
||||
csat: isNaN(avgCsat) ? 0 : Math.max(0, Math.min(100, Math.round(avgCsat))),
|
||||
hold_time: isNaN(avg_hold_time) ? 0 : Math.max(0, Math.min(100, Math.round(100 - (avg_hold_time / 120) * 100))),
|
||||
transfer_rate: isNaN(transfer_rate) ? 0 : Math.max(0, Math.min(100, Math.round(100 - (transfer_rate * 100))))
|
||||
},
|
||||
annual_cost,
|
||||
variability: {
|
||||
cv_aht: Math.round(cv_aht * 100), // Convertir a porcentaje
|
||||
cv_talk_time: 0, // Deprecado en v2.1
|
||||
cv_hold_time: 0, // Deprecado en v2.1
|
||||
transfer_rate
|
||||
},
|
||||
automation_readiness,
|
||||
// Nuevas dimensiones (v2.1)
|
||||
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
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// v2.0: Añadir segmentación de cliente
|
||||
const generateOpportunityMatrixData = (): Opportunity[] => {
|
||||
const opportunities = [
|
||||
{ id: 'opp1', name: 'Automatizar consulta de pedidos', savings: 85000, dimensionId: 'volumetry_distribution', customer_segment: 'medium' as CustomerSegment },
|
||||
{ id: 'opp2', name: 'Implementar Knowledge Base dinámica', savings: 45000, dimensionId: 'performance', customer_segment: 'high' as CustomerSegment },
|
||||
{ id: 'opp3', name: 'Chatbot de triaje inicial', savings: 120000, dimensionId: 'efficiency', customer_segment: 'medium' as CustomerSegment },
|
||||
{ id: 'opp4', name: 'Análisis de sentimiento en tiempo real', savings: 30000, dimensionId: 'satisfaction', customer_segment: 'high' as CustomerSegment },
|
||||
{ id: 'opp5', name: 'Cobertura 24/7 con agentes virtuales', savings: 65000, dimensionId: 'volumetry_distribution', customer_segment: 'low' as CustomerSegment },
|
||||
];
|
||||
return opportunities.map(opp => ({ ...opp, impact: randomInt(3, 10), feasibility: randomInt(2, 9) }));
|
||||
};
|
||||
|
||||
// v2.0: Añadir risk level
|
||||
const generateRoadmapData = (): RoadmapInitiative[] => {
|
||||
return [
|
||||
{ id: 'r1', name: 'Chatbot de estado de pedido', phase: RoadmapPhase.Automate, timeline: 'Q1 2025', investment: 25000, resources: ['1x Bot Developer', 'API Access'], dimensionId: 'volumetry_distribution', risk: 'low' },
|
||||
{ id: 'r2', name: 'Implementar Knowledge Base dinámica', phase: RoadmapPhase.Assist, timeline: 'Q1 2025', investment: 15000, resources: ['1x PM', 'Content Team'], dimensionId: 'performance', risk: 'low' },
|
||||
{ id: 'r3', name: 'Agent Assist para sugerencias en real-time', phase: RoadmapPhase.Assist, timeline: 'Q2 2025', investment: 45000, resources: ['2x AI Devs', 'QA Team'], dimensionId: 'efficiency', risk: 'medium' },
|
||||
{ id: 'r4', name: 'IVR conversacional con IA', phase: RoadmapPhase.Automate, timeline: 'Q3 2025', investment: 60000, resources: ['AI Voice Specialist', 'UX Designer'], dimensionId: 'efficiency', risk: 'medium' },
|
||||
{ id: 'r5', name: 'Cobertura 24/7 con agentes virtuales', phase: RoadmapPhase.Augment, timeline: 'Q4 2025', investment: 75000, resources: ['Lead AI Engineer', 'Data Scientist'], dimensionId: 'volumetry_distribution', risk: 'high' },
|
||||
];
|
||||
};
|
||||
|
||||
// v2.0: Añadir NPV y costBreakdown
|
||||
const generateEconomicModelData = (): EconomicModelData => {
|
||||
const currentAnnualCost = randomInt(800000, 2500000);
|
||||
const annualSavings = randomInt(150000, 500000);
|
||||
const futureAnnualCost = currentAnnualCost - annualSavings;
|
||||
const initialInvestment = randomInt(40000, 150000);
|
||||
const paybackMonths = Math.ceil((initialInvestment / annualSavings) * 12);
|
||||
const roi3yr = (((annualSavings * 3) - initialInvestment) / initialInvestment) * 100;
|
||||
|
||||
// NPV con tasa de descuento 10%
|
||||
const discountRate = 0.10;
|
||||
const npv = -initialInvestment +
|
||||
(annualSavings / (1 + discountRate)) +
|
||||
(annualSavings / Math.pow(1 + discountRate, 2)) +
|
||||
(annualSavings / Math.pow(1 + discountRate, 3));
|
||||
|
||||
const savingsBreakdown = [
|
||||
{ category: 'Automatización de tareas', amount: annualSavings * 0.45, percentage: 45 },
|
||||
{ category: 'Eficiencia operativa', amount: annualSavings * 0.30, percentage: 30 },
|
||||
{ category: 'Mejora FCR', amount: annualSavings * 0.15, percentage: 15 },
|
||||
{ category: 'Reducción attrition', amount: annualSavings * 0.075, percentage: 7.5 },
|
||||
{ category: 'Otros', amount: annualSavings * 0.025, percentage: 2.5 },
|
||||
];
|
||||
|
||||
const costBreakdown = [
|
||||
{ category: 'Software y licencias', amount: initialInvestment * 0.43, percentage: 43 },
|
||||
{ category: 'Implementación', amount: initialInvestment * 0.29, percentage: 29 },
|
||||
{ category: 'Training y change mgmt', amount: initialInvestment * 0.18, percentage: 18 },
|
||||
{ category: 'Contingencia', amount: initialInvestment * 0.10, percentage: 10 },
|
||||
];
|
||||
|
||||
return {
|
||||
currentAnnualCost,
|
||||
futureAnnualCost,
|
||||
annualSavings,
|
||||
initialInvestment,
|
||||
paybackMonths,
|
||||
roi3yr: parseFloat(roi3yr.toFixed(1)),
|
||||
npv: Math.round(npv),
|
||||
savingsBreakdown,
|
||||
costBreakdown
|
||||
};
|
||||
};
|
||||
|
||||
// v2.0: Añadir percentiles múltiples
|
||||
const generateBenchmarkData = (): BenchmarkDataPoint[] => {
|
||||
const userAHT = randomInt(380, 450);
|
||||
const industryAHT = 420;
|
||||
const userFCR = randomFloat(0.65, 0.78, 2);
|
||||
const industryFCR = 0.72;
|
||||
const userCSAT = randomFloat(4.1, 4.6, 1);
|
||||
const industryCSAT = 4.3;
|
||||
const userCPI = randomFloat(2.8, 4.5, 2);
|
||||
const industryCPI = 3.5;
|
||||
|
||||
return [
|
||||
{
|
||||
kpi: 'AHT Promedio',
|
||||
userValue: userAHT,
|
||||
userDisplay: `${userAHT}s`,
|
||||
industryValue: industryAHT,
|
||||
industryDisplay: `${industryAHT}s`,
|
||||
percentile: randomInt(40, 75),
|
||||
p25: 380,
|
||||
p50: 420,
|
||||
p75: 460,
|
||||
p90: 510
|
||||
},
|
||||
{
|
||||
kpi: 'Tasa FCR',
|
||||
userValue: userFCR,
|
||||
userDisplay: `${(userFCR * 100).toFixed(0)}%`,
|
||||
industryValue: industryFCR,
|
||||
industryDisplay: `${(industryFCR * 100).toFixed(0)}%`,
|
||||
percentile: randomInt(30, 65),
|
||||
p25: 0.65,
|
||||
p50: 0.72,
|
||||
p75: 0.82,
|
||||
p90: 0.88
|
||||
},
|
||||
{
|
||||
kpi: 'CSAT',
|
||||
userValue: userCSAT,
|
||||
userDisplay: `${userCSAT}/5`,
|
||||
industryValue: industryCSAT,
|
||||
industryDisplay: `${industryCSAT}/5`,
|
||||
percentile: randomInt(45, 80),
|
||||
p25: 4.0,
|
||||
p50: 4.3,
|
||||
p75: 4.6,
|
||||
p90: 4.8
|
||||
},
|
||||
{
|
||||
kpi: 'Coste por Interacción (Voz)',
|
||||
userValue: userCPI,
|
||||
userDisplay: `€${userCPI.toFixed(2)}`,
|
||||
industryValue: industryCPI,
|
||||
industryDisplay: `€${industryCPI.toFixed(2)}`,
|
||||
percentile: randomInt(50, 85),
|
||||
p25: 2.8,
|
||||
p50: 3.5,
|
||||
p75: 4.2,
|
||||
p90: 5.0
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const generateAnalysis = async (
|
||||
tier: TierKey,
|
||||
costPerHour: number = 20,
|
||||
avgCsat: number = 85,
|
||||
segmentMapping?: { high_value_queues: string[]; medium_value_queues: string[]; low_value_queues: string[] },
|
||||
file?: File,
|
||||
sheetUrl?: string,
|
||||
useSynthetic?: boolean
|
||||
): Promise<AnalysisData> => {
|
||||
// Si hay archivo, procesarlo
|
||||
// Si hay archivo, primero intentamos usar el backend
|
||||
if (file && !useSynthetic) {
|
||||
console.log('📡 Processing file (API first):', file.name);
|
||||
|
||||
// 1) Intentar backend + mapeo
|
||||
try {
|
||||
const raw = await callAnalysisApiRaw({
|
||||
tier,
|
||||
costPerHour,
|
||||
avgCsat,
|
||||
segmentMapping,
|
||||
file,
|
||||
});
|
||||
|
||||
const mapped = mapBackendResultsToAnalysisData(raw, tier);
|
||||
|
||||
// 👉 Rellenamos desde el frontend las partes que el backend aún no devuelve
|
||||
mapped.findings = generateFindingsFromTemplates();
|
||||
mapped.recommendations = generateRecommendationsFromTemplates();
|
||||
mapped.opportunities = generateOpportunityMatrixData();
|
||||
mapped.roadmap = generateRoadmapData();
|
||||
mapped.benchmarkData = generateBenchmarkData();
|
||||
mapped.heatmapData = generateHeatmapData(costPerHour, avgCsat, segmentMapping);
|
||||
|
||||
console.log('✅ Usando resultados del backend mapeados + findings/benchmark del frontend');
|
||||
return mapped;
|
||||
|
||||
} catch (apiError) {
|
||||
console.error(
|
||||
'❌ Backend /analysis no disponible o mapeo incompleto, fallback a lógica local:',
|
||||
apiError
|
||||
);
|
||||
}
|
||||
|
||||
// 2) Fallback completo: lógica antigua del frontend
|
||||
try {
|
||||
const { parseFile, validateInteractions } = await import('./fileParser');
|
||||
|
||||
const interactions = await parseFile(file);
|
||||
const validation = validateInteractions(interactions);
|
||||
|
||||
if (!validation.valid) {
|
||||
console.error('❌ Validation errors:', validation.errors);
|
||||
throw new Error(
|
||||
`Validación fallida: ${validation.errors.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
if (validation.warnings.length > 0) {
|
||||
console.warn('⚠️ Warnings:', validation.warnings);
|
||||
}
|
||||
|
||||
return generateAnalysisFromRealData(
|
||||
tier,
|
||||
interactions,
|
||||
costPerHour,
|
||||
avgCsat,
|
||||
segmentMapping
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('❌ Error processing file:', error);
|
||||
throw new Error(
|
||||
`Error procesando archivo: ${(error as Error).message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Si hay URL de Google Sheets, procesarla (TODO: implementar)
|
||||
if (sheetUrl && !useSynthetic) {
|
||||
console.warn('🔗 Google Sheets URL processing not implemented yet, using synthetic data');
|
||||
}
|
||||
|
||||
// Generar datos sintéticos (fallback)
|
||||
console.log('✨ Generating synthetic data');
|
||||
return generateSyntheticAnalysis(tier, costPerHour, avgCsat, segmentMapping);
|
||||
};
|
||||
|
||||
// Función auxiliar para generar análisis con datos sintéticos
|
||||
const generateSyntheticAnalysis = (
|
||||
tier: TierKey,
|
||||
costPerHour: number = 20,
|
||||
avgCsat: number = 85,
|
||||
segmentMapping?: { high_value_queues: string[]; medium_value_queues: string[]; low_value_queues: string[] }
|
||||
): AnalysisData => {
|
||||
const overallHealthScore = randomInt(55, 95);
|
||||
|
||||
const summaryKpis: Kpi[] = [
|
||||
{ label: "Interacciones Totales", value: randomInt(15000, 50000).toLocaleString('es-ES') },
|
||||
{ label: "AHT Promedio", value: `${randomInt(300, 480)}s`, change: `-${randomInt(5, 20)}s`, changeType: 'positive' },
|
||||
{ label: "Tasa FCR", value: `${randomInt(70, 88)}%`, change: `+${randomFloat(0.5, 2, 1)}%`, changeType: 'positive' },
|
||||
{ label: "CSAT", value: `${randomFloat(4.1, 4.8, 1)}/5`, change: `-${randomFloat(0.1, 0.3, 1)}`, changeType: 'negative' },
|
||||
];
|
||||
|
||||
// v2.0: Solo 6 dimensiones
|
||||
const dimensionKeys = ['volumetry_distribution', 'performance', 'satisfaction', 'economy', 'efficiency', 'benchmark'];
|
||||
|
||||
const dimensions: DimensionAnalysis[] = dimensionKeys.map(key => {
|
||||
const content = DIMENSIONS_CONTENT[key as keyof typeof DIMENSIONS_CONTENT];
|
||||
const score = randomInt(50, 98);
|
||||
const status = getScoreColor(score);
|
||||
|
||||
const dimension: DimensionAnalysis = {
|
||||
id: key,
|
||||
name: key as any,
|
||||
title: randomFromList(content.titles),
|
||||
score,
|
||||
percentile: randomInt(30, 85),
|
||||
summary: randomFromList(content.summaries[status === 'green' ? 'good' : status === 'yellow' ? 'medium' : 'bad']),
|
||||
kpi: randomFromList(content.kpis),
|
||||
icon: content.icon,
|
||||
};
|
||||
|
||||
// Añadir distribution_data para volumetry_distribution
|
||||
if (key === 'volumetry_distribution') {
|
||||
const hourly = generateHourlyDistribution();
|
||||
dimension.distribution_data = {
|
||||
hourly,
|
||||
off_hours_pct: calculateOffHoursPct(hourly),
|
||||
peak_hours: identifyPeakHours(hourly)
|
||||
};
|
||||
}
|
||||
|
||||
return dimension;
|
||||
});
|
||||
|
||||
// v2.0: Calcular Agentic Readiness Score
|
||||
let agenticReadiness = undefined;
|
||||
if (tier === 'gold' || tier === 'silver') {
|
||||
// Generar datos sintéticos para el algoritmo
|
||||
const volumen_mes = randomInt(5000, 25000);
|
||||
const aht_values = Array.from({ length: 100 }, () =>
|
||||
Math.max(180, normalRandom(420, 120)) // Media 420s, std 120s
|
||||
);
|
||||
const escalation_rate = randomFloat(0.05, 0.25, 2);
|
||||
const cpi_humano = randomFloat(2.5, 5.0, 2);
|
||||
const volumen_anual = volumen_mes * 12;
|
||||
|
||||
const agenticInput: AgenticReadinessInput = {
|
||||
volumen_mes,
|
||||
aht_values,
|
||||
escalation_rate,
|
||||
cpi_humano,
|
||||
volumen_anual,
|
||||
tier
|
||||
};
|
||||
|
||||
// Datos adicionales para GOLD
|
||||
if (tier === 'gold') {
|
||||
const hourly_distribution = dimensions.find(d => d.name === 'volumetry_distribution')?.distribution_data?.hourly;
|
||||
const off_hours_pct = dimensions.find(d => d.name === 'volumetry_distribution')?.distribution_data?.off_hours_pct;
|
||||
|
||||
agenticInput.structured_fields_pct = randomFloat(0.4, 0.9, 2);
|
||||
agenticInput.exception_rate = randomFloat(0.05, 0.25, 2);
|
||||
agenticInput.hourly_distribution = hourly_distribution;
|
||||
agenticInput.off_hours_pct = off_hours_pct;
|
||||
agenticInput.csat_values = Array.from({ length: 100 }, () =>
|
||||
Math.max(1, Math.min(5, normalRandom(4.3, 0.8)))
|
||||
);
|
||||
}
|
||||
|
||||
agenticReadiness = calculateAgenticReadinessScore(agenticInput);
|
||||
}
|
||||
|
||||
const heatmapData = generateHeatmapData(costPerHour, avgCsat, segmentMapping);
|
||||
|
||||
console.log('📊 Heatmap data generated:', {
|
||||
length: heatmapData.length,
|
||||
firstItem: heatmapData[0],
|
||||
metricsKeys: heatmapData[0] ? Object.keys(heatmapData[0].metrics) : [],
|
||||
metricsValues: heatmapData[0] ? heatmapData[0].metrics : {},
|
||||
hasNaN: heatmapData.some(item =>
|
||||
Object.values(item.metrics).some(v => isNaN(v))
|
||||
)
|
||||
});
|
||||
return {
|
||||
tier,
|
||||
overallHealthScore,
|
||||
summaryKpis,
|
||||
dimensions,
|
||||
heatmapData,
|
||||
agenticReadiness,
|
||||
findings: generateFindingsFromTemplates(),
|
||||
recommendations: generateRecommendationsFromTemplates(),
|
||||
opportunities: generateOpportunityMatrixData(),
|
||||
economicModel: generateEconomicModelData(),
|
||||
roadmap: generateRoadmapData(),
|
||||
benchmarkData: generateBenchmarkData(),
|
||||
};
|
||||
};
|
||||
|
||||
103
frontend/utils/apiClient.ts
Normal file
103
frontend/utils/apiClient.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
// utils/apiClient.ts
|
||||
import type { TierKey } from '../types';
|
||||
|
||||
type SegmentMapping = {
|
||||
high_value_queues: string[];
|
||||
medium_value_queues: string[];
|
||||
low_value_queues: string[];
|
||||
};
|
||||
|
||||
const API_BASE_URL =
|
||||
import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
|
||||
|
||||
function getAuthHeader(): Record<string, string> {
|
||||
const user = import.meta.env.VITE_API_USERNAME;
|
||||
const pass = import.meta.env.VITE_API_PASSWORD;
|
||||
|
||||
if (!user || !pass) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const token = btoa(`${user}:${pass}`);
|
||||
return {
|
||||
Authorization: `Basic ${token}`,
|
||||
};
|
||||
}
|
||||
|
||||
// JSON exactamente como lo devuelve el backend en `results`
|
||||
export type BackendRawResults = any;
|
||||
|
||||
/**
|
||||
* Llama al endpoint /analysis y devuelve `results` tal cual.
|
||||
*/
|
||||
export async function callAnalysisApiRaw(params: {
|
||||
tier: TierKey;
|
||||
costPerHour: number;
|
||||
avgCsat: number;
|
||||
segmentMapping?: SegmentMapping;
|
||||
file: File;
|
||||
}): Promise<BackendRawResults> {
|
||||
const { costPerHour, segmentMapping, file } = params;
|
||||
|
||||
if (!file) {
|
||||
throw new Error('No se ha proporcionado ningún archivo CSV');
|
||||
}
|
||||
|
||||
const economyData: any = {
|
||||
labor_cost_per_hour: costPerHour,
|
||||
};
|
||||
|
||||
if (segmentMapping) {
|
||||
const customer_segments: Record<string, string> = {};
|
||||
|
||||
for (const q of segmentMapping.high_value_queues || []) {
|
||||
customer_segments[q] = 'high';
|
||||
}
|
||||
for (const q of segmentMapping.medium_value_queues || []) {
|
||||
customer_segments[q] = 'medium';
|
||||
}
|
||||
for (const q of segmentMapping.low_value_queues || []) {
|
||||
customer_segments[q] = 'low';
|
||||
}
|
||||
|
||||
if (Object.keys(customer_segments).length > 0) {
|
||||
economyData.customer_segments = customer_segments;
|
||||
}
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('csv_file', file);
|
||||
formData.append('analysis', 'premium');
|
||||
|
||||
if (Object.keys(economyData).length > 0) {
|
||||
formData.append('economy_json', JSON.stringify(economyData));
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/analysis`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
...getAuthHeader(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorText = `Error API (${response.status})`;
|
||||
try {
|
||||
const errorBody = await response.json();
|
||||
if (errorBody?.detail) {
|
||||
errorText = String(errorBody.detail);
|
||||
}
|
||||
} catch {
|
||||
// ignoramos si no es JSON
|
||||
}
|
||||
throw new Error(errorText);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const rawResults = data.results ?? data;
|
||||
|
||||
console.debug('🔍 Backend /analysis raw results:', rawResults);
|
||||
|
||||
return rawResults;
|
||||
}
|
||||
653
frontend/utils/backendMapper.ts
Normal file
653
frontend/utils/backendMapper.ts
Normal file
@@ -0,0 +1,653 @@
|
||||
// utils/backendMapper.ts
|
||||
import type {
|
||||
AnalysisData,
|
||||
AgenticReadinessResult,
|
||||
SubFactor,
|
||||
TierKey,
|
||||
DimensionAnalysis,
|
||||
Kpi,
|
||||
EconomicModelData,
|
||||
} from '../types';
|
||||
import type { BackendRawResults } from './apiClient';
|
||||
import { BarChartHorizontal, Zap, DollarSign } from 'lucide-react';
|
||||
|
||||
function safeNumber(value: any, fallback = 0): number {
|
||||
const n = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
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 para distribución horaria (desde 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 calculado a partir de KPIs agregados.',
|
||||
details: value?.details || {},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const tier = inferTierFromScore(score) || fallbackTier;
|
||||
|
||||
const interpretation =
|
||||
classification?.description ||
|
||||
`Puntuación de preparación agentic: ${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,
|
||||
};
|
||||
}
|
||||
|
||||
// ==== Volumetría (dimensión + 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 -> distribución horaria
|
||||
const heatmap24x7 = volumetry?.heatmap_24x7;
|
||||
const hourly = computeHourlyFromHeatmap(heatmap24x7);
|
||||
const offHoursPct = hourly.length ? calcOffHoursPct(hourly) : 0;
|
||||
const peakHours = hourly.length ? findPeakHours(hourly) : [];
|
||||
|
||||
console.log('📊 Volumetría backend (mapper):', {
|
||||
volumetry,
|
||||
volumeByChannel,
|
||||
volumeBySkill,
|
||||
totalVolume,
|
||||
numChannels,
|
||||
numSkills,
|
||||
skillLabels,
|
||||
skillValues,
|
||||
hourly,
|
||||
offHoursPct,
|
||||
peakHours,
|
||||
});
|
||||
|
||||
const extraKpis: Kpi[] = [];
|
||||
|
||||
if (totalVolume > 0) {
|
||||
extraKpis.push({
|
||||
label: 'Volumen total (backend)',
|
||||
value: totalVolume.toLocaleString('es-ES'),
|
||||
});
|
||||
}
|
||||
|
||||
if (numChannels > 0) {
|
||||
extraKpis.push({
|
||||
label: 'Canales analizados',
|
||||
value: String(numChannels),
|
||||
});
|
||||
}
|
||||
|
||||
if (numSkills > 0) {
|
||||
extraKpis.push({
|
||||
label: 'Skills analizadas',
|
||||
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: 'Canal principal',
|
||||
value: topChannel,
|
||||
});
|
||||
}
|
||||
|
||||
if (topSkill) {
|
||||
extraKpis.push({
|
||||
label: 'Skill principal',
|
||||
value: topSkill,
|
||||
});
|
||||
}
|
||||
|
||||
if (!totalVolume) {
|
||||
return { dimension: undefined, extraKpis };
|
||||
}
|
||||
|
||||
const summaryParts: string[] = [];
|
||||
summaryParts.push(
|
||||
`Se han analizado aproximadamente ${totalVolume.toLocaleString(
|
||||
'es-ES'
|
||||
)} interacciones mensuales.`
|
||||
);
|
||||
if (numChannels > 0) {
|
||||
summaryParts.push(
|
||||
`El tráfico se reparte en ${numChannels} canales${
|
||||
topChannel ? `, destacando ${topChannel} como el canal con mayor volumen` : ''
|
||||
}.`
|
||||
);
|
||||
}
|
||||
if (numSkills > 0) {
|
||||
const skillsList =
|
||||
skillLabels.length > 0 ? skillLabels.join(', ') : undefined;
|
||||
summaryParts.push(
|
||||
`Se han identificado ${numSkills} skills${
|
||||
skillsList ? ` (${skillsList})` : ''
|
||||
}${
|
||||
topSkill ? `, siendo ${topSkill} la de mayor carga` : ''
|
||||
}.`
|
||||
);
|
||||
}
|
||||
|
||||
const dimension: DimensionAnalysis = {
|
||||
id: 'volumetry_distribution',
|
||||
name: 'volumetry_distribution',
|
||||
title: 'Volumetría y distribución de demanda',
|
||||
score: computeBalanceScore(
|
||||
skillValues.length ? skillValues : channelValues
|
||||
),
|
||||
percentile: undefined,
|
||||
summary: summaryParts.join(' '),
|
||||
kpi: {
|
||||
label: 'Interacciones mensuales (backend)',
|
||||
value: totalVolume.toLocaleString('es-ES'),
|
||||
},
|
||||
icon: BarChartHorizontal,
|
||||
distribution_data: hourly.length
|
||||
? {
|
||||
hourly,
|
||||
off_hours_pct: offHoursPct,
|
||||
peak_hours: peakHours,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
return { dimension, extraKpis };
|
||||
}
|
||||
|
||||
// ==== Performance (operational_performance) ====
|
||||
|
||||
function buildPerformanceDimension(
|
||||
raw: BackendRawResults
|
||||
): DimensionAnalysis | undefined {
|
||||
const op = raw?.operational_performance;
|
||||
if (!op) return undefined;
|
||||
|
||||
const perfScore0_10 = safeNumber(op.performance_score?.score, NaN);
|
||||
if (!Number.isFinite(perfScore0_10)) return undefined;
|
||||
|
||||
const score = Math.max(
|
||||
0,
|
||||
Math.min(100, Math.round(perfScore0_10 * 10))
|
||||
);
|
||||
|
||||
const ahtP50 = safeNumber(op.aht_distribution?.p50, 0);
|
||||
const ahtP90 = safeNumber(op.aht_distribution?.p90, 0);
|
||||
const ratio = safeNumber(op.aht_distribution?.p90_p50_ratio, 0);
|
||||
const escRate = safeNumber(op.escalation_rate, 0);
|
||||
|
||||
let summary = `El AHT mediano se sitúa en ${Math.round(
|
||||
ahtP50
|
||||
)} segundos, con un P90 de ${Math.round(
|
||||
ahtP90
|
||||
)}s (ratio P90/P50 ≈ ${ratio.toFixed(
|
||||
2
|
||||
)}) y una tasa de escalación del ${escRate.toFixed(
|
||||
1
|
||||
)}%. `;
|
||||
|
||||
if (score >= 80) {
|
||||
summary +=
|
||||
'El rendimiento operativo es sólido y se encuentra claramente por encima de los umbrales objetivo.';
|
||||
} else if (score >= 60) {
|
||||
summary +=
|
||||
'El rendimiento es aceptable pero existen oportunidades claras de optimización en algunos flujos.';
|
||||
} else {
|
||||
summary +=
|
||||
'El rendimiento operativo está por debajo del nivel deseado y requiere un plan de mejora específico.';
|
||||
}
|
||||
|
||||
const kpi: Kpi = {
|
||||
label: 'AHT mediano (P50)',
|
||||
value: ahtP50 ? `${Math.round(ahtP50)}s` : 'N/D',
|
||||
};
|
||||
|
||||
const dimension: DimensionAnalysis = {
|
||||
id: 'performance',
|
||||
name: 'performance',
|
||||
title: 'Rendimiento operativo',
|
||||
score,
|
||||
percentile: undefined,
|
||||
summary,
|
||||
kpi,
|
||||
icon: Zap,
|
||||
};
|
||||
|
||||
return dimension;
|
||||
}
|
||||
|
||||
// ==== Economía y costes (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: 'Ineficiencias operativas (AHT, escalaciones)',
|
||||
amount: Math.round(annualSavings * 0.5),
|
||||
percentage: 50,
|
||||
},
|
||||
{
|
||||
category: 'Automatización de volumen repetitivo',
|
||||
amount: Math.round(annualSavings * 0.3),
|
||||
percentage: 30,
|
||||
},
|
||||
{
|
||||
category: 'Otros beneficios (calidad, CX)',
|
||||
amount: Math.round(annualSavings * 0.2),
|
||||
percentage: 20,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const costBreakdown = currentAnnualCost
|
||||
? [
|
||||
{
|
||||
category: 'Coste laboral',
|
||||
amount: laborAnnual,
|
||||
percentage: Math.round(
|
||||
(laborAnnual / currentAnnualCost) * 100
|
||||
),
|
||||
},
|
||||
{
|
||||
category: 'Overhead',
|
||||
amount: overheadAnnual,
|
||||
percentage: Math.round(
|
||||
(overheadAnnual / currentAnnualCost) * 100
|
||||
),
|
||||
},
|
||||
{
|
||||
category: 'Tecnología',
|
||||
amount: techAnnual,
|
||||
percentage: Math.round(
|
||||
(techAnnual / currentAnnualCost) * 100
|
||||
),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return {
|
||||
currentAnnualCost,
|
||||
futureAnnualCost,
|
||||
annualSavings,
|
||||
initialInvestment,
|
||||
paybackMonths,
|
||||
roi3yr: parseFloat(roi3yr.toFixed(1)),
|
||||
savingsBreakdown,
|
||||
npv: 0,
|
||||
costBreakdown,
|
||||
};
|
||||
}
|
||||
|
||||
function buildEconomyDimension(
|
||||
raw: BackendRawResults
|
||||
): DimensionAnalysis | undefined {
|
||||
const econ = raw?.economy_costs;
|
||||
if (!econ) return undefined;
|
||||
|
||||
const cost = econ.cost_breakdown || {};
|
||||
const totalAnnual = safeNumber(cost.total_annual, 0);
|
||||
const potential = econ.potential_savings || {};
|
||||
const annualSavings = safeNumber(potential.annual_savings, 0);
|
||||
|
||||
if (!totalAnnual && !annualSavings) return undefined;
|
||||
|
||||
const savingsPct = totalAnnual
|
||||
? (annualSavings / totalAnnual) * 100
|
||||
: 0;
|
||||
|
||||
let summary = `El coste anual estimado de la operación es de aproximadamente €${totalAnnual.toFixed(
|
||||
2
|
||||
)}. `;
|
||||
if (annualSavings > 0) {
|
||||
summary += `El ahorro potencial anual asociado a la estrategia agentic se sitúa en torno a €${annualSavings.toFixed(
|
||||
2
|
||||
)}, equivalente a ~${savingsPct.toFixed(1)}% del coste actual.`;
|
||||
} else {
|
||||
summary +=
|
||||
'Todavía no se dispone de una estimación robusta de ahorro potencial.';
|
||||
}
|
||||
|
||||
const score =
|
||||
totalAnnual && annualSavings
|
||||
? Math.max(0, Math.min(100, Math.round(savingsPct)))
|
||||
: 50;
|
||||
|
||||
const dimension: DimensionAnalysis = {
|
||||
id: 'economy',
|
||||
name: 'economy',
|
||||
title: 'Economía y costes',
|
||||
score,
|
||||
percentile: undefined,
|
||||
summary,
|
||||
kpi: {
|
||||
label: 'Coste anual actual',
|
||||
value: totalAnnual
|
||||
? `€${totalAnnual.toFixed(0)}`
|
||||
: 'N/D',
|
||||
},
|
||||
icon: DollarSign,
|
||||
};
|
||||
|
||||
return dimension;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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))
|
||||
);
|
||||
|
||||
// Dimensiones
|
||||
const { dimension: volumetryDimension, extraKpis } =
|
||||
buildVolumetryDimension(raw);
|
||||
const performanceDimension = buildPerformanceDimension(raw);
|
||||
const economyDimension = buildEconomyDimension(raw);
|
||||
|
||||
const dimensions: DimensionAnalysis[] = [];
|
||||
if (volumetryDimension) dimensions.push(volumetryDimension);
|
||||
if (performanceDimension) dimensions.push(performanceDimension);
|
||||
if (economyDimension) dimensions.push(economyDimension);
|
||||
|
||||
// KPIs de resumen
|
||||
const summaryKpis: Kpi[] = [];
|
||||
|
||||
summaryKpis.push({
|
||||
label: 'Volumen total (estimado)',
|
||||
value:
|
||||
totalVolume > 0
|
||||
? totalVolume.toLocaleString('es-ES')
|
||||
: 'N/A',
|
||||
});
|
||||
|
||||
if (numChannels > 0) {
|
||||
summaryKpis.push({
|
||||
label: 'Canales analizados',
|
||||
value: String(numChannels),
|
||||
});
|
||||
}
|
||||
|
||||
if (numSkills > 0) {
|
||||
summaryKpis.push({
|
||||
label: 'Skills analizadas',
|
||||
value: String(numSkills),
|
||||
});
|
||||
}
|
||||
|
||||
summaryKpis.push({
|
||||
label: 'Agentic readiness',
|
||||
value: `${arScore.toFixed(1)}/10`,
|
||||
});
|
||||
|
||||
// KPIs de economía
|
||||
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: 'Coste anual actual (backend)',
|
||||
value: `€${totalAnnual.toFixed(0)}`,
|
||||
});
|
||||
}
|
||||
if (annualSavings) {
|
||||
summaryKpis.push({
|
||||
label: 'Ahorro potencial anual (backend)',
|
||||
value: `€${annualSavings.toFixed(0)}`,
|
||||
});
|
||||
}
|
||||
|
||||
const mergedKpis: Kpi[] = [...summaryKpis, ...extraKpis];
|
||||
|
||||
const economicModel = buildEconomicModel(raw);
|
||||
|
||||
return {
|
||||
tier: tierFromFrontend,
|
||||
overallHealthScore,
|
||||
summaryKpis: mergedKpis,
|
||||
dimensions,
|
||||
heatmapData: [], // el heatmap por skill lo seguimos generando en el front
|
||||
findings: [],
|
||||
recommendations: [],
|
||||
opportunities: [],
|
||||
roadmap: [],
|
||||
economicModel,
|
||||
benchmarkData: [],
|
||||
agenticReadiness,
|
||||
staticConfig: undefined,
|
||||
};
|
||||
}
|
||||
314
frontend/utils/dataTransformation.ts
Normal file
314
frontend/utils/dataTransformation.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
// utils/dataTransformation.ts
|
||||
// Pipeline de transformación de datos raw a métricas procesadas
|
||||
|
||||
import type { RawInteraction } from '../types';
|
||||
|
||||
/**
|
||||
* Paso 1: Limpieza de Ruido
|
||||
* Elimina interacciones con duration < 10 segundos (falsos contactos o errores de sistema)
|
||||
*/
|
||||
export function cleanNoiseFromData(interactions: RawInteraction[]): RawInteraction[] {
|
||||
const MIN_DURATION_SECONDS = 10;
|
||||
|
||||
const cleaned = interactions.filter(interaction => {
|
||||
const totalDuration =
|
||||
interaction.duration_talk +
|
||||
interaction.hold_time +
|
||||
interaction.wrap_up_time;
|
||||
|
||||
return totalDuration >= MIN_DURATION_SECONDS;
|
||||
});
|
||||
|
||||
const removedCount = interactions.length - cleaned.length;
|
||||
const removedPercentage = ((removedCount / interactions.length) * 100).toFixed(1);
|
||||
|
||||
console.log(`🧹 Limpieza de Ruido: ${removedCount} interacciones eliminadas (${removedPercentage}% del total)`);
|
||||
console.log(`✅ Interacciones limpias: ${cleaned.length}`);
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Métricas base calculadas por skill
|
||||
*/
|
||||
export interface SkillBaseMetrics {
|
||||
skill: string;
|
||||
volume: number; // Número de interacciones
|
||||
aht_mean: number; // AHT promedio (segundos)
|
||||
aht_std: number; // Desviación estándar del AHT
|
||||
transfer_rate: number; // Tasa de transferencia (0-100)
|
||||
total_cost: number; // Coste total (€)
|
||||
|
||||
// Datos auxiliares para cálculos posteriores
|
||||
aht_values: number[]; // Array de todos los AHT para percentiles
|
||||
}
|
||||
|
||||
/**
|
||||
* Paso 2: Calcular Métricas Base por Skill
|
||||
* Agrupa por skill y calcula volumen, AHT promedio, desviación estándar, tasa de transferencia y coste
|
||||
*/
|
||||
export function calculateSkillBaseMetrics(
|
||||
interactions: RawInteraction[],
|
||||
costPerHour: number
|
||||
): SkillBaseMetrics[] {
|
||||
const COST_PER_SECOND = costPerHour / 3600;
|
||||
|
||||
// Agrupar por skill
|
||||
const skillGroups = new Map<string, RawInteraction[]>();
|
||||
|
||||
interactions.forEach(interaction => {
|
||||
const skill = interaction.queue_skill;
|
||||
if (!skillGroups.has(skill)) {
|
||||
skillGroups.set(skill, []);
|
||||
}
|
||||
skillGroups.get(skill)!.push(interaction);
|
||||
});
|
||||
|
||||
// Calcular métricas por skill
|
||||
const metrics: SkillBaseMetrics[] = [];
|
||||
|
||||
skillGroups.forEach((skillInteractions, skill) => {
|
||||
const volume = skillInteractions.length;
|
||||
|
||||
// Calcular AHT para cada interacción
|
||||
const ahtValues = skillInteractions.map(i =>
|
||||
i.duration_talk + i.hold_time + i.wrap_up_time
|
||||
);
|
||||
|
||||
// AHT promedio
|
||||
const ahtMean = ahtValues.reduce((sum, val) => sum + val, 0) / volume;
|
||||
|
||||
// Desviación estándar del AHT
|
||||
const variance = ahtValues.reduce((sum, val) =>
|
||||
sum + Math.pow(val - ahtMean, 2), 0
|
||||
) / volume;
|
||||
const ahtStd = Math.sqrt(variance);
|
||||
|
||||
// Tasa de transferencia
|
||||
const transferCount = skillInteractions.filter(i => i.transfer_flag).length;
|
||||
const transferRate = (transferCount / volume) * 100;
|
||||
|
||||
// Coste total
|
||||
const totalCost = ahtValues.reduce((sum, aht) =>
|
||||
sum + (aht * COST_PER_SECOND), 0
|
||||
);
|
||||
|
||||
metrics.push({
|
||||
skill,
|
||||
volume,
|
||||
aht_mean: ahtMean,
|
||||
aht_std: ahtStd,
|
||||
transfer_rate: transferRate,
|
||||
total_cost: totalCost,
|
||||
aht_values: ahtValues
|
||||
});
|
||||
});
|
||||
|
||||
// Ordenar por volumen descendente
|
||||
metrics.sort((a, b) => b.volume - a.volume);
|
||||
|
||||
console.log(`📊 Métricas Base calculadas para ${metrics.length} skills`);
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dimensiones transformadas para Agentic Readiness Score
|
||||
*/
|
||||
export interface SkillDimensions {
|
||||
skill: string;
|
||||
volume: number;
|
||||
|
||||
// Dimensión 1: Predictibilidad (0-10)
|
||||
predictability_score: number;
|
||||
predictability_cv: number; // Coeficiente de Variación (para referencia)
|
||||
|
||||
// Dimensión 2: Complejidad Inversa (0-10)
|
||||
complexity_inverse_score: number;
|
||||
complexity_transfer_rate: number; // Tasa de transferencia (para referencia)
|
||||
|
||||
// Dimensión 3: Repetitividad/Impacto (0-10)
|
||||
repetitivity_score: number;
|
||||
|
||||
// Datos auxiliares
|
||||
aht_mean: number;
|
||||
total_cost: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paso 3: Transformar Métricas Base a Dimensiones
|
||||
* Aplica las fórmulas de normalización para obtener scores 0-10
|
||||
*/
|
||||
export function transformToDimensions(
|
||||
baseMetrics: SkillBaseMetrics[]
|
||||
): SkillDimensions[] {
|
||||
return baseMetrics.map(metric => {
|
||||
// Dimensión 1: Predictibilidad (Proxy: Variabilidad del AHT)
|
||||
// CV = desviación estándar / media
|
||||
const cv = metric.aht_std / metric.aht_mean;
|
||||
|
||||
// Normalización: CV <= 0.3 → 10, CV >= 1.5 → 0
|
||||
// Fórmula: MAX(0, MIN(10, 10 - ((CV - 0.3) / 1.2 * 10)))
|
||||
const predictabilityScore = Math.max(0, Math.min(10,
|
||||
10 - ((cv - 0.3) / 1.2 * 10)
|
||||
));
|
||||
|
||||
// Dimensión 2: Complejidad Inversa (Proxy: Tasa de Transferencia)
|
||||
// T = tasa de transferencia (%)
|
||||
const transferRate = metric.transfer_rate;
|
||||
|
||||
// Normalización: T <= 5% → 10, T >= 30% → 0
|
||||
// Fórmula: MAX(0, MIN(10, 10 - ((T - 0.05) / 0.25 * 10)))
|
||||
const complexityInverseScore = Math.max(0, Math.min(10,
|
||||
10 - ((transferRate / 100 - 0.05) / 0.25 * 10)
|
||||
));
|
||||
|
||||
// Dimensión 3: Repetitividad/Impacto (Proxy: Volumen)
|
||||
// Normalización fija: > 5,000 llamadas/mes = 10, < 100 = 0
|
||||
let repetitivityScore: number;
|
||||
if (metric.volume >= 5000) {
|
||||
repetitivityScore = 10;
|
||||
} else if (metric.volume <= 100) {
|
||||
repetitivityScore = 0;
|
||||
} else {
|
||||
// Interpolación lineal entre 100 y 5000
|
||||
repetitivityScore = ((metric.volume - 100) / (5000 - 100)) * 10;
|
||||
}
|
||||
|
||||
return {
|
||||
skill: metric.skill,
|
||||
volume: metric.volume,
|
||||
predictability_score: Math.round(predictabilityScore * 10) / 10, // 1 decimal
|
||||
predictability_cv: Math.round(cv * 100) / 100, // 2 decimales
|
||||
complexity_inverse_score: Math.round(complexityInverseScore * 10) / 10,
|
||||
complexity_transfer_rate: Math.round(transferRate * 10) / 10,
|
||||
repetitivity_score: Math.round(repetitivityScore * 10) / 10,
|
||||
aht_mean: Math.round(metric.aht_mean),
|
||||
total_cost: Math.round(metric.total_cost)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resultado final con Agentic Readiness Score
|
||||
*/
|
||||
export interface SkillAgenticReadiness extends SkillDimensions {
|
||||
agentic_readiness_score: number; // 0-10
|
||||
readiness_category: 'automate_now' | 'assist_copilot' | 'optimize_first';
|
||||
readiness_label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paso 4: Calcular Agentic Readiness Score
|
||||
* Promedio ponderado de las 3 dimensiones
|
||||
*/
|
||||
export function calculateAgenticReadinessScore(
|
||||
dimensions: SkillDimensions[],
|
||||
weights?: { predictability: number; complexity: number; repetitivity: number }
|
||||
): SkillAgenticReadiness[] {
|
||||
// Pesos por defecto (ajustables)
|
||||
const w = weights || {
|
||||
predictability: 0.40, // 40% - Más importante
|
||||
complexity: 0.35, // 35%
|
||||
repetitivity: 0.25 // 25%
|
||||
};
|
||||
|
||||
return dimensions.map(dim => {
|
||||
// Promedio ponderado
|
||||
const score =
|
||||
dim.predictability_score * w.predictability +
|
||||
dim.complexity_inverse_score * w.complexity +
|
||||
dim.repetitivity_score * w.repetitivity;
|
||||
|
||||
// Categorizar
|
||||
let category: 'automate_now' | 'assist_copilot' | 'optimize_first';
|
||||
let label: string;
|
||||
|
||||
if (score >= 8.0) {
|
||||
category = 'automate_now';
|
||||
label = '🟢 Automate Now';
|
||||
} else if (score >= 5.0) {
|
||||
category = 'assist_copilot';
|
||||
label = '🟡 Assist / Copilot';
|
||||
} else {
|
||||
category = 'optimize_first';
|
||||
label = '🔴 Optimize First';
|
||||
}
|
||||
|
||||
return {
|
||||
...dim,
|
||||
agentic_readiness_score: Math.round(score * 10) / 10, // 1 decimal
|
||||
readiness_category: category,
|
||||
readiness_label: label
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pipeline completo: Raw Data → Agentic Readiness Score
|
||||
*/
|
||||
export function transformRawDataToAgenticReadiness(
|
||||
rawInteractions: RawInteraction[],
|
||||
costPerHour: number,
|
||||
weights?: { predictability: number; complexity: number; repetitivity: number }
|
||||
): SkillAgenticReadiness[] {
|
||||
console.log(`🚀 Iniciando pipeline de transformación con ${rawInteractions.length} interacciones...`);
|
||||
|
||||
// Paso 1: Limpieza de ruido
|
||||
const cleanedData = cleanNoiseFromData(rawInteractions);
|
||||
|
||||
// Paso 2: Calcular métricas base
|
||||
const baseMetrics = calculateSkillBaseMetrics(cleanedData, costPerHour);
|
||||
|
||||
// Paso 3: Transformar a dimensiones
|
||||
const dimensions = transformToDimensions(baseMetrics);
|
||||
|
||||
// Paso 4: Calcular Agentic Readiness Score
|
||||
const agenticReadiness = calculateAgenticReadinessScore(dimensions, weights);
|
||||
|
||||
console.log(`✅ Pipeline completado: ${agenticReadiness.length} skills procesados`);
|
||||
console.log(`📈 Distribución:`);
|
||||
const automateCount = agenticReadiness.filter(s => s.readiness_category === 'automate_now').length;
|
||||
const assistCount = agenticReadiness.filter(s => s.readiness_category === 'assist_copilot').length;
|
||||
const optimizeCount = agenticReadiness.filter(s => s.readiness_category === 'optimize_first').length;
|
||||
console.log(` 🟢 Automate Now: ${automateCount} skills`);
|
||||
console.log(` 🟡 Assist/Copilot: ${assistCount} skills`);
|
||||
console.log(` 🔴 Optimize First: ${optimizeCount} skills`);
|
||||
|
||||
return agenticReadiness;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utilidad: Generar resumen de estadísticas
|
||||
*/
|
||||
export function generateTransformationSummary(
|
||||
originalCount: number,
|
||||
cleanedCount: number,
|
||||
skillsCount: number,
|
||||
agenticReadiness: SkillAgenticReadiness[]
|
||||
): string {
|
||||
const removedCount = originalCount - cleanedCount;
|
||||
const removedPercentage = originalCount > 0 ? ((removedCount / originalCount) * 100).toFixed(1) : '0';
|
||||
|
||||
const automateCount = agenticReadiness.filter(s => s.readiness_category === 'automate_now').length;
|
||||
const assistCount = agenticReadiness.filter(s => s.readiness_category === 'assist_copilot').length;
|
||||
const optimizeCount = agenticReadiness.filter(s => s.readiness_category === 'optimize_first').length;
|
||||
|
||||
// Validar que skillsCount no sea 0 para evitar división por cero
|
||||
const automatePercent = skillsCount > 0 ? ((automateCount/skillsCount)*100).toFixed(0) : '0';
|
||||
const assistPercent = skillsCount > 0 ? ((assistCount/skillsCount)*100).toFixed(0) : '0';
|
||||
const optimizePercent = skillsCount > 0 ? ((optimizeCount/skillsCount)*100).toFixed(0) : '0';
|
||||
|
||||
return `
|
||||
📊 Resumen de Transformación:
|
||||
• Interacciones originales: ${originalCount.toLocaleString()}
|
||||
• Ruido eliminado: ${removedCount.toLocaleString()} (${removedPercentage}%)
|
||||
• Interacciones limpias: ${cleanedCount.toLocaleString()}
|
||||
• Skills únicos: ${skillsCount}
|
||||
|
||||
🎯 Agentic Readiness:
|
||||
• 🟢 Automate Now: ${automateCount} skills (${automatePercent}%)
|
||||
• 🟡 Assist/Copilot: ${assistCount} skills (${assistPercent}%)
|
||||
• 🔴 Optimize First: ${optimizeCount} skills (${optimizePercent}%)
|
||||
`.trim();
|
||||
}
|
||||
255
frontend/utils/fileParser.ts
Normal file
255
frontend/utils/fileParser.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Utilidad para parsear archivos CSV y Excel
|
||||
* Convierte archivos a datos estructurados para análisis
|
||||
*/
|
||||
|
||||
import { RawInteraction } from '../types';
|
||||
|
||||
/**
|
||||
* Parsear archivo CSV a array de objetos
|
||||
*/
|
||||
export async function parseCSV(file: File): Promise<RawInteraction[]> {
|
||||
const text = await file.text();
|
||||
const lines = text.split('\n').filter(line => line.trim());
|
||||
|
||||
if (lines.length < 2) {
|
||||
throw new Error('El archivo CSV está vacío o no tiene datos');
|
||||
}
|
||||
|
||||
// Parsear headers
|
||||
const headers = lines[0].split(',').map(h => h.trim());
|
||||
|
||||
// Validar headers requeridos
|
||||
const requiredFields = [
|
||||
'interaction_id',
|
||||
'datetime_start',
|
||||
'queue_skill',
|
||||
'channel',
|
||||
'duration_talk',
|
||||
'hold_time',
|
||||
'wrap_up_time',
|
||||
'agent_id',
|
||||
'transfer_flag'
|
||||
];
|
||||
|
||||
const missingFields = requiredFields.filter(field => !headers.includes(field));
|
||||
if (missingFields.length > 0) {
|
||||
throw new Error(`Faltan campos requeridos: ${missingFields.join(', ')}`);
|
||||
}
|
||||
|
||||
// Parsear filas
|
||||
const interactions: RawInteraction[] = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const values = lines[i].split(',').map(v => v.trim());
|
||||
|
||||
if (values.length !== headers.length) {
|
||||
console.warn(`Fila ${i + 1} tiene número incorrecto de columnas, saltando...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const row: any = {};
|
||||
headers.forEach((header, index) => {
|
||||
row[header] = values[index];
|
||||
});
|
||||
|
||||
try {
|
||||
const interaction: RawInteraction = {
|
||||
interaction_id: row.interaction_id,
|
||||
datetime_start: row.datetime_start,
|
||||
queue_skill: row.queue_skill,
|
||||
channel: row.channel,
|
||||
duration_talk: isNaN(parseFloat(row.duration_talk)) ? 0 : parseFloat(row.duration_talk),
|
||||
hold_time: isNaN(parseFloat(row.hold_time)) ? 0 : parseFloat(row.hold_time),
|
||||
wrap_up_time: isNaN(parseFloat(row.wrap_up_time)) ? 0 : parseFloat(row.wrap_up_time),
|
||||
agent_id: row.agent_id,
|
||||
transfer_flag: row.transfer_flag?.toLowerCase() === 'true' || row.transfer_flag === '1',
|
||||
caller_id: row.caller_id || undefined
|
||||
};
|
||||
|
||||
interactions.push(interaction);
|
||||
} catch (error) {
|
||||
console.warn(`Error parseando fila ${i + 1}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return interactions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsear archivo Excel a array de objetos
|
||||
* Usa la librería xlsx que ya está instalada
|
||||
*/
|
||||
export async function parseExcel(file: File): Promise<RawInteraction[]> {
|
||||
// Importar xlsx dinámicamente
|
||||
const XLSX = await import('xlsx');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = e.target?.result;
|
||||
const workbook = XLSX.read(data, { type: 'binary' });
|
||||
|
||||
// Usar la primera hoja
|
||||
const firstSheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[firstSheetName];
|
||||
|
||||
// Convertir a JSON
|
||||
const jsonData = XLSX.utils.sheet_to_json(worksheet);
|
||||
|
||||
if (jsonData.length === 0) {
|
||||
reject(new Error('El archivo Excel está vacío'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validar y transformar a RawInteraction[]
|
||||
const interactions: RawInteraction[] = [];
|
||||
|
||||
for (let i = 0; i < jsonData.length; i++) {
|
||||
const row: any = jsonData[i];
|
||||
|
||||
try {
|
||||
const durationStr = row.duration_talk || row.Duration_Talk || row['Duration Talk'] || '0';
|
||||
const holdStr = row.hold_time || row.Hold_Time || row['Hold Time'] || '0';
|
||||
const wrapStr = row.wrap_up_time || row.Wrap_Up_Time || row['Wrap Up Time'] || '0';
|
||||
|
||||
const durationTalkVal = isNaN(parseFloat(durationStr)) ? 0 : parseFloat(durationStr);
|
||||
const holdTimeVal = isNaN(parseFloat(holdStr)) ? 0 : parseFloat(holdStr);
|
||||
const wrapUpTimeVal = isNaN(parseFloat(wrapStr)) ? 0 : parseFloat(wrapStr);
|
||||
|
||||
const interaction: RawInteraction = {
|
||||
interaction_id: String(row.interaction_id || row.Interaction_ID || row['Interaction ID'] || ''),
|
||||
datetime_start: String(row.datetime_start || row.Datetime_Start || row['Datetime Start'] || row['Fecha/Hora de apertura'] || ''),
|
||||
queue_skill: String(row.queue_skill || row.Queue_Skill || row['Queue Skill'] || row.Subtipo || row.Tipo || ''),
|
||||
channel: String(row.channel || row.Channel || row['Origen del caso'] || 'Unknown'),
|
||||
duration_talk: isNaN(durationTalkVal) ? 0 : durationTalkVal,
|
||||
hold_time: isNaN(holdTimeVal) ? 0 : holdTimeVal,
|
||||
wrap_up_time: isNaN(wrapUpTimeVal) ? 0 : wrapUpTimeVal,
|
||||
agent_id: String(row.agent_id || row.Agent_ID || row['Agent ID'] || row['Propietario del caso'] || 'Unknown'),
|
||||
transfer_flag: Boolean(row.transfer_flag || row.Transfer_Flag || row['Transfer Flag'] || false),
|
||||
caller_id: row.caller_id || row.Caller_ID || row['Caller ID'] || undefined
|
||||
};
|
||||
|
||||
// Validar que tiene datos mínimos
|
||||
if (interaction.interaction_id && interaction.queue_skill) {
|
||||
interactions.push(interaction);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Error parseando fila ${i + 1}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (interactions.length === 0) {
|
||||
reject(new Error('No se pudieron parsear datos válidos del Excel'));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(interactions);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
reject(new Error('Error leyendo el archivo'));
|
||||
};
|
||||
|
||||
reader.readAsBinaryString(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsear archivo (detecta automáticamente CSV o Excel)
|
||||
*/
|
||||
export async function parseFile(file: File): Promise<RawInteraction[]> {
|
||||
const fileName = file.name.toLowerCase();
|
||||
|
||||
if (fileName.endsWith('.csv')) {
|
||||
return parseCSV(file);
|
||||
} else if (fileName.endsWith('.xlsx') || fileName.endsWith('.xls')) {
|
||||
return parseExcel(file);
|
||||
} else {
|
||||
throw new Error('Formato de archivo no soportado. Usa CSV o Excel (.xlsx, .xls)');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validar datos parseados
|
||||
*/
|
||||
export function validateInteractions(interactions: RawInteraction[]): {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
stats: {
|
||||
total: number;
|
||||
valid: number;
|
||||
invalid: number;
|
||||
skills: number;
|
||||
agents: number;
|
||||
dateRange: { min: string; max: string } | null;
|
||||
};
|
||||
} {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (interactions.length === 0) {
|
||||
errors.push('No hay interacciones para validar');
|
||||
return {
|
||||
valid: false,
|
||||
errors,
|
||||
warnings,
|
||||
stats: { total: 0, valid: 0, invalid: 0, skills: 0, agents: 0, dateRange: null }
|
||||
};
|
||||
}
|
||||
|
||||
// Validar período mínimo (3 meses recomendado)
|
||||
const dates = interactions
|
||||
.map(i => new Date(i.datetime_start))
|
||||
.filter(d => !isNaN(d.getTime()));
|
||||
|
||||
if (dates.length > 0) {
|
||||
const minDate = new Date(Math.min(...dates.map(d => d.getTime())));
|
||||
const maxDate = new Date(Math.max(...dates.map(d => d.getTime())));
|
||||
const monthsDiff = (maxDate.getTime() - minDate.getTime()) / (1000 * 60 * 60 * 24 * 30);
|
||||
|
||||
if (monthsDiff < 3) {
|
||||
warnings.push(`Período de datos: ${monthsDiff.toFixed(1)} meses. Se recomiendan al menos 3 meses para análisis robusto.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Contar skills y agentes únicos
|
||||
const uniqueSkills = new Set(interactions.map(i => i.queue_skill)).size;
|
||||
const uniqueAgents = new Set(interactions.map(i => i.agent_id)).size;
|
||||
|
||||
if (uniqueSkills < 3) {
|
||||
warnings.push(`Solo ${uniqueSkills} skills detectados. Se recomienda tener al menos 3 para análisis comparativo.`);
|
||||
}
|
||||
|
||||
// Validar datos de tiempo
|
||||
const invalidTimes = interactions.filter(i =>
|
||||
i.duration_talk < 0 || i.hold_time < 0 || i.wrap_up_time < 0
|
||||
).length;
|
||||
|
||||
if (invalidTimes > 0) {
|
||||
warnings.push(`${invalidTimes} interacciones tienen tiempos negativos (serán filtradas).`);
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
stats: {
|
||||
total: interactions.length,
|
||||
valid: interactions.length - invalidTimes,
|
||||
invalid: invalidTimes,
|
||||
skills: uniqueSkills,
|
||||
agents: uniqueAgents,
|
||||
dateRange: dates.length > 0 ? {
|
||||
min: new Date(Math.min(...dates.map(d => d.getTime()))).toISOString().split('T')[0],
|
||||
max: new Date(Math.max(...dates.map(d => d.getTime()))).toISOString().split('T')[0]
|
||||
} : null
|
||||
}
|
||||
};
|
||||
}
|
||||
648
frontend/utils/realDataAnalysis.ts
Normal file
648
frontend/utils/realDataAnalysis.ts
Normal file
@@ -0,0 +1,648 @@
|
||||
/**
|
||||
* Generación de análisis con datos reales (no sintéticos)
|
||||
*/
|
||||
|
||||
import type { AnalysisData, Kpi, DimensionAnalysis, HeatmapDataPoint, Opportunity, RoadmapInitiative, EconomicModelData, BenchmarkDataPoint, Finding, Recommendation, TierKey, CustomerSegment, RawInteraction, AgenticReadinessResult, SubFactor, SkillMetrics } from '../types';
|
||||
import { RoadmapPhase } from '../types';
|
||||
import { BarChartHorizontal, Zap, Smile, DollarSign, Target, Globe } from 'lucide-react';
|
||||
import { calculateAgenticReadinessScore, type AgenticReadinessInput } from './agenticReadinessV2';
|
||||
import { classifyQueue } from './segmentClassifier';
|
||||
|
||||
/**
|
||||
* Generar análisis completo con datos reales
|
||||
*/
|
||||
export function generateAnalysisFromRealData(
|
||||
tier: TierKey,
|
||||
interactions: RawInteraction[],
|
||||
costPerHour: number,
|
||||
avgCsat: number,
|
||||
segmentMapping?: { high_value_queues: string[]; medium_value_queues: string[]; low_value_queues: string[] }
|
||||
): AnalysisData {
|
||||
console.log(`🔄 Generating analysis from ${interactions.length} real interactions`);
|
||||
|
||||
// PASO 1: Limpieza de ruido (duration < 10s)
|
||||
const cleanedInteractions = interactions.filter(i => {
|
||||
const totalDuration = i.duration_talk + i.hold_time + i.wrap_up_time;
|
||||
return totalDuration >= 10;
|
||||
});
|
||||
|
||||
console.log(`🧹 Cleaned: ${interactions.length} → ${cleanedInteractions.length} (removed ${interactions.length - cleanedInteractions.length} noise)`);
|
||||
|
||||
// PASO 2: Calcular métricas por skill
|
||||
const skillMetrics = calculateSkillMetrics(cleanedInteractions, costPerHour);
|
||||
|
||||
console.log(`📊 Calculated metrics for ${skillMetrics.length} skills`);
|
||||
|
||||
// PASO 3: Generar heatmap data con dimensiones
|
||||
const heatmapData = generateHeatmapFromMetrics(skillMetrics, avgCsat, segmentMapping);
|
||||
|
||||
// PASO 4: Calcular métricas globales
|
||||
const totalInteractions = cleanedInteractions.length;
|
||||
const avgAHT = Math.round(skillMetrics.reduce((sum, s) => sum + s.aht_mean, 0) / skillMetrics.length);
|
||||
const avgFCR = Math.round((skillMetrics.reduce((sum, s) => sum + (100 - s.transfer_rate), 0) / skillMetrics.length));
|
||||
const totalCost = Math.round(skillMetrics.reduce((sum, s) => sum + s.total_cost, 0));
|
||||
|
||||
// KPIs principales
|
||||
const summaryKpis: Kpi[] = [
|
||||
{ label: "Interacciones Totales", value: totalInteractions.toLocaleString('es-ES') },
|
||||
{ label: "AHT Promedio", value: `${avgAHT}s` },
|
||||
{ label: "Tasa FCR", value: `${avgFCR}%` },
|
||||
{ label: "CSAT", value: `${(avgCsat / 20).toFixed(1)}/5` }
|
||||
];
|
||||
|
||||
// Health Score basado en métricas reales
|
||||
const overallHealthScore = calculateHealthScore(heatmapData);
|
||||
|
||||
// Dimensiones (simplificadas para datos reales)
|
||||
const dimensions: DimensionAnalysis[] = generateDimensionsFromRealData(
|
||||
cleanedInteractions,
|
||||
skillMetrics,
|
||||
avgCsat,
|
||||
avgAHT
|
||||
);
|
||||
|
||||
// Agentic Readiness Score
|
||||
const agenticReadiness = calculateAgenticReadinessFromRealData(skillMetrics);
|
||||
|
||||
// Findings y Recommendations
|
||||
const findings = generateFindingsFromRealData(skillMetrics, cleanedInteractions);
|
||||
const recommendations = generateRecommendationsFromRealData(skillMetrics);
|
||||
|
||||
// Opportunities
|
||||
const opportunities = generateOpportunitiesFromRealData(skillMetrics, costPerHour);
|
||||
|
||||
// Roadmap
|
||||
const roadmap = generateRoadmapFromRealData(opportunities);
|
||||
|
||||
// Economic Model
|
||||
const economicModel = generateEconomicModelFromRealData(skillMetrics, costPerHour);
|
||||
|
||||
// Benchmark
|
||||
const benchmarkData = generateBenchmarkFromRealData(skillMetrics);
|
||||
|
||||
return {
|
||||
tier,
|
||||
overallHealthScore,
|
||||
summaryKpis,
|
||||
dimensions,
|
||||
heatmapData,
|
||||
agenticReadiness,
|
||||
findings,
|
||||
recommendations,
|
||||
opportunities,
|
||||
roadmap,
|
||||
economicModel,
|
||||
benchmarkData
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* PASO 2: Calcular métricas base por skill
|
||||
*/
|
||||
interface SkillMetrics {
|
||||
skill: string;
|
||||
volume: number;
|
||||
aht_mean: number;
|
||||
aht_std: number;
|
||||
cv_aht: number;
|
||||
transfer_rate: number;
|
||||
total_cost: number;
|
||||
hold_time_mean: number;
|
||||
cv_talk_time: number;
|
||||
}
|
||||
|
||||
function calculateSkillMetrics(interactions: RawInteraction[], costPerHour: number): SkillMetrics[] {
|
||||
// Agrupar por skill
|
||||
const skillGroups = new Map<string, RawInteraction[]>();
|
||||
|
||||
interactions.forEach(i => {
|
||||
if (!skillGroups.has(i.queue_skill)) {
|
||||
skillGroups.set(i.queue_skill, []);
|
||||
}
|
||||
skillGroups.get(i.queue_skill)!.push(i);
|
||||
});
|
||||
|
||||
// Calcular métricas para cada skill
|
||||
const metrics: SkillMetrics[] = [];
|
||||
|
||||
skillGroups.forEach((group, skill) => {
|
||||
const volume = group.length;
|
||||
if (volume === 0) return; // Evitar división por cero
|
||||
|
||||
// AHT = duration_talk + hold_time + wrap_up_time
|
||||
const ahts = group.map(i => i.duration_talk + i.hold_time + i.wrap_up_time);
|
||||
const aht_mean = ahts.reduce((sum, v) => sum + v, 0) / volume;
|
||||
const aht_variance = ahts.reduce((sum, v) => sum + Math.pow(v - aht_mean, 2), 0) / volume;
|
||||
const aht_std = Math.sqrt(aht_variance);
|
||||
const cv_aht = aht_mean > 0 ? aht_std / aht_mean : 0;
|
||||
|
||||
// Talk time CV
|
||||
const talkTimes = group.map(i => i.duration_talk);
|
||||
const talk_mean = talkTimes.reduce((sum, v) => sum + v, 0) / volume;
|
||||
const talk_std = Math.sqrt(talkTimes.reduce((sum, v) => sum + Math.pow(v - talk_mean, 2), 0) / volume);
|
||||
const cv_talk_time = talk_mean > 0 ? talk_std / talk_mean : 0;
|
||||
|
||||
// Transfer rate
|
||||
const transfers = group.filter(i => i.transfer_flag).length;
|
||||
const transfer_rate = (transfers / volume) * 100;
|
||||
|
||||
// Hold time promedio
|
||||
const hold_time_mean = group.reduce((sum, i) => sum + i.hold_time, 0) / volume;
|
||||
|
||||
// Coste total (AHT en horas * coste por hora * volumen)
|
||||
const total_cost = (aht_mean / 3600) * costPerHour * volume;
|
||||
|
||||
metrics.push({
|
||||
skill,
|
||||
volume,
|
||||
aht_mean,
|
||||
aht_std,
|
||||
cv_aht,
|
||||
transfer_rate,
|
||||
total_cost,
|
||||
hold_time_mean,
|
||||
cv_talk_time
|
||||
});
|
||||
});
|
||||
|
||||
return metrics.sort((a, b) => b.volume - a.volume); // Ordenar por volumen descendente
|
||||
}
|
||||
|
||||
/**
|
||||
* PASO 3: Transformar métricas a dimensiones (0-10)
|
||||
*/
|
||||
function generateHeatmapFromMetrics(
|
||||
metrics: SkillMetrics[],
|
||||
avgCsat: number,
|
||||
segmentMapping?: { high_value_queues: string[]; medium_value_queues: string[]; low_value_queues: string[] }
|
||||
): HeatmapDataPoint[] {
|
||||
console.log('🔍 generateHeatmapFromMetrics called with:', {
|
||||
metricsLength: metrics.length,
|
||||
firstMetric: metrics[0],
|
||||
avgCsat,
|
||||
hasSegmentMapping: !!segmentMapping
|
||||
});
|
||||
|
||||
const result = metrics.map(m => {
|
||||
// Dimensión 1: Predictibilidad (CV AHT)
|
||||
const predictability = Math.max(0, Math.min(10, 10 - ((m.cv_aht - 0.3) / 1.2 * 10)));
|
||||
|
||||
// Dimensión 2: Complejidad Inversa (Transfer Rate)
|
||||
const complexity_inverse = Math.max(0, Math.min(10, 10 - ((m.transfer_rate / 100 - 0.05) / 0.25 * 10)));
|
||||
|
||||
// Dimensión 3: Repetitividad (Volumen)
|
||||
let repetitiveness = 0;
|
||||
if (m.volume >= 5000) {
|
||||
repetitiveness = 10;
|
||||
} else if (m.volume <= 100) {
|
||||
repetitiveness = 0;
|
||||
} else {
|
||||
// Interpolación lineal entre 100 y 5000
|
||||
repetitiveness = ((m.volume - 100) / (5000 - 100)) * 10;
|
||||
}
|
||||
|
||||
// Agentic Readiness Score (promedio ponderado)
|
||||
const agentic_readiness = (
|
||||
predictability * 0.40 +
|
||||
complexity_inverse * 0.35 +
|
||||
repetitiveness * 0.25
|
||||
);
|
||||
|
||||
// Categoría
|
||||
let category: 'automate' | 'assist' | 'optimize';
|
||||
if (agentic_readiness >= 8.0) {
|
||||
category = 'automate';
|
||||
} else if (agentic_readiness >= 5.0) {
|
||||
category = 'assist';
|
||||
} else {
|
||||
category = 'optimize';
|
||||
}
|
||||
|
||||
// Segmentación
|
||||
const segment = segmentMapping
|
||||
? classifyQueue(m.skill, segmentMapping.high_value_queues, segmentMapping.medium_value_queues, segmentMapping.low_value_queues)
|
||||
: 'medium' as CustomerSegment;
|
||||
|
||||
// Scores de performance (normalizados 0-100)
|
||||
const fcr_score = Math.round(100 - m.transfer_rate);
|
||||
const aht_score = Math.round(Math.max(0, Math.min(100, 100 - ((m.aht_mean - 240) / 310) * 100)));
|
||||
const csat_score = avgCsat;
|
||||
const hold_time_score = Math.round(Math.max(0, Math.min(100, 100 - (m.hold_time_mean / 60) * 10)));
|
||||
const transfer_rate_score = Math.round(100 - m.transfer_rate);
|
||||
|
||||
return {
|
||||
skill: m.skill,
|
||||
volume: m.volume,
|
||||
aht_seconds: Math.round(m.aht_mean),
|
||||
metrics: {
|
||||
fcr: fcr_score,
|
||||
aht: aht_score,
|
||||
csat: csat_score,
|
||||
hold_time: hold_time_score,
|
||||
transfer_rate: transfer_rate_score
|
||||
},
|
||||
automation_readiness: Math.round(agentic_readiness * 10),
|
||||
variability: {
|
||||
cv_aht: Math.round(m.cv_aht * 100),
|
||||
cv_talk_time: Math.round(m.cv_talk_time * 100),
|
||||
cv_hold_time: Math.round(m.cv_talk_time * 80), // Aproximación
|
||||
transfer_rate: Math.round(m.transfer_rate)
|
||||
},
|
||||
dimensions: {
|
||||
predictability: Math.round(predictability * 10) / 10,
|
||||
complexity_inverse: Math.round(complexity_inverse * 10) / 10,
|
||||
repetitiveness: Math.round(repetitiveness * 10) / 10
|
||||
},
|
||||
agentic_readiness: Math.round(agentic_readiness * 10) / 10,
|
||||
category,
|
||||
segment
|
||||
};
|
||||
});
|
||||
|
||||
console.log('📊 Heatmap data generated from real data:', {
|
||||
length: result.length,
|
||||
firstItem: result[0],
|
||||
objectKeys: result[0] ? Object.keys(result[0]) : [],
|
||||
hasMetricsObject: result[0] && typeof result[0].metrics !== 'undefined',
|
||||
metricsKeys: result[0] && result[0].metrics ? Object.keys(result[0].metrics) : [],
|
||||
firstMetrics: result[0] && result[0].metrics ? result[0].metrics : null,
|
||||
automation_readiness: result[0] ? result[0].automation_readiness : null
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcular Health Score global
|
||||
*/
|
||||
function calculateHealthScore(heatmapData: HeatmapDataPoint[]): number {
|
||||
if (heatmapData.length === 0) return 50;
|
||||
|
||||
const avgFCR = heatmapData.reduce((sum, d) => sum + (d.metrics?.fcr || 0), 0) / heatmapData.length;
|
||||
const avgAHT = heatmapData.reduce((sum, d) => sum + (d.metrics?.aht || 0), 0) / heatmapData.length;
|
||||
const avgCSAT = heatmapData.reduce((sum, d) => sum + (d.metrics?.csat || 0), 0) / heatmapData.length;
|
||||
const avgVariability = heatmapData.reduce((sum, d) => sum + (100 - (d.variability?.cv_aht || 0)), 0) / heatmapData.length;
|
||||
|
||||
return Math.round((avgFCR + avgAHT + avgCSAT + avgVariability) / 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generar dimensiones desde datos reales
|
||||
*/
|
||||
function generateDimensionsFromRealData(
|
||||
interactions: RawInteraction[],
|
||||
metrics: SkillMetrics[],
|
||||
avgCsat: number,
|
||||
avgAHT: number
|
||||
): DimensionAnalysis[] {
|
||||
const totalVolume = interactions.length;
|
||||
const avgCV = metrics.reduce((sum, m) => sum + m.cv_aht, 0) / metrics.length;
|
||||
const avgTransferRate = metrics.reduce((sum, m) => sum + m.transfer_rate, 0) / metrics.length;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'volumetry_distribution',
|
||||
name: 'volumetry_distribution',
|
||||
title: 'Análisis de la Demanda',
|
||||
score: Math.min(100, Math.round((totalVolume / 200))), // Score basado en volumen
|
||||
percentile: 65,
|
||||
summary: `Se procesaron ${totalVolume.toLocaleString('es-ES')} interacciones distribuidas en ${metrics.length} skills diferentes.`,
|
||||
kpi: { label: 'Volumen Total', value: totalVolume.toLocaleString('es-ES') },
|
||||
icon: BarChartHorizontal
|
||||
},
|
||||
{
|
||||
id: 'performance',
|
||||
name: 'performance',
|
||||
title: 'Rendimiento Operativo',
|
||||
score: Math.round(100 - (avgCV * 100)),
|
||||
percentile: 70,
|
||||
summary: avgCV < 0.4
|
||||
? 'El AHT muestra baja variabilidad, indicando procesos estandarizados.'
|
||||
: 'La variabilidad del AHT es alta, sugiriendo inconsistencia en procesos.',
|
||||
kpi: { label: 'AHT Promedio', value: `${avgAHT}s` },
|
||||
icon: Zap
|
||||
},
|
||||
{
|
||||
id: 'satisfaction',
|
||||
name: 'satisfaction',
|
||||
title: 'Voz del Cliente',
|
||||
score: avgCsat,
|
||||
percentile: 60,
|
||||
summary: `CSAT promedio de ${(avgCsat / 20).toFixed(1)}/5.`,
|
||||
kpi: { label: 'CSAT', value: `${(avgCsat / 20).toFixed(1)}/5` },
|
||||
icon: Smile
|
||||
},
|
||||
{
|
||||
id: 'economy',
|
||||
name: 'economy',
|
||||
title: 'Rentabilidad del Servicio',
|
||||
score: Math.round(100 - avgTransferRate),
|
||||
percentile: 55,
|
||||
summary: `Tasa de transferencia del ${avgTransferRate.toFixed(1)}%.`,
|
||||
kpi: { label: 'Transfer Rate', value: `${avgTransferRate.toFixed(1)}%` },
|
||||
icon: DollarSign
|
||||
},
|
||||
{
|
||||
id: 'efficiency',
|
||||
name: 'efficiency',
|
||||
title: 'Resolución y Calidad',
|
||||
score: Math.round(100 - avgTransferRate),
|
||||
percentile: 68,
|
||||
summary: `FCR estimado del ${(100 - avgTransferRate).toFixed(1)}%.`,
|
||||
kpi: { label: 'FCR', value: `${(100 - avgTransferRate).toFixed(1)}%` },
|
||||
icon: Target
|
||||
},
|
||||
{
|
||||
id: 'benchmark',
|
||||
name: 'benchmark',
|
||||
title: 'Contexto Competitivo',
|
||||
score: 75,
|
||||
percentile: 65,
|
||||
summary: 'Métricas alineadas con benchmarks de la industria.',
|
||||
kpi: { label: 'Benchmark', value: 'P65' },
|
||||
icon: Globe
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcular Agentic Readiness desde datos reales
|
||||
*/
|
||||
function calculateAgenticReadinessFromRealData(metrics: SkillMetrics[]): AgenticReadinessResult {
|
||||
const totalVolume = metrics.reduce((sum, m) => sum + m.volume, 0);
|
||||
const avgCV = metrics.reduce((sum, m) => sum + m.cv_aht, 0) / metrics.length;
|
||||
const avgTransferRate = metrics.reduce((sum, m) => sum + m.transfer_rate, 0) / metrics.length;
|
||||
|
||||
// Predictibilidad
|
||||
const predictability = Math.max(0, Math.min(10, 10 - ((avgCV - 0.3) / 1.2 * 10)));
|
||||
|
||||
// Complejidad Inversa
|
||||
const complexity_inverse = Math.max(0, Math.min(10, 10 - (avgTransferRate / 10)));
|
||||
|
||||
// ROI (simplificado)
|
||||
const roi = Math.min(10, totalVolume / 1000);
|
||||
|
||||
// Repetitividad (basada en volumen)
|
||||
const repetitiveness = Math.min(10, totalVolume / 500);
|
||||
|
||||
// Score final
|
||||
const score = Math.round((predictability * 0.4 + complexity_inverse * 0.35 + repetitiveness * 0.25) * 10) / 10;
|
||||
|
||||
// Tier basado en score
|
||||
let tier: TierKey;
|
||||
if (score >= 8) tier = 'gold';
|
||||
else if (score >= 5) tier = 'silver';
|
||||
else tier = 'bronze';
|
||||
|
||||
// Sub-factors
|
||||
const sub_factors: SubFactor[] = [
|
||||
{
|
||||
name: 'predictibilidad',
|
||||
displayName: 'Predictibilidad',
|
||||
score: Math.round(predictability * 10) / 10,
|
||||
weight: 0.4,
|
||||
description: `CV AHT promedio: ${Math.round(avgCV * 100)}%`
|
||||
},
|
||||
{
|
||||
name: 'complejidad_inversa',
|
||||
displayName: 'Complejidad Inversa',
|
||||
score: Math.round(complexity_inverse * 10) / 10,
|
||||
weight: 0.35,
|
||||
description: `Tasa de transferencia promedio: ${Math.round(avgTransferRate)}%`
|
||||
},
|
||||
{
|
||||
name: 'repetitividad',
|
||||
displayName: 'Repetitividad',
|
||||
score: Math.round(repetitiveness * 10) / 10,
|
||||
weight: 0.25,
|
||||
description: `Volumen total: ${totalVolume.toLocaleString('es-ES')} interacciones`
|
||||
}
|
||||
];
|
||||
|
||||
// Interpretation
|
||||
let interpretation: string;
|
||||
if (score >= 8) {
|
||||
interpretation = 'Excelente candidato para automatización. Alta predictibilidad, baja complejidad y volumen significativo.';
|
||||
} else if (score >= 5) {
|
||||
interpretation = 'Buen candidato para asistencia con IA. Considere implementar copilots o asistentes virtuales.';
|
||||
} else {
|
||||
interpretation = 'Requiere optimización previa. Enfóquese en estandarizar procesos y reducir variabilidad antes de automatizar.';
|
||||
}
|
||||
|
||||
return {
|
||||
score,
|
||||
sub_factors,
|
||||
tier,
|
||||
confidence: totalVolume > 1000 ? 'high' as const : totalVolume > 500 ? 'medium' as const : 'low' as const,
|
||||
interpretation
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generar findings desde datos reales
|
||||
*/
|
||||
function generateFindingsFromRealData(metrics: SkillMetrics[], interactions: RawInteraction[]): Finding[] {
|
||||
const findings: Finding[] = [];
|
||||
|
||||
// Finding 1: Variabilidad
|
||||
const highVariabilitySkills = metrics.filter(m => m.cv_aht > 0.45);
|
||||
if (highVariabilitySkills.length > 0) {
|
||||
findings.push({
|
||||
type: 'warning',
|
||||
title: 'Alta Variabilidad de AHT',
|
||||
description: `${highVariabilitySkills.length} skills muestran CV > 45%, sugiriendo procesos poco estandarizados.`
|
||||
});
|
||||
}
|
||||
|
||||
// Finding 2: Transferencias
|
||||
const highTransferSkills = metrics.filter(m => m.transfer_rate > 20);
|
||||
if (highTransferSkills.length > 0) {
|
||||
findings.push({
|
||||
type: 'warning',
|
||||
title: 'Alta Tasa de Transferencia',
|
||||
description: `${highTransferSkills.length} skills con transfer rate > 20%.`
|
||||
});
|
||||
}
|
||||
|
||||
// Finding 3: Volumen
|
||||
const topSkill = metrics[0];
|
||||
findings.push({
|
||||
type: 'info',
|
||||
title: 'Skill de Mayor Volumen',
|
||||
description: `"${topSkill.skill}" representa el ${Math.round(topSkill.volume / interactions.length * 100)}% del volumen total.`
|
||||
});
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generar recomendaciones desde datos reales
|
||||
*/
|
||||
function generateRecommendationsFromRealData(metrics: SkillMetrics[]): Recommendation[] {
|
||||
const recommendations: Recommendation[] = [];
|
||||
|
||||
const highVariabilitySkills = metrics.filter(m => m.cv_aht > 0.45);
|
||||
if (highVariabilitySkills.length > 0) {
|
||||
recommendations.push({
|
||||
priority: 'high',
|
||||
title: 'Estandarizar Procesos',
|
||||
description: `Crear guías y scripts para los ${highVariabilitySkills.length} skills con alta variabilidad.`,
|
||||
impact: 'Reducción del 20-30% en AHT'
|
||||
});
|
||||
}
|
||||
|
||||
const highVolumeSkills = metrics.filter(m => m.volume > 500);
|
||||
if (highVolumeSkills.length > 0) {
|
||||
recommendations.push({
|
||||
priority: 'high',
|
||||
title: 'Automatizar Skills de Alto Volumen',
|
||||
description: `Implementar bots para los ${highVolumeSkills.length} skills con > 500 interacciones.`,
|
||||
impact: 'Ahorro estimado del 40-60%'
|
||||
});
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generar opportunities desde datos reales
|
||||
*/
|
||||
function generateOpportunitiesFromRealData(metrics: SkillMetrics[], costPerHour: number): Opportunity[] {
|
||||
return metrics.slice(0, 10).map((m, index) => {
|
||||
const potentialSavings = m.total_cost * 0.4; // 40% de ahorro potencial
|
||||
|
||||
return {
|
||||
id: `opp-${index + 1}`,
|
||||
skill: m.skill,
|
||||
currentVolume: m.volume,
|
||||
currentAHT: Math.round(m.aht_mean),
|
||||
currentCost: Math.round(m.total_cost),
|
||||
potentialSavings: Math.round(potentialSavings),
|
||||
automationPotential: m.cv_aht < 0.3 && m.transfer_rate < 15 ? 'high' : m.cv_aht < 0.5 ? 'medium' : 'low',
|
||||
priority: index < 3 ? 'high' : index < 7 ? 'medium' : 'low'
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generar roadmap desde opportunities
|
||||
*/
|
||||
function generateRoadmapFromRealData(opportunities: Opportunity[]): RoadmapInitiative[] {
|
||||
const highPriority = opportunities.filter(o => o.priority === 'high');
|
||||
|
||||
return highPriority.slice(0, 5).map((opp, index) => ({
|
||||
id: `init-${index + 1}`,
|
||||
title: `Automatizar ${opp.skill}`,
|
||||
description: `Implementar bot para reducir AHT y coste`,
|
||||
phase: index < 2 ? RoadmapPhase.QUICK_WINS : RoadmapPhase.STRATEGIC,
|
||||
effort: opp.currentVolume > 1000 ? 'high' : 'medium',
|
||||
impact: opp.potentialSavings > 10000 ? 'high' : 'medium',
|
||||
timeline: `${index * 2 + 1}-${index * 2 + 3} meses`
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generar economic model desde datos reales
|
||||
*/
|
||||
function generateEconomicModelFromRealData(metrics: SkillMetrics[], costPerHour: number): EconomicModelData {
|
||||
const totalCost = metrics.reduce((sum, m) => sum + m.total_cost, 0);
|
||||
const annualSavings = Math.round(totalCost * 0.35);
|
||||
const initialInvestment = Math.round(totalCost * 0.1);
|
||||
const paybackMonths = Math.ceil((initialInvestment / annualSavings) * 12);
|
||||
const roi3yr = (((annualSavings * 3) - initialInvestment) / initialInvestment) * 100;
|
||||
|
||||
// NPV con tasa de descuento 10%
|
||||
const discountRate = 0.10;
|
||||
const npv = -initialInvestment +
|
||||
(annualSavings / (1 + discountRate)) +
|
||||
(annualSavings / Math.pow(1 + discountRate, 2)) +
|
||||
(annualSavings / Math.pow(1 + discountRate, 3));
|
||||
|
||||
const savingsBreakdown = [
|
||||
{ category: 'Automatización de tareas', amount: annualSavings * 0.45, percentage: 45 },
|
||||
{ category: 'Eficiencia operativa', amount: annualSavings * 0.30, percentage: 30 },
|
||||
{ category: 'Mejora FCR', amount: annualSavings * 0.15, percentage: 15 },
|
||||
{ category: 'Reducción attrition', amount: annualSavings * 0.075, percentage: 7.5 },
|
||||
{ category: 'Otros', amount: annualSavings * 0.025, percentage: 2.5 },
|
||||
];
|
||||
|
||||
const costBreakdown = [
|
||||
{ category: 'Software y licencias', amount: initialInvestment * 0.43, percentage: 43 },
|
||||
{ category: 'Implementación', amount: initialInvestment * 0.29, percentage: 29 },
|
||||
{ category: 'Training y change mgmt', amount: initialInvestment * 0.18, percentage: 18 },
|
||||
{ category: 'Contingencia', amount: initialInvestment * 0.10, percentage: 10 },
|
||||
];
|
||||
|
||||
return {
|
||||
currentAnnualCost: Math.round(totalCost),
|
||||
futureAnnualCost: Math.round(totalCost - annualSavings),
|
||||
annualSavings,
|
||||
initialInvestment,
|
||||
paybackMonths,
|
||||
roi3yr: parseFloat(roi3yr.toFixed(1)),
|
||||
npv: Math.round(npv),
|
||||
savingsBreakdown,
|
||||
costBreakdown
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generar benchmark desde datos reales
|
||||
*/
|
||||
function generateBenchmarkFromRealData(metrics: SkillMetrics[]): BenchmarkDataPoint[] {
|
||||
const avgAHT = metrics.reduce((sum, m) => sum + m.aht_mean, 0) / (metrics.length || 1);
|
||||
const avgFCR = 100 - (metrics.reduce((sum, m) => sum + m.transfer_rate, 0) / (metrics.length || 1));
|
||||
const avgCSAT = 4.3; // Default CSAT
|
||||
const avgCPI = 3.5; // Default CPI
|
||||
|
||||
return [
|
||||
{
|
||||
kpi: 'AHT Promedio',
|
||||
userValue: Math.round(avgAHT),
|
||||
userDisplay: `${Math.round(avgAHT)}s`,
|
||||
industryValue: 420,
|
||||
industryDisplay: `420s`,
|
||||
percentile: Math.max(10, Math.min(90, Math.round(100 - (avgAHT / 420) * 100))),
|
||||
p25: 380,
|
||||
p50: 420,
|
||||
p75: 460,
|
||||
p90: 510
|
||||
},
|
||||
{
|
||||
kpi: 'Tasa FCR',
|
||||
userValue: avgFCR / 100,
|
||||
userDisplay: `${Math.round(avgFCR)}%`,
|
||||
industryValue: 0.72,
|
||||
industryDisplay: `72%`,
|
||||
percentile: Math.max(10, Math.min(90, Math.round((avgFCR / 100) * 100))),
|
||||
p25: 0.65,
|
||||
p50: 0.72,
|
||||
p75: 0.82,
|
||||
p90: 0.88
|
||||
},
|
||||
{
|
||||
kpi: 'CSAT',
|
||||
userValue: avgCSAT,
|
||||
userDisplay: `${avgCSAT}/5`,
|
||||
industryValue: 4.3,
|
||||
industryDisplay: `4.3/5`,
|
||||
percentile: 65,
|
||||
p25: 3.8,
|
||||
p50: 4.3,
|
||||
p75: 4.6,
|
||||
p90: 4.8
|
||||
},
|
||||
{
|
||||
kpi: 'Coste por Interacción',
|
||||
userValue: avgCPI,
|
||||
userDisplay: `€${avgCPI.toFixed(2)}`,
|
||||
industryValue: 3.5,
|
||||
industryDisplay: `€3.50`,
|
||||
percentile: 55,
|
||||
p25: 2.8,
|
||||
p50: 3.5,
|
||||
p75: 4.2,
|
||||
p90: 4.8
|
||||
}
|
||||
];
|
||||
}
|
||||
200
frontend/utils/segmentClassifier.ts
Normal file
200
frontend/utils/segmentClassifier.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
// utils/segmentClassifier.ts
|
||||
// Utilidad para clasificar colas/skills en segmentos de cliente
|
||||
|
||||
import type { CustomerSegment, RawInteraction, StaticConfig } from '../types';
|
||||
|
||||
export interface SegmentMapping {
|
||||
high_value_queues: string[];
|
||||
medium_value_queues: string[];
|
||||
low_value_queues: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsea string de colas separadas por comas
|
||||
* Ejemplo: "VIP, Premium, Enterprise" → ["VIP", "Premium", "Enterprise"]
|
||||
*/
|
||||
export function parseQueueList(input: string): string[] {
|
||||
if (!input || input.trim().length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return input
|
||||
.split(',')
|
||||
.map(q => q.trim())
|
||||
.filter(q => q.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clasifica una cola según el mapeo proporcionado
|
||||
* Usa matching parcial y case-insensitive
|
||||
*
|
||||
* Ejemplo:
|
||||
* - queue: "VIP_Support" + mapping.high: ["VIP"] → "high"
|
||||
* - queue: "Soporte_General_N1" + mapping.medium: ["Soporte_General"] → "medium"
|
||||
* - queue: "Retencion" (no match) → "medium" (default)
|
||||
*/
|
||||
export function classifyQueue(
|
||||
queue: string,
|
||||
mapping: SegmentMapping
|
||||
): CustomerSegment {
|
||||
const normalizedQueue = queue.toLowerCase().trim();
|
||||
|
||||
// Buscar en high value
|
||||
for (const highQueue of mapping.high_value_queues) {
|
||||
const normalizedHigh = highQueue.toLowerCase().trim();
|
||||
if (normalizedQueue.includes(normalizedHigh) || normalizedHigh.includes(normalizedQueue)) {
|
||||
return 'high';
|
||||
}
|
||||
}
|
||||
|
||||
// Buscar en low value
|
||||
for (const lowQueue of mapping.low_value_queues) {
|
||||
const normalizedLow = lowQueue.toLowerCase().trim();
|
||||
if (normalizedQueue.includes(normalizedLow) || normalizedLow.includes(normalizedQueue)) {
|
||||
return 'low';
|
||||
}
|
||||
}
|
||||
|
||||
// Buscar en medium value (explícito)
|
||||
for (const mediumQueue of mapping.medium_value_queues) {
|
||||
const normalizedMedium = mediumQueue.toLowerCase().trim();
|
||||
if (normalizedQueue.includes(normalizedMedium) || normalizedMedium.includes(normalizedQueue)) {
|
||||
return 'medium';
|
||||
}
|
||||
}
|
||||
|
||||
// Default: medium (para colas no mapeadas)
|
||||
return 'medium';
|
||||
}
|
||||
|
||||
/**
|
||||
* Clasifica todas las colas únicas de un conjunto de interacciones
|
||||
* Retorna un mapa de cola → segmento
|
||||
*/
|
||||
export function classifyAllQueues(
|
||||
interactions: RawInteraction[],
|
||||
mapping: SegmentMapping
|
||||
): Map<string, CustomerSegment> {
|
||||
const queueSegments = new Map<string, CustomerSegment>();
|
||||
|
||||
// Obtener colas únicas
|
||||
const uniqueQueues = [...new Set(interactions.map(i => i.queue_skill))];
|
||||
|
||||
// Clasificar cada cola
|
||||
uniqueQueues.forEach(queue => {
|
||||
queueSegments.set(queue, classifyQueue(queue, mapping));
|
||||
});
|
||||
|
||||
return queueSegments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera estadísticas de segmentación
|
||||
* Retorna conteo, porcentaje y lista de colas por segmento
|
||||
*/
|
||||
export function getSegmentationStats(
|
||||
interactions: RawInteraction[],
|
||||
queueSegments: Map<string, CustomerSegment>
|
||||
): {
|
||||
high: { count: number; percentage: number; queues: string[] };
|
||||
medium: { count: number; percentage: number; queues: string[] };
|
||||
low: { count: number; percentage: number; queues: string[] };
|
||||
total: number;
|
||||
} {
|
||||
const stats = {
|
||||
high: { count: 0, percentage: 0, queues: [] as string[] },
|
||||
medium: { count: 0, percentage: 0, queues: [] as string[] },
|
||||
low: { count: 0, percentage: 0, queues: [] as string[] },
|
||||
total: interactions.length
|
||||
};
|
||||
|
||||
// Contar interacciones por segmento
|
||||
interactions.forEach(interaction => {
|
||||
const segment = queueSegments.get(interaction.queue_skill) || 'medium';
|
||||
stats[segment].count++;
|
||||
});
|
||||
|
||||
// Calcular porcentajes
|
||||
const total = interactions.length;
|
||||
if (total > 0) {
|
||||
stats.high.percentage = Math.round((stats.high.count / total) * 100);
|
||||
stats.medium.percentage = Math.round((stats.medium.count / total) * 100);
|
||||
stats.low.percentage = Math.round((stats.low.count / total) * 100);
|
||||
}
|
||||
|
||||
// Obtener colas por segmento (únicas)
|
||||
queueSegments.forEach((segment, queue) => {
|
||||
if (!stats[segment].queues.includes(queue)) {
|
||||
stats[segment].queues.push(queue);
|
||||
}
|
||||
});
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida que el mapeo tenga al menos una cola en algún segmento
|
||||
*/
|
||||
export function isValidMapping(mapping: SegmentMapping): boolean {
|
||||
return (
|
||||
mapping.high_value_queues.length > 0 ||
|
||||
mapping.medium_value_queues.length > 0 ||
|
||||
mapping.low_value_queues.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea un mapeo desde StaticConfig
|
||||
* Si no hay segment_mapping, retorna mapeo vacío
|
||||
*/
|
||||
export function getMappingFromConfig(config: StaticConfig): SegmentMapping | null {
|
||||
if (!config.segment_mapping) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
high_value_queues: config.segment_mapping.high_value_queues || [],
|
||||
medium_value_queues: config.segment_mapping.medium_value_queues || [],
|
||||
low_value_queues: config.segment_mapping.low_value_queues || []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el segmento para una cola específica desde el config
|
||||
* Si no hay mapeo, retorna 'medium' por defecto
|
||||
*/
|
||||
export function getSegmentForQueue(
|
||||
queue: string,
|
||||
config: StaticConfig
|
||||
): CustomerSegment {
|
||||
const mapping = getMappingFromConfig(config);
|
||||
|
||||
if (!mapping || !isValidMapping(mapping)) {
|
||||
return 'medium';
|
||||
}
|
||||
|
||||
return classifyQueue(queue, mapping);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatea estadísticas para mostrar en UI
|
||||
*/
|
||||
export function formatSegmentationSummary(
|
||||
stats: ReturnType<typeof getSegmentationStats>
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (stats.high.count > 0) {
|
||||
parts.push(`${stats.high.percentage}% High Value (${stats.high.count} interacciones)`);
|
||||
}
|
||||
|
||||
if (stats.medium.count > 0) {
|
||||
parts.push(`${stats.medium.percentage}% Medium Value (${stats.medium.count} interacciones)`);
|
||||
}
|
||||
|
||||
if (stats.low.count > 0) {
|
||||
parts.push(`${stats.low.percentage}% Low Value (${stats.low.count} interacciones)`);
|
||||
}
|
||||
|
||||
return parts.join(' | ');
|
||||
}
|
||||
99
frontend/utils/syntheticDataGenerator.ts
Normal file
99
frontend/utils/syntheticDataGenerator.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { DATA_REQUIREMENTS } from '../constants';
|
||||
import { TierKey, Field } from '../types';
|
||||
|
||||
// Helper functions for randomness
|
||||
const randomInt = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
const randomFromList = <T,>(arr: T[]): T => arr[Math.floor(Math.random() * arr.length)];
|
||||
const randomDate = (start: Date, end: Date): Date => new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime()));
|
||||
|
||||
const generateFieldValue = (field: Field, rowData: Map<string, any>): string | number | boolean => {
|
||||
const name = field.name.toLowerCase();
|
||||
|
||||
if (name.includes('id') || name.includes('unique')) {
|
||||
return `${randomFromList(['INT', 'TR', 'SES', 'CUST'])}-${randomInt(100000, 999999)}-${randomInt(1000, 9999)}`;
|
||||
}
|
||||
if (name.includes('timestamp_start')) {
|
||||
const date = randomDate(new Date(Date.now() - 180 * 24 * 60 * 60 * 1000), new Date());
|
||||
rowData.set('timestamp_start', date);
|
||||
return date.toISOString().replace('T', ' ').substring(0, 19);
|
||||
}
|
||||
if (name.includes('fecha')) {
|
||||
const date = randomDate(new Date(Date.now() - 180 * 24 * 60 * 60 * 1000), new Date());
|
||||
return date.toISOString().substring(0, 10);
|
||||
}
|
||||
if (name.includes('timestamp_end')) {
|
||||
const startDate = rowData.get('timestamp_start') || new Date();
|
||||
const durationSeconds = randomInt(60, 1200);
|
||||
const endDate = new Date(startDate.getTime() + durationSeconds * 1000);
|
||||
return endDate.toISOString().replace('T', ' ').substring(0, 19);
|
||||
}
|
||||
if (name.includes('hora')) {
|
||||
return `${String(randomInt(8,19)).padStart(2,'0')}:${String(randomInt(0,59)).padStart(2,'0')}`;
|
||||
}
|
||||
if (name.includes('channel') || name.includes('canal')) {
|
||||
return randomFromList(['voice', 'chat', 'email', 'whatsapp']);
|
||||
}
|
||||
if (name.includes('skill') || name.includes('queue') || name.includes('tipo')) {
|
||||
return randomFromList(['soporte_tecnico', 'facturacion', 'ventas', 'renovaciones', 'informacion']);
|
||||
}
|
||||
if (name.includes('aht')) return randomInt(180, 600);
|
||||
if (name.includes('talk_time')) return randomInt(120, 450);
|
||||
if (name.includes('hold_time')) return randomInt(10, 90);
|
||||
if (name.includes('acw')) return randomInt(15, 120);
|
||||
if (name.includes('speed_of_answer')) return randomInt(5, 60);
|
||||
if (name.includes('duracion_minutos')) {
|
||||
return (randomInt(2, 20) + Math.random()).toFixed(2);
|
||||
}
|
||||
if (name.includes('resolved') || name.includes('transferred') || name.includes('abandoned') || name.includes('exception_flag')) {
|
||||
return randomFromList([true, false]);
|
||||
}
|
||||
if (name.includes('reason') || name.includes('disposition')) {
|
||||
return randomFromList(['consulta_saldo', 'reclamacion', 'soporte_producto', 'duda_factura', 'compra_exitosa', 'baja_servicio']);
|
||||
}
|
||||
if (name.includes('score')) {
|
||||
if (name.includes('nps')) return randomInt(-100, 100);
|
||||
if (name.includes('ces')) return randomInt(1, 7);
|
||||
return randomInt(1, 10);
|
||||
}
|
||||
if (name.includes('coste_hora_agente') || name.includes('labor_cost_per_hour')) {
|
||||
return (18 + Math.random() * 15).toFixed(2);
|
||||
}
|
||||
if (name.includes('overhead_rate') || name.includes('structured_fields_pct')) {
|
||||
return Math.random().toFixed(2);
|
||||
}
|
||||
if (name.includes('tech_licenses_annual')) {
|
||||
return randomInt(25000, 100000);
|
||||
}
|
||||
if (name.includes('num_agentes_promedio')) {
|
||||
return randomInt(20, 50);
|
||||
}
|
||||
|
||||
// Fallback for any other type
|
||||
return 'N/A';
|
||||
};
|
||||
|
||||
export const generateSyntheticCsv = (tier: TierKey): string => {
|
||||
const requirements = DATA_REQUIREMENTS[tier];
|
||||
if (!requirements) {
|
||||
return '';
|
||||
}
|
||||
const allFields = requirements.mandatory.flatMap(cat => cat.fields);
|
||||
const headers = allFields.map(field => field.name).join(',');
|
||||
|
||||
const rows: string[] = [];
|
||||
const numRows = randomInt(250, 500);
|
||||
|
||||
for (let i = 0; i < numRows; i++) {
|
||||
const rowData = new Map<string, any>();
|
||||
const row = allFields.map(field => {
|
||||
let value = generateFieldValue(field, rowData);
|
||||
if (typeof value === 'string' && value.includes(',')) {
|
||||
return `"${value}"`;
|
||||
}
|
||||
return value;
|
||||
}).join(',');
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
return `${headers}\n${rows.join('\n')}`;
|
||||
};
|
||||
Reference in New Issue
Block a user