Initial commit - ACME demo version
This commit is contained in:
111
frontend/utils/AuthContext.tsx
Normal file
111
frontend/utils/AuthContext.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
// utils/AuthContext.tsx
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
const API_BASE_URL =
|
||||
import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
|
||||
|
||||
type AuthContextValue = {
|
||||
authHeader: string | null;
|
||||
isAuthenticated: boolean;
|
||||
login: (username: string, password: string) => Promise<void>; // 👈 async
|
||||
logout: () => void;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||
|
||||
const STORAGE_KEY = 'bd_auth_v1';
|
||||
const SESSION_DURATION_MS = 60 * 60 * 1000; // 1 hora
|
||||
|
||||
type StoredAuth = {
|
||||
authHeader: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [authHeader, setAuthHeader] = useState<string | null>(null);
|
||||
const [expiresAt, setExpiresAt] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
const parsed: StoredAuth = JSON.parse(raw);
|
||||
if (parsed.authHeader && parsed.expiresAt && parsed.expiresAt > Date.now()) {
|
||||
setAuthHeader(parsed.authHeader);
|
||||
setExpiresAt(parsed.expiresAt);
|
||||
} else {
|
||||
window.localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error leyendo auth de localStorage', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = () => {
|
||||
setAuthHeader(null);
|
||||
setExpiresAt(null);
|
||||
try {
|
||||
window.localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
};
|
||||
|
||||
const login = async (username: string, password: string): Promise<void> => {
|
||||
const basic = 'Basic ' + btoa(`${username}:${password}`);
|
||||
|
||||
// 1) Validar contra /auth/check
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(`${API_BASE_URL}/auth/check`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: basic,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error llamando a /auth/check', err);
|
||||
throw new Error('No se ha podido contactar con el servidor.');
|
||||
}
|
||||
|
||||
if (resp.status === 401) {
|
||||
throw new Error('Credenciales inválidas');
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error(`No se ha podido validar las credenciales (status ${resp.status}).`);
|
||||
}
|
||||
|
||||
// 2) Si hemos llegado aquí, las credenciales son válidas -> guardamos sesión
|
||||
const exp = Date.now() + SESSION_DURATION_MS;
|
||||
|
||||
setAuthHeader(basic);
|
||||
setExpiresAt(exp);
|
||||
|
||||
try {
|
||||
const toStore: StoredAuth = { authHeader: basic, expiresAt: exp };
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(toStore));
|
||||
} catch (err) {
|
||||
console.error('Error guardando auth en localStorage', err);
|
||||
}
|
||||
};
|
||||
|
||||
const isAuthenticated = !!authHeader && !!expiresAt && expiresAt > Date.now();
|
||||
|
||||
const value: AuthContextValue = {
|
||||
authHeader: isAuthenticated ? authHeader : null,
|
||||
isAuthenticated,
|
||||
login,
|
||||
logout,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useAuth debe usarse dentro de un AuthProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
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'
|
||||
};
|
||||
}
|
||||
}
|
||||
1415
frontend/utils/analysisGenerator.ts
Normal file
1415
frontend/utils/analysisGenerator.ts
Normal file
File diff suppressed because it is too large
Load Diff
105
frontend/utils/apiClient.ts
Normal file
105
frontend/utils/apiClient.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
// 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 || '';
|
||||
|
||||
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;
|
||||
authHeaderOverride?: string;
|
||||
}): Promise<BackendRawResults> {
|
||||
const { costPerHour, segmentMapping, file, authHeaderOverride } = 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));
|
||||
}
|
||||
|
||||
// Si nos pasan un Authorization desde el login, lo usamos.
|
||||
// Si no, caemos al getAuthHeader() basado en variables de entorno (útil en dev).
|
||||
const authHeaders: Record<string, string> = authHeaderOverride
|
||||
? { Authorization: authHeaderOverride }
|
||||
: getAuthHeader();
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/analysis`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
...authHeaders,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error(
|
||||
`Error en API /analysis: ${response.status} ${response.statusText}`
|
||||
);
|
||||
(error as any).status = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
// ⬇️ IMPORTANTE: nos quedamos solo con `results`
|
||||
const json = await response.json();
|
||||
const results = (json as any)?.results ?? json;
|
||||
|
||||
return results as BackendRawResults;
|
||||
}
|
||||
|
||||
1685
frontend/utils/backendMapper.ts
Normal file
1685
frontend/utils/backendMapper.ts
Normal file
File diff suppressed because it is too large
Load Diff
241
frontend/utils/dataCache.ts
Normal file
241
frontend/utils/dataCache.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* dataCache.ts - Sistema de caché para datos de análisis
|
||||
*
|
||||
* Usa IndexedDB para persistir los datos parseados entre rebuilds.
|
||||
* El CSV de 500MB parseado a JSON es mucho más pequeño (~10-50MB).
|
||||
*/
|
||||
|
||||
import { RawInteraction, AnalysisData } from '../types';
|
||||
|
||||
const DB_NAME = 'BeyondDiagnosisCache';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_RAW = 'rawInteractions';
|
||||
const STORE_ANALYSIS = 'analysisData';
|
||||
const STORE_META = 'metadata';
|
||||
|
||||
interface CacheMetadata {
|
||||
id: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
recordCount: number;
|
||||
cachedAt: string;
|
||||
costPerHour: number;
|
||||
}
|
||||
|
||||
// Abrir conexión a IndexedDB
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
|
||||
// Store para interacciones raw
|
||||
if (!db.objectStoreNames.contains(STORE_RAW)) {
|
||||
db.createObjectStore(STORE_RAW, { keyPath: 'id' });
|
||||
}
|
||||
|
||||
// Store para datos de análisis
|
||||
if (!db.objectStoreNames.contains(STORE_ANALYSIS)) {
|
||||
db.createObjectStore(STORE_ANALYSIS, { keyPath: 'id' });
|
||||
}
|
||||
|
||||
// Store para metadata
|
||||
if (!db.objectStoreNames.contains(STORE_META)) {
|
||||
db.createObjectStore(STORE_META, { keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Guardar interacciones parseadas en caché
|
||||
*/
|
||||
export async function cacheRawInteractions(
|
||||
interactions: RawInteraction[],
|
||||
fileName: string,
|
||||
fileSize: number,
|
||||
costPerHour: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Validar que es un array antes de cachear
|
||||
if (!Array.isArray(interactions)) {
|
||||
console.error('[Cache] No se puede cachear: interactions no es un array');
|
||||
return;
|
||||
}
|
||||
|
||||
if (interactions.length === 0) {
|
||||
console.warn('[Cache] No se cachea: array vacío');
|
||||
return;
|
||||
}
|
||||
|
||||
const db = await openDB();
|
||||
|
||||
// Guardar metadata
|
||||
const metadata: CacheMetadata = {
|
||||
id: 'current',
|
||||
fileName,
|
||||
fileSize,
|
||||
recordCount: interactions.length,
|
||||
cachedAt: new Date().toISOString(),
|
||||
costPerHour
|
||||
};
|
||||
|
||||
const metaTx = db.transaction(STORE_META, 'readwrite');
|
||||
metaTx.objectStore(STORE_META).put(metadata);
|
||||
|
||||
// Guardar interacciones (en chunks para archivos grandes)
|
||||
const rawTx = db.transaction(STORE_RAW, 'readwrite');
|
||||
const store = rawTx.objectStore(STORE_RAW);
|
||||
|
||||
// Limpiar datos anteriores
|
||||
store.clear();
|
||||
|
||||
// Guardar como un solo objeto (más eficiente para lectura)
|
||||
// Aseguramos que guardamos el array directamente
|
||||
const dataToStore = { id: 'interactions', data: [...interactions] };
|
||||
store.put(dataToStore);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
rawTx.oncomplete = resolve;
|
||||
rawTx.onerror = () => reject(rawTx.error);
|
||||
});
|
||||
|
||||
console.log(`[Cache] Guardadas ${interactions.length} interacciones en caché (verificado: Array)`);
|
||||
} catch (error) {
|
||||
console.error('[Cache] Error guardando en caché:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Guardar resultado de análisis en caché
|
||||
*/
|
||||
export async function cacheAnalysisData(data: AnalysisData): Promise<void> {
|
||||
try {
|
||||
const db = await openDB();
|
||||
const tx = db.transaction(STORE_ANALYSIS, 'readwrite');
|
||||
tx.objectStore(STORE_ANALYSIS).put({ id: 'analysis', data });
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
tx.oncomplete = resolve;
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
|
||||
console.log('[Cache] Análisis guardado en caché');
|
||||
} catch (error) {
|
||||
console.error('[Cache] Error guardando análisis:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener metadata de caché (para mostrar info al usuario)
|
||||
*/
|
||||
export async function getCacheMetadata(): Promise<CacheMetadata | null> {
|
||||
try {
|
||||
const db = await openDB();
|
||||
const tx = db.transaction(STORE_META, 'readonly');
|
||||
const request = tx.objectStore(STORE_META).get('current');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result || null);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Cache] Error leyendo metadata:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener interacciones cacheadas
|
||||
*/
|
||||
export async function getCachedInteractions(): Promise<RawInteraction[] | null> {
|
||||
try {
|
||||
const db = await openDB();
|
||||
const tx = db.transaction(STORE_RAW, 'readonly');
|
||||
const request = tx.objectStore(STORE_RAW).get('interactions');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => {
|
||||
const result = request.result;
|
||||
const data = result?.data;
|
||||
|
||||
// Validar que es un array
|
||||
if (!data) {
|
||||
console.log('[Cache] No hay datos en caché');
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
console.error('[Cache] Datos en caché no son un array:', typeof data);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Cache] Recuperadas ${data.length} interacciones`);
|
||||
resolve(data);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Cache] Error leyendo interacciones:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener análisis cacheado
|
||||
*/
|
||||
export async function getCachedAnalysis(): Promise<AnalysisData | null> {
|
||||
try {
|
||||
const db = await openDB();
|
||||
const tx = db.transaction(STORE_ANALYSIS, 'readonly');
|
||||
const request = tx.objectStore(STORE_ANALYSIS).get('analysis');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => {
|
||||
const result = request.result;
|
||||
resolve(result?.data || null);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Cache] Error leyendo análisis:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpiar toda la caché
|
||||
*/
|
||||
export async function clearCache(): Promise<void> {
|
||||
try {
|
||||
const db = await openDB();
|
||||
|
||||
const tx = db.transaction([STORE_RAW, STORE_ANALYSIS, STORE_META], 'readwrite');
|
||||
tx.objectStore(STORE_RAW).clear();
|
||||
tx.objectStore(STORE_ANALYSIS).clear();
|
||||
tx.objectStore(STORE_META).clear();
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
tx.oncomplete = resolve;
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
|
||||
console.log('[Cache] Caché limpiada');
|
||||
} catch (error) {
|
||||
console.error('[Cache] Error limpiando caché:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar si hay datos en caché
|
||||
*/
|
||||
export async function hasCachedData(): Promise<boolean> {
|
||||
const metadata = await getCacheMetadata();
|
||||
return metadata !== null;
|
||||
}
|
||||
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();
|
||||
}
|
||||
459
frontend/utils/fileParser.ts
Normal file
459
frontend/utils/fileParser.ts
Normal file
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* Utilidad para parsear archivos CSV y Excel
|
||||
* Convierte archivos a datos estructurados para análisis
|
||||
*/
|
||||
|
||||
import { RawInteraction } from '../types';
|
||||
|
||||
/**
|
||||
* Helper: Parsear valor booleano de CSV (TRUE/FALSE, true/false, 1/0, yes/no, etc.)
|
||||
*/
|
||||
function parseBoolean(value: any): boolean {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return false;
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value === 1;
|
||||
}
|
||||
const strVal = String(value).toLowerCase().trim();
|
||||
return strVal === 'true' || strVal === '1' || strVal === 'yes' || strVal === 'si' || strVal === 'sí' || strVal === 'y' || strVal === 's';
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Obtener valor de columna buscando múltiples variaciones del nombre
|
||||
*/
|
||||
function getColumnValue(row: any, ...columnNames: string[]): string {
|
||||
for (const name of columnNames) {
|
||||
if (row[name] !== undefined && row[name] !== null && row[name] !== '') {
|
||||
return String(row[name]);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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());
|
||||
console.log('📋 Todos los headers del CSV:', headers);
|
||||
|
||||
// Verificar campos clave
|
||||
const keyFields = ['is_abandoned', 'fcr_real_flag', 'repeat_call_7d', 'transfer_flag', 'record_status'];
|
||||
const foundKeyFields = keyFields.filter(f => headers.includes(f));
|
||||
const missingKeyFields = keyFields.filter(f => !headers.includes(f));
|
||||
console.log('✅ Campos clave encontrados:', foundKeyFields);
|
||||
console.log('⚠️ Campos clave NO encontrados:', missingKeyFields.length > 0 ? missingKeyFields : 'TODOS PRESENTES');
|
||||
|
||||
// Debug: Mostrar las primeras 5 filas con valores crudos de campos booleanos
|
||||
console.log('📋 VALORES CRUDOS DE CAMPOS BOOLEANOS (primeras 5 filas):');
|
||||
for (let rowNum = 1; rowNum <= Math.min(5, lines.length - 1); rowNum++) {
|
||||
const rawValues = lines[rowNum].split(',').map(v => v.trim());
|
||||
const rowData: Record<string, string> = {};
|
||||
headers.forEach((header, idx) => {
|
||||
rowData[header] = rawValues[idx] || '';
|
||||
});
|
||||
console.log(` Fila ${rowNum}:`, {
|
||||
is_abandoned: rowData.is_abandoned,
|
||||
fcr_real_flag: rowData.fcr_real_flag,
|
||||
repeat_call_7d: rowData.repeat_call_7d,
|
||||
transfer_flag: rowData.transfer_flag,
|
||||
record_status: rowData.record_status
|
||||
});
|
||||
}
|
||||
|
||||
// Validar headers requeridos (con variantes aceptadas)
|
||||
// v3.1: queue_skill (estratégico) y original_queue_id (operativo) son campos separados
|
||||
const requiredFieldsWithVariants: { field: string; variants: string[] }[] = [
|
||||
{ field: 'interaction_id', variants: ['interaction_id', 'Interaction_ID', 'Interaction ID'] },
|
||||
{ field: 'datetime_start', variants: ['datetime_start', 'Datetime_Start', 'Datetime Start'] },
|
||||
{ field: 'queue_skill', variants: ['queue_skill', 'Queue_Skill', 'Queue Skill', 'Skill'] },
|
||||
{ field: 'original_queue_id', variants: ['original_queue_id', 'Original_Queue_ID', 'Original Queue ID', 'Cola'] },
|
||||
{ field: 'channel', variants: ['channel', 'Channel'] },
|
||||
{ field: 'duration_talk', variants: ['duration_talk', 'Duration_Talk', 'Duration Talk'] },
|
||||
{ field: 'hold_time', variants: ['hold_time', 'Hold_Time', 'Hold Time'] },
|
||||
{ field: 'wrap_up_time', variants: ['wrap_up_time', 'Wrap_Up_Time', 'Wrap Up Time'] },
|
||||
{ field: 'agent_id', variants: ['agent_id', 'Agent_ID', 'Agent ID'] },
|
||||
{ field: 'transfer_flag', variants: ['transfer_flag', 'Transfer_Flag', 'Transfer Flag'] }
|
||||
];
|
||||
|
||||
const missingFields = requiredFieldsWithVariants
|
||||
.filter(({ variants }) => !variants.some(v => headers.includes(v)))
|
||||
.map(({ field }) => field);
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
throw new Error(`Faltan campos requeridos: ${missingFields.join(', ')}`);
|
||||
}
|
||||
|
||||
// Parsear filas
|
||||
const interactions: RawInteraction[] = [];
|
||||
|
||||
// Contadores para debug
|
||||
let abandonedTrueCount = 0;
|
||||
let abandonedFalseCount = 0;
|
||||
let fcrTrueCount = 0;
|
||||
let fcrFalseCount = 0;
|
||||
let repeatTrueCount = 0;
|
||||
let repeatFalseCount = 0;
|
||||
let transferTrueCount = 0;
|
||||
let transferFalseCount = 0;
|
||||
|
||||
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 ${values.length} columnas, esperado ${headers.length}, saltando...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const row: any = {};
|
||||
headers.forEach((header, index) => {
|
||||
row[header] = values[index];
|
||||
});
|
||||
|
||||
try {
|
||||
// === PARSING SIMPLE Y DIRECTO ===
|
||||
|
||||
// is_abandoned: valor directo del CSV
|
||||
const isAbandonedRaw = getColumnValue(row, 'is_abandoned', 'Is_Abandoned', 'Is Abandoned', 'abandoned');
|
||||
const isAbandoned = parseBoolean(isAbandonedRaw);
|
||||
if (isAbandoned) abandonedTrueCount++; else abandonedFalseCount++;
|
||||
|
||||
// fcr_real_flag: valor directo del CSV
|
||||
const fcrRealRaw = getColumnValue(row, 'fcr_real_flag', 'FCR_Real_Flag', 'FCR Real Flag', 'fcr_flag', 'fcr');
|
||||
const fcrRealFlag = parseBoolean(fcrRealRaw);
|
||||
if (fcrRealFlag) fcrTrueCount++; else fcrFalseCount++;
|
||||
|
||||
// repeat_call_7d: valor directo del CSV
|
||||
const repeatRaw = getColumnValue(row, 'repeat_call_7d', 'Repeat_Call_7d', 'Repeat Call 7d', 'repeat_call', 'rellamada', 'Rellamada');
|
||||
const repeatCall7d = parseBoolean(repeatRaw);
|
||||
if (repeatCall7d) repeatTrueCount++; else repeatFalseCount++;
|
||||
|
||||
// transfer_flag: valor directo del CSV
|
||||
const transferRaw = getColumnValue(row, 'transfer_flag', 'Transfer_Flag', 'Transfer Flag');
|
||||
const transferFlag = parseBoolean(transferRaw);
|
||||
if (transferFlag) transferTrueCount++; else transferFalseCount++;
|
||||
|
||||
// record_status: valor directo, normalizado a lowercase
|
||||
const recordStatusRaw = getColumnValue(row, 'record_status', 'Record_Status', 'Record Status').toLowerCase().trim();
|
||||
const validStatuses = ['valid', 'noise', 'zombie', 'abandon'];
|
||||
const recordStatus = validStatuses.includes(recordStatusRaw)
|
||||
? recordStatusRaw as 'valid' | 'noise' | 'zombie' | 'abandon'
|
||||
: undefined;
|
||||
|
||||
// v3.0: Parsear campos para drill-down
|
||||
// business_unit = Línea de Negocio (9 categorías C-Level)
|
||||
// queue_skill ya se usa como skill técnico (980 skills granulares)
|
||||
const lineaNegocio = getColumnValue(row, 'business_unit', 'Business_Unit', 'BusinessUnit', 'linea_negocio', 'Linea_Negocio', 'business_line');
|
||||
|
||||
// v3.1: Parsear ambos niveles de jerarquía
|
||||
const queueSkill = getColumnValue(row, 'queue_skill', 'Queue_Skill', 'Queue Skill', 'Skill');
|
||||
const originalQueueId = getColumnValue(row, 'original_queue_id', 'Original_Queue_ID', 'Original Queue ID', 'Cola');
|
||||
|
||||
const interaction: RawInteraction = {
|
||||
interaction_id: row.interaction_id,
|
||||
datetime_start: row.datetime_start,
|
||||
queue_skill: queueSkill,
|
||||
original_queue_id: originalQueueId || undefined,
|
||||
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: transferFlag,
|
||||
repeat_call_7d: repeatCall7d,
|
||||
caller_id: row.caller_id || undefined,
|
||||
is_abandoned: isAbandoned,
|
||||
record_status: recordStatus,
|
||||
fcr_real_flag: fcrRealFlag,
|
||||
linea_negocio: lineaNegocio || undefined
|
||||
};
|
||||
|
||||
interactions.push(interaction);
|
||||
} catch (error) {
|
||||
console.warn(`Error parseando fila ${i + 1}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// === DEBUG SUMMARY ===
|
||||
const total = interactions.length;
|
||||
console.log('');
|
||||
console.log('═══════════════════════════════════════════════════════════════');
|
||||
console.log('📊 RESUMEN DE PARSING CSV - VALORES BOOLEANOS');
|
||||
console.log('═══════════════════════════════════════════════════════════════');
|
||||
console.log(`Total registros parseados: ${total}`);
|
||||
console.log('');
|
||||
console.log(`is_abandoned:`);
|
||||
console.log(` TRUE: ${abandonedTrueCount} (${((abandonedTrueCount/total)*100).toFixed(1)}%)`);
|
||||
console.log(` FALSE: ${abandonedFalseCount} (${((abandonedFalseCount/total)*100).toFixed(1)}%)`);
|
||||
console.log('');
|
||||
console.log(`fcr_real_flag:`);
|
||||
console.log(` TRUE: ${fcrTrueCount} (${((fcrTrueCount/total)*100).toFixed(1)}%)`);
|
||||
console.log(` FALSE: ${fcrFalseCount} (${((fcrFalseCount/total)*100).toFixed(1)}%)`);
|
||||
console.log('');
|
||||
console.log(`repeat_call_7d:`);
|
||||
console.log(` TRUE: ${repeatTrueCount} (${((repeatTrueCount/total)*100).toFixed(1)}%)`);
|
||||
console.log(` FALSE: ${repeatFalseCount} (${((repeatFalseCount/total)*100).toFixed(1)}%)`);
|
||||
console.log('');
|
||||
console.log(`transfer_flag:`);
|
||||
console.log(` TRUE: ${transferTrueCount} (${((transferTrueCount/total)*100).toFixed(1)}%)`);
|
||||
console.log(` FALSE: ${transferFalseCount} (${((transferFalseCount/total)*100).toFixed(1)}%)`);
|
||||
console.log('');
|
||||
|
||||
// Calcular métricas esperadas
|
||||
const expectedAbandonRate = (abandonedTrueCount / total) * 100;
|
||||
const expectedFCR_fromFlag = (fcrTrueCount / total) * 100;
|
||||
const expectedFCR_calculated = ((total - transferTrueCount - repeatTrueCount +
|
||||
interactions.filter(i => i.transfer_flag && i.repeat_call_7d).length) / total) * 100;
|
||||
|
||||
console.log('📈 MÉTRICAS ESPERADAS:');
|
||||
console.log(` Abandonment Rate (is_abandoned=TRUE): ${expectedAbandonRate.toFixed(1)}%`);
|
||||
console.log(` FCR (fcr_real_flag=TRUE): ${expectedFCR_fromFlag.toFixed(1)}%`);
|
||||
console.log(` FCR calculado (no transfer AND no repeat): ~${expectedFCR_calculated.toFixed(1)}%`);
|
||||
console.log('═══════════════════════════════════════════════════════════════');
|
||||
console.log('');
|
||||
|
||||
return interactions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsear archivo Excel a array de objetos
|
||||
*/
|
||||
export async function parseExcel(file: File): Promise<RawInteraction[]> {
|
||||
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' });
|
||||
|
||||
const firstSheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[firstSheetName];
|
||||
|
||||
const jsonData = XLSX.utils.sheet_to_json(worksheet);
|
||||
|
||||
if (jsonData.length === 0) {
|
||||
reject(new Error('El archivo Excel está vacío'));
|
||||
return;
|
||||
}
|
||||
|
||||
const interactions: RawInteraction[] = [];
|
||||
|
||||
// Contadores para debug
|
||||
let abandonedTrueCount = 0;
|
||||
let fcrTrueCount = 0;
|
||||
let repeatTrueCount = 0;
|
||||
let transferTrueCount = 0;
|
||||
|
||||
for (let i = 0; i < jsonData.length; i++) {
|
||||
const row: any = jsonData[i];
|
||||
|
||||
try {
|
||||
// === PARSING SIMPLE Y DIRECTO ===
|
||||
|
||||
// is_abandoned
|
||||
const isAbandonedRaw = getColumnValue(row, 'is_abandoned', 'Is_Abandoned', 'Is Abandoned', 'abandoned');
|
||||
const isAbandoned = parseBoolean(isAbandonedRaw);
|
||||
if (isAbandoned) abandonedTrueCount++;
|
||||
|
||||
// fcr_real_flag
|
||||
const fcrRealRaw = getColumnValue(row, 'fcr_real_flag', 'FCR_Real_Flag', 'FCR Real Flag', 'fcr_flag', 'fcr');
|
||||
const fcrRealFlag = parseBoolean(fcrRealRaw);
|
||||
if (fcrRealFlag) fcrTrueCount++;
|
||||
|
||||
// repeat_call_7d
|
||||
const repeatRaw = getColumnValue(row, 'repeat_call_7d', 'Repeat_Call_7d', 'Repeat Call 7d', 'repeat_call', 'rellamada');
|
||||
const repeatCall7d = parseBoolean(repeatRaw);
|
||||
if (repeatCall7d) repeatTrueCount++;
|
||||
|
||||
// transfer_flag
|
||||
const transferRaw = getColumnValue(row, 'transfer_flag', 'Transfer_Flag', 'Transfer Flag');
|
||||
const transferFlag = parseBoolean(transferRaw);
|
||||
if (transferFlag) transferTrueCount++;
|
||||
|
||||
// record_status
|
||||
const recordStatusRaw = getColumnValue(row, 'record_status', 'Record_Status', 'Record Status').toLowerCase().trim();
|
||||
const validStatuses = ['valid', 'noise', 'zombie', 'abandon'];
|
||||
const recordStatus = validStatuses.includes(recordStatusRaw)
|
||||
? recordStatusRaw as 'valid' | 'noise' | 'zombie' | 'abandon'
|
||||
: undefined;
|
||||
|
||||
const durationTalkVal = parseFloat(getColumnValue(row, 'duration_talk', 'Duration_Talk', 'Duration Talk') || '0');
|
||||
const holdTimeVal = parseFloat(getColumnValue(row, 'hold_time', 'Hold_Time', 'Hold Time') || '0');
|
||||
const wrapUpTimeVal = parseFloat(getColumnValue(row, 'wrap_up_time', 'Wrap_Up_Time', 'Wrap Up Time') || '0');
|
||||
|
||||
// v3.0: Parsear campos para drill-down
|
||||
// business_unit = Línea de Negocio (9 categorías C-Level)
|
||||
const lineaNegocio = getColumnValue(row, 'business_unit', 'Business_Unit', 'BusinessUnit', 'linea_negocio', 'Linea_Negocio', 'business_line');
|
||||
|
||||
const interaction: RawInteraction = {
|
||||
interaction_id: String(getColumnValue(row, 'interaction_id', 'Interaction_ID', 'Interaction ID') || ''),
|
||||
datetime_start: String(getColumnValue(row, 'datetime_start', 'Datetime_Start', 'Datetime Start', 'Fecha/Hora de apertura') || ''),
|
||||
queue_skill: String(getColumnValue(row, 'queue_skill', 'Queue_Skill', 'Queue Skill', 'Skill', 'Subtipo', 'Tipo') || ''),
|
||||
original_queue_id: String(getColumnValue(row, 'original_queue_id', 'Original_Queue_ID', 'Original Queue ID', 'Cola') || '') || undefined,
|
||||
channel: String(getColumnValue(row, 'channel', 'Channel', '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(getColumnValue(row, 'agent_id', 'Agent_ID', 'Agent ID', 'Propietario del caso') || 'Unknown'),
|
||||
transfer_flag: transferFlag,
|
||||
repeat_call_7d: repeatCall7d,
|
||||
caller_id: getColumnValue(row, 'caller_id', 'Caller_ID', 'Caller ID') || undefined,
|
||||
is_abandoned: isAbandoned,
|
||||
record_status: recordStatus,
|
||||
fcr_real_flag: fcrRealFlag,
|
||||
linea_negocio: lineaNegocio || undefined
|
||||
};
|
||||
|
||||
if (interaction.interaction_id && interaction.queue_skill) {
|
||||
interactions.push(interaction);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Error parseando fila ${i + 1}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Debug summary
|
||||
const total = interactions.length;
|
||||
console.log('📊 Excel Parsing Summary:', {
|
||||
total,
|
||||
is_abandoned_TRUE: `${abandonedTrueCount} (${((abandonedTrueCount/total)*100).toFixed(1)}%)`,
|
||||
fcr_real_flag_TRUE: `${fcrTrueCount} (${((fcrTrueCount/total)*100).toFixed(1)}%)`,
|
||||
repeat_call_7d_TRUE: `${repeatTrueCount} (${((repeatTrueCount/total)*100).toFixed(1)}%)`,
|
||||
transfer_flag_TRUE: `${transferTrueCount} (${((transferTrueCount/total)*100).toFixed(1)}%)`
|
||||
});
|
||||
|
||||
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)
|
||||
let minTime = Infinity;
|
||||
let maxTime = -Infinity;
|
||||
let validDatesCount = 0;
|
||||
|
||||
for (const interaction of interactions) {
|
||||
const date = new Date(interaction.datetime_start);
|
||||
const time = date.getTime();
|
||||
if (!isNaN(time)) {
|
||||
validDatesCount++;
|
||||
if (time < minTime) minTime = time;
|
||||
if (time > maxTime) maxTime = time;
|
||||
}
|
||||
}
|
||||
|
||||
if (validDatesCount > 0) {
|
||||
const monthsDiff = (maxTime - minTime) / (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: validDatesCount > 0 ? {
|
||||
min: new Date(minTime).toISOString().split('T')[0],
|
||||
max: new Date(maxTime).toISOString().split('T')[0]
|
||||
} : null
|
||||
}
|
||||
};
|
||||
}
|
||||
15
frontend/utils/formatters.ts
Normal file
15
frontend/utils/formatters.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// utils/formatters.ts
|
||||
// Shared formatting utilities
|
||||
|
||||
/**
|
||||
* Formats the current date as "Month Year" in Spanish
|
||||
* Example: "Enero 2025"
|
||||
*/
|
||||
export const formatDateMonthYear = (): string => {
|
||||
const now = new Date();
|
||||
const months = [
|
||||
'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
|
||||
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'
|
||||
];
|
||||
return `${months[now.getMonth()]} ${now.getFullYear()}`;
|
||||
};
|
||||
2523
frontend/utils/realDataAnalysis.ts
Normal file
2523
frontend/utils/realDataAnalysis.ts
Normal file
File diff suppressed because it is too large
Load Diff
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(' | ');
|
||||
}
|
||||
260
frontend/utils/serverCache.ts
Normal file
260
frontend/utils/serverCache.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* serverCache.ts - Server-side cache for CSV files
|
||||
*
|
||||
* Uses backend API to store/retrieve cached CSV files.
|
||||
* Works across browsers and computers (as long as they access the same server).
|
||||
*/
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
|
||||
|
||||
export interface ServerCacheMetadata {
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
recordCount: number;
|
||||
cachedAt: string;
|
||||
costPerHour: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if server has cached data
|
||||
*/
|
||||
export async function checkServerCache(authHeader: string): Promise<{
|
||||
exists: boolean;
|
||||
metadata: ServerCacheMetadata | null;
|
||||
}> {
|
||||
const url = `${API_BASE_URL}/cache/check`;
|
||||
console.log('[ServerCache] Checking cache at:', url);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('[ServerCache] Response status:', response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
console.error('[ServerCache] Error checking cache:', response.status, text);
|
||||
return { exists: false, metadata: null };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('[ServerCache] Response data:', data);
|
||||
return {
|
||||
exists: data.exists || false,
|
||||
metadata: data.metadata || null,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[ServerCache] Error checking cache:', error);
|
||||
return { exists: false, metadata: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save CSV file to server cache using FormData
|
||||
* This sends the actual file, not parsed JSON data
|
||||
*/
|
||||
export async function saveFileToServerCache(
|
||||
authHeader: string,
|
||||
file: File,
|
||||
costPerHour: number
|
||||
): Promise<boolean> {
|
||||
const url = `${API_BASE_URL}/cache/file`;
|
||||
console.log(`[ServerCache] Saving file "${file.name}" (${(file.size / 1024 / 1024).toFixed(2)} MB) to server at:`, url);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('csv_file', file);
|
||||
formData.append('fileName', file.name);
|
||||
formData.append('fileSize', file.size.toString());
|
||||
formData.append('costPerHour', costPerHour.toString());
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
// Note: Don't set Content-Type - browser sets it automatically with boundary for FormData
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
console.log('[ServerCache] Save response status:', response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
console.error('[ServerCache] Error saving cache:', response.status, text);
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('[ServerCache] Save success:', data);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[ServerCache] Error saving cache:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the cached CSV file from the server
|
||||
* Returns a File object that can be parsed locally
|
||||
*/
|
||||
export async function downloadCachedFile(authHeader: string): Promise<File | null> {
|
||||
const url = `${API_BASE_URL}/cache/download`;
|
||||
console.log('[ServerCache] Downloading cached file from:', url);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('[ServerCache] Download response status:', response.status);
|
||||
|
||||
if (response.status === 404) {
|
||||
console.error('[ServerCache] No cached file found');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
console.error('[ServerCache] Error downloading cached file:', response.status, text);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the blob and create a File object
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], 'cached_data.csv', { type: 'text/csv' });
|
||||
console.log(`[ServerCache] Downloaded file: ${(file.size / 1024 / 1024).toFixed(2)} MB`);
|
||||
return file;
|
||||
} catch (error) {
|
||||
console.error('[ServerCache] Error downloading cached file:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save drilldownData JSON to server cache
|
||||
* Called after calculating drilldown from uploaded file
|
||||
*/
|
||||
export async function saveDrilldownToServerCache(
|
||||
authHeader: string,
|
||||
drilldownData: any[]
|
||||
): Promise<boolean> {
|
||||
const url = `${API_BASE_URL}/cache/drilldown`;
|
||||
console.log(`[ServerCache] Saving drilldownData (${drilldownData.length} skills) to server`);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('drilldown_json', JSON.stringify(drilldownData));
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
console.log('[ServerCache] Save drilldown response status:', response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
console.error('[ServerCache] Error saving drilldown:', response.status, text);
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('[ServerCache] Drilldown save success:', data);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[ServerCache] Error saving drilldown:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached drilldownData from server
|
||||
* Returns the pre-calculated drilldown data for fast cache usage
|
||||
*/
|
||||
export async function getCachedDrilldown(authHeader: string): Promise<any[] | null> {
|
||||
const url = `${API_BASE_URL}/cache/drilldown`;
|
||||
console.log('[ServerCache] Getting cached drilldown from:', url);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('[ServerCache] Get drilldown response status:', response.status);
|
||||
|
||||
if (response.status === 404) {
|
||||
console.log('[ServerCache] No cached drilldown found');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
console.error('[ServerCache] Error getting drilldown:', response.status, text);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log(`[ServerCache] Got cached drilldown: ${data.drilldownData?.length || 0} skills`);
|
||||
return data.drilldownData || null;
|
||||
} catch (error) {
|
||||
console.error('[ServerCache] Error getting drilldown:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear server cache
|
||||
*/
|
||||
export async function clearServerCache(authHeader: string): Promise<boolean> {
|
||||
const url = `${API_BASE_URL}/cache/file`;
|
||||
console.log('[ServerCache] Clearing cache at:', url);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('[ServerCache] Clear response status:', response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
console.error('[ServerCache] Error clearing cache:', response.status, text);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('[ServerCache] Cache cleared');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[ServerCache] Error clearing cache:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy exports - kept for backwards compatibility during transition
|
||||
// These will throw errors if called since the backend endpoints are deprecated
|
||||
export async function saveServerCache(): Promise<boolean> {
|
||||
console.error('[ServerCache] saveServerCache is deprecated - use saveFileToServerCache instead');
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function getServerCachedInteractions(): Promise<null> {
|
||||
console.error('[ServerCache] getServerCachedInteractions is deprecated - use cached file analysis instead');
|
||||
return null;
|
||||
}
|
||||
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