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",
"enabled": true,
"metrics": [
"csat_global",
"csat_avg_by_skill_channel",
"nps_avg_by_skill_channel",
"ces_avg_by_skill_channel",

View File

@@ -7,7 +7,7 @@ import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.axes import Axes
import math
REQUIRED_COLUMNS_OP: List[str] = [
"interaction_id",
@@ -165,21 +165,57 @@ class OperationalPerformanceMetrics:
# ------------------------------------------------------------------ #
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.
Si la columna no existe, devuelve NaN.
Usamos la métrica de escalación ya calculada a partir de transfer_flag.
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
if "is_resolved" not in df.columns:
if "transfer_flag" not in df.columns or len(df) == 0:
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)
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")
resolved = df["is_resolved"].sum()
return float(round(resolved / total * 100, 2))
fcr = 100.0 - esc_rate * 100.0
return float(max(0.0, min(100.0, round(fcr, 2))))
def escalation_rate(self) -> float:
"""

View File

@@ -161,6 +161,26 @@ class SatisfactionExperienceMetrics:
)
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]:
"""
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: 'agent_id', type: 'String', example: 'Agente_045', 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 = () => {

View File

@@ -1,6 +1,7 @@
import type { LucideIcon } from 'lucide-react';
export type TierKey = 'gold' | 'silver' | 'bronze';
export type AnalysisSource = 'synthetic' | 'backend' | 'fallback';
export interface Tier {
name: string;
@@ -269,4 +270,5 @@ export interface AnalysisData {
benchmarkData: BenchmarkDataPoint[]; // Actualizado de benchmarkReport
agenticReadiness?: AgenticReadinessResult; // v2.0: Nuevo campo
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 { calculateAgenticReadinessScore, type AgenticReadinessInput } from './agenticReadinessV2';
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;
@@ -568,8 +572,18 @@ export const generateAnalysis = async (
mapped.recommendations = generateRecommendationsFromTemplates();
mapped.opportunities = generateOpportunityMatrixData();
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');
return mapped;
@@ -734,6 +748,7 @@ const generateSyntheticAnalysis = (
economicModel: generateEconomicModelData(),
roadmap: generateRoadmapData(),
benchmarkData: generateBenchmarkData(),
source: 'synthetic',
};
};

View File

@@ -10,6 +10,8 @@ import type {
} from '../types';
import type { BackendRawResults } from './apiClient';
import { BarChartHorizontal, Zap, DollarSign } from 'lucide-react';
import type { HeatmapDataPoint, CustomerSegment } from '../types';
function safeNumber(value: any, fallback = 0): number {
const n = typeof value === 'number' ? value : Number(value);
@@ -577,17 +579,67 @@ export function mapBackendResultsToAnalysisData(
if (performanceDimension) dimensions.push(performanceDimension);
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[] = [];
// 1) Interacciones Totales (volumen backend)
summaryKpis.push({
label: 'Volumen total (estimado)',
label: 'Interacciones Totales',
value:
totalVolume > 0
? 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) {
summaryKpis.push({
label: 'Canales analizados',
@@ -607,7 +659,7 @@ export function mapBackendResultsToAnalysisData(
value: `${arScore.toFixed(1)}/10`,
});
// KPIs de economía
// KPIs de economía (backend)
const econ = raw?.economy_costs;
const totalAnnual = safeNumber(
econ?.cost_breakdown?.total_annual,
@@ -649,5 +701,277 @@ export function mapBackendResultsToAnalysisData(
benchmarkData: [],
agenticReadiness,
staticConfig: undefined,
source: 'backend',
};
}
export function buildHeatmapFromBackend(
raw: BackendRawResults,
costPerHour: number,
avgCsat: number,
segmentMapping?: {
high_value_queues: string[];
medium_value_queues: string[];
low_value_queues: string[];
}
): HeatmapDataPoint[] {
const volumetry = raw?.volumetry;
const volumeBySkill = volumetry?.volume_by_skill;
const rawSkillLabels =
volumeBySkill?.labels ??
volumeBySkill?.skills ??
volumeBySkill?.skill_names ??
[];
const skillLabels: string[] = Array.isArray(rawSkillLabels)
? rawSkillLabels.map((s: any) => String(s))
: [];
const skillVolumes: number[] = Array.isArray(volumeBySkill?.values)
? volumeBySkill.values.map((v: any) => safeNumber(v, 0))
: [];
const op = raw?.operational_performance;
const econ = raw?.economy_costs;
const cs = raw?.customer_satisfaction;
const 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
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