Ajustes en la conexión front-back y eliminación datos demo

This commit is contained in:
igferne
2026-01-02 19:28:31 +01:00
parent 2cd6d6b95c
commit 4c8d32dd45
9 changed files with 1423 additions and 17 deletions

View File

@@ -33,6 +33,7 @@
"class": "beyond_metrics.dimensions.SatisfactionExperience.SatisfactionExperienceMetrics", "class": "beyond_metrics.dimensions.SatisfactionExperience.SatisfactionExperienceMetrics",
"enabled": true, "enabled": true,
"metrics": [ "metrics": [
"csat_global",
"csat_avg_by_skill_channel", "csat_avg_by_skill_channel",
"nps_avg_by_skill_channel", "nps_avg_by_skill_channel",
"ces_avg_by_skill_channel", "ces_avg_by_skill_channel",

View File

@@ -7,7 +7,7 @@ import numpy as np
import pandas as pd import pandas as pd
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
from matplotlib.axes import Axes from matplotlib.axes import Axes
import math
REQUIRED_COLUMNS_OP: List[str] = [ REQUIRED_COLUMNS_OP: List[str] = [
"interaction_id", "interaction_id",
@@ -165,21 +165,57 @@ class OperationalPerformanceMetrics:
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def fcr_rate(self) -> float: def fcr_rate(self) -> float:
""" """
FCR = % de interacciones resueltas en el primer contacto. FCR proxy = 100 - escalation_rate.
Definido como % de filas con is_resolved == True. Usamos la métrica de escalación ya calculada a partir de transfer_flag.
Si la columna no existe, devuelve NaN. Si no se puede calcular escalation_rate, intentamos derivarlo
directamente de la columna transfer_flag. Si todo falla, devolvemos NaN.
""" """
try:
esc = self.escalation_rate()
except Exception:
esc = float("nan")
# Si escalation_rate es válido, usamos el proxy simple
if esc is not None and not math.isnan(esc):
fcr = 100.0 - esc
return float(max(0.0, min(100.0, round(fcr, 2))))
# Fallback: calcular directamente desde transfer_flag
df = self.df df = self.df
if "is_resolved" not in df.columns: if "transfer_flag" not in df.columns or len(df) == 0:
return float("nan") return float("nan")
col = df["transfer_flag"]
# Normalizar a booleano: TRUE/FALSE, 1/0, etc.
if col.dtype == "O":
col_norm = (
col.astype(str)
.str.strip()
.str.lower()
.map({
"true": True,
"t": True,
"1": True,
"yes": True,
"y": True,
})
).fillna(False)
transfer_mask = col_norm
else:
transfer_mask = pd.to_numeric(col, errors="coerce").fillna(0) > 0
total = len(df) total = len(df)
if total == 0: transfers = int(transfer_mask.sum())
esc_rate = transfers / total if total > 0 else float("nan")
if math.isnan(esc_rate):
return float("nan") return float("nan")
resolved = df["is_resolved"].sum() fcr = 100.0 - esc_rate * 100.0
return float(round(resolved / total * 100, 2)) return float(max(0.0, min(100.0, round(fcr, 2))))
def escalation_rate(self) -> float: def escalation_rate(self) -> float:
""" """

View File

@@ -161,6 +161,26 @@ class SatisfactionExperienceMetrics:
) )
return pivot return pivot
def csat_global(self) -> float:
"""
CSAT medio global (todas las interacciones).
Usa la columna opcional `csat_score`:
- Si no existe, devuelve NaN.
- Si todos los valores son NaN / vacíos, devuelve NaN.
"""
df = self.df
if "csat_score" not in df.columns:
return float("nan")
series = pd.to_numeric(df["csat_score"], errors="coerce").dropna()
if series.empty:
return float("nan")
mean = series.mean()
return float(round(mean, 2))
def csat_aht_correlation(self) -> Dict[str, Any]: def csat_aht_correlation(self) -> Dict[str, Any]:
""" """
Correlación Pearson CSAT vs AHT. Correlación Pearson CSAT vs AHT.

File diff suppressed because it is too large Load Diff

View File

@@ -58,7 +58,8 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
{ name: 'wrap_up_time', type: 'Segundos', example: '30', required: true }, { name: 'wrap_up_time', type: 'Segundos', example: '30', required: true },
{ name: 'agent_id', type: 'String', example: 'Agente_045', required: true }, { name: 'agent_id', type: 'String', example: 'Agente_045', required: true },
{ name: 'transfer_flag', type: 'Boolean', example: 'TRUE / FALSE', required: true }, { name: 'transfer_flag', type: 'Boolean', example: 'TRUE / FALSE', required: true },
{ name: 'caller_id', type: 'String (hash)', example: 'Hash_99283', required: false } { name: 'caller_id', type: 'String (hash)', example: 'Hash_99283', required: false },
{ name: 'csat_score', type: 'Float', example: '4', required: false }
]; ];
const handleDownloadTemplate = () => { const handleDownloadTemplate = () => {

View File

@@ -1,6 +1,7 @@
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
export type TierKey = 'gold' | 'silver' | 'bronze'; export type TierKey = 'gold' | 'silver' | 'bronze';
export type AnalysisSource = 'synthetic' | 'backend' | 'fallback';
export interface Tier { export interface Tier {
name: string; name: string;
@@ -269,4 +270,5 @@ export interface AnalysisData {
benchmarkData: BenchmarkDataPoint[]; // Actualizado de benchmarkReport benchmarkData: BenchmarkDataPoint[]; // Actualizado de benchmarkReport
agenticReadiness?: AgenticReadinessResult; // v2.0: Nuevo campo agenticReadiness?: AgenticReadinessResult; // v2.0: Nuevo campo
staticConfig?: StaticConfig; // v2.0: Configuración estática usada staticConfig?: StaticConfig; // v2.0: Configuración estática usada
source?: AnalysisSource;
} }

View File

@@ -5,7 +5,11 @@ import { RoadmapPhase } from '../types';
import { BarChartHorizontal, Zap, Smile, DollarSign, Target, Globe } from 'lucide-react'; import { BarChartHorizontal, Zap, Smile, DollarSign, Target, Globe } from 'lucide-react';
import { calculateAgenticReadinessScore, type AgenticReadinessInput } from './agenticReadinessV2'; import { calculateAgenticReadinessScore, type AgenticReadinessInput } from './agenticReadinessV2';
import { callAnalysisApiRaw } from './apiClient'; import { callAnalysisApiRaw } from './apiClient';
import { mapBackendResultsToAnalysisData } from './backendMapper'; import {
mapBackendResultsToAnalysisData,
buildHeatmapFromBackend,
} from './backendMapper';
const randomInt = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min; const randomInt = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min;
@@ -568,8 +572,18 @@ export const generateAnalysis = async (
mapped.recommendations = generateRecommendationsFromTemplates(); mapped.recommendations = generateRecommendationsFromTemplates();
mapped.opportunities = generateOpportunityMatrixData(); mapped.opportunities = generateOpportunityMatrixData();
mapped.roadmap = generateRoadmapData(); mapped.roadmap = generateRoadmapData();
mapped.benchmarkData = generateBenchmarkData();
mapped.heatmapData = generateHeatmapData(costPerHour, avgCsat, segmentMapping); // Benchmark: de momento no tenemos datos reales -> no lo generamos en modo backend
mapped.benchmarkData = [];
// Heatmap: ahora se construye a partir de datos reales del backend
mapped.heatmapData = buildHeatmapFromBackend(
raw,
costPerHour,
avgCsat,
segmentMapping
);
console.log('✅ Usando resultados del backend mapeados + findings/benchmark del frontend'); console.log('✅ Usando resultados del backend mapeados + findings/benchmark del frontend');
return mapped; return mapped;
@@ -734,6 +748,7 @@ const generateSyntheticAnalysis = (
economicModel: generateEconomicModelData(), economicModel: generateEconomicModelData(),
roadmap: generateRoadmapData(), roadmap: generateRoadmapData(),
benchmarkData: generateBenchmarkData(), benchmarkData: generateBenchmarkData(),
source: 'synthetic',
}; };
}; };

View File

@@ -10,6 +10,8 @@ import type {
} from '../types'; } from '../types';
import type { BackendRawResults } from './apiClient'; import type { BackendRawResults } from './apiClient';
import { BarChartHorizontal, Zap, DollarSign } from 'lucide-react'; import { BarChartHorizontal, Zap, DollarSign } from 'lucide-react';
import type { HeatmapDataPoint, CustomerSegment } from '../types';
function safeNumber(value: any, fallback = 0): number { function safeNumber(value: any, fallback = 0): number {
const n = typeof value === 'number' ? value : Number(value); const n = typeof value === 'number' ? value : Number(value);
@@ -577,17 +579,67 @@ export function mapBackendResultsToAnalysisData(
if (performanceDimension) dimensions.push(performanceDimension); if (performanceDimension) dimensions.push(performanceDimension);
if (economyDimension) dimensions.push(economyDimension); if (economyDimension) dimensions.push(economyDimension);
// KPIs de resumen const op = raw?.operational_performance;
const cs = raw?.customer_satisfaction;
// FCR: viene ya como porcentaje 0-100
const fcrPctRaw = safeNumber(op?.fcr_rate, NaN);
const fcrPct =
Number.isFinite(fcrPctRaw) && fcrPctRaw >= 0
? Math.min(100, Math.max(0, fcrPctRaw))
: undefined;
const csatAvg = computeCsatAverage(cs);
// CSAT global (opcional)
const csatGlobalRaw = safeNumber(cs?.csat_global, NaN);
const csatGlobal =
Number.isFinite(csatGlobalRaw) && csatGlobalRaw > 0
? csatGlobalRaw
: undefined;
// KPIs de resumen (los 4 primeros son los que se ven en "Métricas de Contacto")
const summaryKpis: Kpi[] = []; const summaryKpis: Kpi[] = [];
// 1) Interacciones Totales (volumen backend)
summaryKpis.push({ summaryKpis.push({
label: 'Volumen total (estimado)', label: 'Interacciones Totales',
value: value:
totalVolume > 0 totalVolume > 0
? totalVolume.toLocaleString('es-ES') ? totalVolume.toLocaleString('es-ES')
: 'N/A', : 'N/D',
}); });
// 2) AHT Promedio (P50 de distribución de AHT)
const ahtP50 = safeNumber(op?.aht_distribution?.p50, 0);
summaryKpis.push({
label: 'AHT Promedio',
value: ahtP50
? `${Math.round(ahtP50)}s`
: 'N/D',
});
// 3) Tasa FCR
summaryKpis.push({
label: 'Tasa FCR',
value:
fcrPct !== undefined
? `${Math.round(fcrPct)}%`
: 'N/D',
});
// 4) CSAT
summaryKpis.push({
label: 'CSAT',
value:
csatGlobal !== undefined
? `${csatGlobal.toFixed(1)}/5`
: 'N/D',
});
// --- KPIs adicionales, usados en otras secciones ---
if (numChannels > 0) { if (numChannels > 0) {
summaryKpis.push({ summaryKpis.push({
label: 'Canales analizados', label: 'Canales analizados',
@@ -607,7 +659,7 @@ export function mapBackendResultsToAnalysisData(
value: `${arScore.toFixed(1)}/10`, value: `${arScore.toFixed(1)}/10`,
}); });
// KPIs de economía // KPIs de economía (backend)
const econ = raw?.economy_costs; const econ = raw?.economy_costs;
const totalAnnual = safeNumber( const totalAnnual = safeNumber(
econ?.cost_breakdown?.total_annual, econ?.cost_breakdown?.total_annual,
@@ -649,5 +701,277 @@ export function mapBackendResultsToAnalysisData(
benchmarkData: [], benchmarkData: [],
agenticReadiness, agenticReadiness,
staticConfig: undefined, staticConfig: undefined,
source: 'backend',
}; };
} }
export function buildHeatmapFromBackend(
raw: BackendRawResults,
costPerHour: number,
avgCsat: number,
segmentMapping?: {
high_value_queues: string[];
medium_value_queues: string[];
low_value_queues: string[];
}
): HeatmapDataPoint[] {
const volumetry = raw?.volumetry;
const volumeBySkill = volumetry?.volume_by_skill;
const rawSkillLabels =
volumeBySkill?.labels ??
volumeBySkill?.skills ??
volumeBySkill?.skill_names ??
[];
const skillLabels: string[] = Array.isArray(rawSkillLabels)
? rawSkillLabels.map((s: any) => String(s))
: [];
const skillVolumes: number[] = Array.isArray(volumeBySkill?.values)
? volumeBySkill.values.map((v: any) => safeNumber(v, 0))
: [];
const op = raw?.operational_performance;
const econ = raw?.economy_costs;
const cs = raw?.customer_satisfaction;
const talkHoldAcwBySkill = Array.isArray(
op?.talk_hold_acw_p50_by_skill
)
? op.talk_hold_acw_p50_by_skill
: [];
const globalEscalation = safeNumber(op?.escalation_rate, 0);
const globalFcrPct = Math.max(
0,
Math.min(100, 100 - globalEscalation)
);
const csatGlobalRaw = safeNumber(cs?.csat_global, NaN);
const csatGlobal =
Number.isFinite(csatGlobalRaw) && csatGlobalRaw > 0
? csatGlobalRaw
: undefined;
const csatMetric0_100 = csatGlobal
? Math.max(
0,
Math.min(100, Math.round((csatGlobal / 5) * 100))
)
: 0;
const ineffBySkill = Array.isArray(
econ?.inefficiency_cost_by_skill_channel
)
? econ.inefficiency_cost_by_skill_channel
: [];
const COST_PER_SECOND = costPerHour / 3600;
if (!skillLabels.length) return [];
// Para normalizar la repetitividad según volumen
const volumesForNorm = skillVolumes.filter((v) => v > 0);
const minVol =
volumesForNorm.length > 0
? Math.min(...volumesForNorm)
: 0;
const maxVol =
volumesForNorm.length > 0
? Math.max(...volumesForNorm)
: 0;
const heatmap: HeatmapDataPoint[] = [];
for (let i = 0; i < skillLabels.length; i++) {
const skill = skillLabels[i];
const volume = safeNumber(skillVolumes[i], 0);
const talkHold = talkHoldAcwBySkill[i] || {};
const talk_p50 = safeNumber(talkHold.talk_p50, 0);
const hold_p50 = safeNumber(talkHold.hold_p50, 0);
const acw_p50 = safeNumber(talkHold.acw_p50, 0);
const aht_mean = talk_p50 + hold_p50 + acw_p50;
// Coste anual aproximado
const annual_volume = volume * 12;
const annual_cost = Math.round(
annual_volume * aht_mean * COST_PER_SECOND
);
const ineff = ineffBySkill[i] || {};
const aht_p50_backend = safeNumber(ineff.aht_p50, aht_mean);
const aht_p90_backend = safeNumber(ineff.aht_p90, aht_mean);
// Variabilidad proxy: aproximamos CV a partir de P90-P50
let cv_aht = 0;
if (aht_p50_backend > 0) {
cv_aht =
(aht_p90_backend - aht_p50_backend) / aht_p50_backend;
}
// Dimensiones agentic similares a las que tenías en generateHeatmapData,
// pero usando valores reales en lugar de aleatorios.
// 1) Predictibilidad (menor CV => mayor puntuación)
const predictability_score = Math.max(
0,
Math.min(
10,
10 - ((cv_aht - 0.3) / 1.2) * 10
)
);
// 2) Complejidad inversa (usamos la tasa global de escalación como proxy)
const transfer_rate = globalEscalation; // %
const complexity_inverse_score = Math.max(
0,
Math.min(
10,
10 - ((transfer_rate / 100 - 0.05) / 0.25) * 10
)
);
// 3) Repetitividad (según volumen relativo)
let repetitivity_score = 5;
if (maxVol > minVol && volume > 0) {
repetitivity_score =
((volume - minVol) / (maxVol - minVol)) * 10;
} else if (volume === 0) {
repetitivity_score = 0;
}
const agentic_readiness_score =
predictability_score * 0.4 +
complexity_inverse_score * 0.35 +
repetitivity_score * 0.25;
let readiness_category:
| 'automate_now'
| 'assist_copilot'
| 'optimize_first';
if (agentic_readiness_score >= 8.0) {
readiness_category = 'automate_now';
} else if (agentic_readiness_score >= 5.0) {
readiness_category = 'assist_copilot';
} else {
readiness_category = 'optimize_first';
}
const automation_readiness = Math.round(
agentic_readiness_score * 10
); // 0-100
// Métricas normalizadas 0-100 para el color del heatmap
const ahtMetric = aht_mean
? Math.max(
0,
Math.min(
100,
Math.round(
100 - ((aht_mean - 240) / 310) * 100
)
)
)
: 0;
const holdMetric = hold_p50
? Math.max(
0,
Math.min(
100,
Math.round(
100 - (hold_p50 / 120) * 100
)
)
)
: 0;
const transferMetric = Math.max(
0,
Math.min(
100,
Math.round(100 - transfer_rate)
)
);
// Clasificación por segmento (si nos pasan mapeo)
let segment: CustomerSegment | undefined;
if (segmentMapping) {
const normalizedSkill = skill.toLowerCase();
if (
segmentMapping.high_value_queues.some((q) =>
normalizedSkill.includes(q.toLowerCase())
)
) {
segment = 'high';
} else if (
segmentMapping.low_value_queues.some((q) =>
normalizedSkill.includes(q.toLowerCase())
)
) {
segment = 'low';
} else {
segment = 'medium';
}
}
heatmap.push({
skill,
segment,
volume,
aht_seconds: aht_mean,
metrics: {
fcr: Math.round(globalFcrPct),
aht: ahtMetric,
csat: csatMetric0_100,
hold_time: holdMetric,
transfer_rate: transferMetric,
},
annual_cost,
variability: {
cv_aht: Math.round(cv_aht * 100), // %
cv_talk_time: 0,
cv_hold_time: 0,
transfer_rate,
},
automation_readiness,
dimensions: {
predictability: Math.round(predictability_score * 10) / 10,
complexity_inverse:
Math.round(complexity_inverse_score * 10) / 10,
repetitivity: Math.round(repetitivity_score * 10) / 10,
},
readiness_category,
});
}
console.log('📊 Heatmap backend generado:', {
length: heatmap.length,
firstItem: heatmap[0],
});
return heatmap;
}
function computeCsatAverage(customerSatisfaction: any): number | undefined {
const arr = customerSatisfaction?.csat_avg_by_skill_channel;
if (!Array.isArray(arr) || !arr.length) return undefined;
const values: number[] = arr
.map((item: any) =>
safeNumber(
item?.csat ??
item?.value ??
item?.score,
NaN
)
)
.filter((v) => Number.isFinite(v));
if (!values.length) return undefined;
const sum = values.reduce((a, b) => a + b, 0);
return sum / values.length;
}

View File

@@ -10,3 +10,9 @@ python -m uvicorn beyond_api.main:app --reload --port 8000
# Frontend # Frontend
npm run dev npm run dev
# Siguientes pasos: que revise todo el código y quitar todo lo random para que utilice datos reales
# Comparar los sintéticos con la demo y ver que ofrecen los mismos datos. Faltan cosas
# Hacer que funcione de alguna manera el selector de JSON
# Dockerizar
# Limpieza de código