Initial commit - ACME demo version

This commit is contained in:
sujucu70
2026-02-04 11:08:21 +01:00
commit 1bb0765766
180 changed files with 52249 additions and 0 deletions

View 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;
}

View 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'
};
}
}

File diff suppressed because it is too large Load Diff

105
frontend/utils/apiClient.ts Normal file
View 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;
}

File diff suppressed because it is too large Load Diff

241
frontend/utils/dataCache.ts Normal file
View 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;
}

View 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();
}

View 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
}
};
}

View 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()}`;
};

File diff suppressed because it is too large Load Diff

View 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(' | ');
}

View 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;
}

View 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')}`;
};