Initial commit: frontend + backend integration

This commit is contained in:
Ignacio
2025-12-29 18:12:32 +01:00
commit 2cd6d6b95c
146 changed files with 31503 additions and 0 deletions

13
backend/.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
.venv
__pycache__
*.pyc
*.pyo
*.pyd
.git
.gitignore
test_results
dist
build
data/output
*.zip
.DS_Store

15
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
__pycache__/
*.py[cod]
*.pyo
*.pyd
*.log
.env
.venv
venv/
env/
.idea/
.vscode/
.ipynb_checkpoints/
dist/
build/
*.egg-info/

31
backend/Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# Primero copiamos el pyproject para aprovechar la caché al instalar deps
COPY pyproject.toml ./
# Si tienes setup.cfg/setup.py, los copias también
RUN pip install --upgrade pip && \
pip install .
# Ahora copiamos todo el código
COPY . .
# Crear directorios base de datos
RUN mkdir -p /app/data/input /app/data/output
EXPOSE 8000
# Credenciales por defecto (en runtime las puedes sobrescribir)
ENV BASIC_AUTH_USERNAME=beyond \
BASIC_AUTH_PASSWORD=beyond2026
CMD ["uvicorn", "beyond_api.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -0,0 +1,4 @@
# vacío o con un pequeño comentario
"""
Paquete de API para BeyondCX Heatmap.
"""

View File

@@ -0,0 +1,3 @@
from .analysis import router
__all__ = ["router"]

View File

@@ -0,0 +1,119 @@
from __future__ import annotations
from pathlib import Path
import json
import math
from uuid import uuid4
from typing import Optional, Any, Literal
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Depends
from fastapi.responses import JSONResponse
from beyond_api.security import get_current_user
from beyond_api.services.analysis_service import run_analysis_collect_json
router = APIRouter(
prefix="",
tags=["analysis"],
)
def sanitize_for_json(obj: Any) -> Any:
"""
Recorre un objeto (dict/list/escalares) y convierte:
- NaN, +inf, -inf -> None
para que sea JSON-compliant.
"""
if isinstance(obj, float):
if math.isnan(obj) or math.isinf(obj):
return None
return obj
if obj is None or isinstance(obj, (str, int, bool)):
return obj
if isinstance(obj, dict):
return {k: sanitize_for_json(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return [sanitize_for_json(v) for v in obj]
return str(obj)
@router.post("/analysis")
async def analysis_endpoint(
csv_file: UploadFile = File(...),
economy_json: Optional[str] = Form(default=None),
analysis: Literal["basic", "premium"] = Form(default="premium"),
current_user: str = Depends(get_current_user),
):
"""
Ejecuta el pipeline sobre un CSV subido (multipart/form-data) y devuelve
ÚNICAMENTE un JSON con todos los resultados (incluyendo agentic_readiness).
Parámetro `analysis`:
- "basic": usa una configuración reducida (p.ej. configs/basic.json)
- "premium": usa la configuración completa por defecto
(p.ej. beyond_metrics_config.json), sin romper lo existente.
"""
# Validar `analysis` (por si llega algo raro)
if analysis not in {"basic", "premium"}:
raise HTTPException(
status_code=400,
detail="analysis debe ser 'basic' o 'premium'.",
)
# 1) Parseo de economía (si viene)
economy_data = None
if economy_json:
try:
economy_data = json.loads(economy_json)
except json.JSONDecodeError:
raise HTTPException(
status_code=400,
detail="economy_json no es un JSON válido.",
)
# 2) Guardar el CSV subido en una carpeta de trabajo
base_input_dir = Path("data/input")
base_input_dir.mkdir(parents=True, exist_ok=True)
original_name = csv_file.filename or f"input_{uuid4().hex}.csv"
safe_name = Path(original_name).name # evita rutas con ../
input_path = base_input_dir / safe_name
with input_path.open("wb") as f:
while True:
chunk = await csv_file.read(1024 * 1024) # 1 MB
if not chunk:
break
f.write(chunk)
try:
# 3) Ejecutar el análisis y obtener el JSON en memoria
results_json = run_analysis_collect_json(
input_path=input_path,
economy_data=economy_data,
analysis=analysis, # "basic" o "premium"
company_folder=None,
)
finally:
# 3b) Limpiar el CSV temporal
try:
input_path.unlink(missing_ok=True)
except Exception:
# No queremos romper la respuesta si falla el borrado
pass
# 4) Limpiar NaN/inf para que el JSON sea válido
safe_results = sanitize_for_json(results_json)
# 5) Devolver SOLO JSON
return JSONResponse(
content={
"user": current_user,
"results": safe_results,
}
)

View File

@@ -0,0 +1,32 @@
import logging
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
# importa tus routers
from beyond_api.api.analysis import router as analysis_router
def setup_basic_logging() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
)
setup_basic_logging()
app = FastAPI()
origins = [
"http://localhost:3000",
"http://127.0.0.1:3000",
]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(analysis_router)

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
import os
import secrets
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
security = HTTPBasic()
# En producción: export BASIC_AUTH_USERNAME y BASIC_AUTH_PASSWORD.
BASIC_USER = os.getenv("BASIC_AUTH_USERNAME", "beyond")
BASIC_PASS = os.getenv("BASIC_AUTH_PASSWORD", "beyond2026")
def get_current_user(credentials: HTTPBasicCredentials = Depends(security)) -> str:
"""
Valida el usuario/contraseña vía HTTP Basic.
"""
correct_username = secrets.compare_digest(credentials.username, BASIC_USER)
correct_password = secrets.compare_digest(credentials.password, BASIC_PASS)
if not (correct_username and correct_password):
# Importante devolver el header WWW-Authenticate para que el navegador saque el prompt
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Credenciales incorrectas",
headers={"WWW-Authenticate": "Basic"},
)
return credentials.username

View File

View File

@@ -0,0 +1,262 @@
from __future__ import annotations
from pathlib import Path
from uuid import uuid4
from datetime import datetime
from typing import Optional, Literal
import json
import zipfile
from beyond_metrics.io import LocalDataSource, LocalResultsSink, ResultsSink
from beyond_metrics.pipeline import build_pipeline
from beyond_metrics.dimensions.EconomyCost import EconomyConfig
from beyond_flows.scorers import AgenticScorer
from typing import Any, Mapping, Optional, Dict
def _build_economy_config(economy_data: Optional[Mapping[str, Any]]) -> EconomyConfig:
"""
Construye EconomyConfig validando tipos y evitando que el type checker
mezcle floats y dicts en un solo diccionario.
"""
# Valores por defecto
default_customer_segments: Dict[str, str] = {
"VIP": "high",
"Premium": "high",
"Soporte_General": "medium",
"Ventas": "medium",
"Basico": "low",
}
if economy_data is None:
return EconomyConfig(
labor_cost_per_hour=20.0,
overhead_rate=0.10,
tech_costs_annual=5000.0,
automation_cpi=0.20,
automation_volume_share=0.5,
automation_success_rate=0.6,
customer_segments=default_customer_segments,
)
def _get_float(field: str, default: float) -> float:
value = economy_data.get(field, default)
if isinstance(value, (int, float)):
return float(value)
raise ValueError(f"El campo '{field}' debe ser numérico (float). Valor recibido: {value!r}")
# Campos escalares
labor_cost_per_hour = _get_float("labor_cost_per_hour", 20.0)
overhead_rate = _get_float("overhead_rate", 0.10)
tech_costs_annual = _get_float("tech_costs_annual", 5000.0)
automation_cpi = _get_float("automation_cpi", 0.20)
automation_volume_share = _get_float("automation_volume_share", 0.5)
automation_success_rate = _get_float("automation_success_rate", 0.6)
# customer_segments puede venir o no; si viene, validarlo
customer_segments: Dict[str, str] = dict(default_customer_segments)
if "customer_segments" in economy_data and economy_data["customer_segments"] is not None:
cs = economy_data["customer_segments"]
if not isinstance(cs, Mapping):
raise ValueError("customer_segments debe ser un diccionario {segment: level}")
for k, v in cs.items():
if not isinstance(v, str):
raise ValueError(
f"El valor de customer_segments['{k}'] debe ser str. Valor recibido: {v!r}"
)
customer_segments[str(k)] = v
return EconomyConfig(
labor_cost_per_hour=labor_cost_per_hour,
overhead_rate=overhead_rate,
tech_costs_annual=tech_costs_annual,
automation_cpi=automation_cpi,
automation_volume_share=automation_volume_share,
automation_success_rate=automation_success_rate,
customer_segments=customer_segments,
)
def run_analysis(
input_path: Path,
economy_data: Optional[dict] = None,
return_type: Literal["path", "zip"] = "path",
company_folder: Optional[str] = None,
) -> tuple[Path, Optional[Path]]:
"""
Ejecuta el pipeline sobre un CSV y devuelve:
- (results_dir, None) si return_type == "path"
- (results_dir, zip_path) si return_type == "zip"
input_path puede ser absoluto o relativo, pero los resultados
se escribirán SIEMPRE en la carpeta del CSV, dentro de una
subcarpeta con nombre = timestamp (y opcionalmente prefijada
por company_folder).
"""
input_path = input_path.resolve()
if not input_path.exists():
raise FileNotFoundError(f"El CSV no existe: {input_path}")
if not input_path.is_file():
raise ValueError(f"La ruta no apunta a un fichero CSV: {input_path}")
# Carpeta donde está el CSV
csv_dir = input_path.parent
# DataSource y ResultsSink apuntan a ESA carpeta
datasource = LocalDataSource(base_dir=str(csv_dir))
sink = LocalResultsSink(base_dir=str(csv_dir))
# Config de economía
economy_cfg = _build_economy_config(economy_data)
dimension_params: Dict[str, Mapping[str, Any]] = {
"economy_costs": {
"config": economy_cfg,
}
}
# Callback de scoring
def agentic_post_run(results: Dict[str, Any], run_base: str, sink_: ResultsSink) -> None:
scorer = AgenticScorer()
try:
agentic = scorer.compute_and_return(results)
except Exception as e:
# No rompemos toda la ejecución si el scorer falla
agentic = {
"error": f"{type(e).__name__}: {e}",
}
sink_.write_json(f"{run_base}/agentic_readiness.json", agentic)
pipeline = build_pipeline(
dimensions_config_path="beyond_metrics/configs/beyond_metrics_config.json",
datasource=datasource,
sink=sink,
dimension_params=dimension_params,
post_run=[agentic_post_run],
)
# Timestamp de ejecución (nombre de la carpeta de resultados)
timestamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S")
# Ruta lógica de resultados (RELATIVA al base_dir del sink)
if company_folder:
# Ej: "Cliente_X/20251208-153045"
run_dir_rel = f"{company_folder.rstrip('/')}/{timestamp}"
else:
# Ej: "20251208-153045"
run_dir_rel = timestamp
# Ejecutar pipeline: el CSV se pasa relativo a csv_dir
pipeline.run(
input_path=input_path.name,
run_dir=run_dir_rel,
)
# Carpeta real con los resultados
results_dir = csv_dir / run_dir_rel
if return_type == "path":
return results_dir, None
# --- ZIP de resultados -------------------------------------------------
# Creamos el ZIP en la MISMA carpeta del CSV, con nombre basado en run_dir
zip_name = f"{run_dir_rel.replace('/', '_')}.zip"
zip_path = csv_dir / zip_name
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
for file in results_dir.rglob("*"):
if file.is_file():
# Lo guardamos relativo a la carpeta de resultados
arcname = file.relative_to(results_dir.parent)
zipf.write(file, arcname)
return results_dir, zip_path
from typing import Any, Mapping, Dict # asegúrate de tener estos imports arriba
def run_analysis_collect_json(
input_path: Path,
economy_data: Optional[dict] = None,
analysis: Literal["basic", "premium"] = "premium",
company_folder: Optional[str] = None,
) -> Dict[str, Any]:
"""
Ejecuta el pipeline y devuelve un único JSON con todos los resultados.
A diferencia de run_analysis:
- NO escribe results.json
- NO escribe agentic_readiness.json
- agentic_readiness se incrusta en el dict de resultados
El parámetro `analysis` permite elegir el nivel de análisis:
- "basic" -> beyond_metrics/configs/basic.json
- "premium" -> beyond_metrics/configs/beyond_metrics_config.json
"""
# Normalizamos y validamos la ruta del CSV
input_path = input_path.resolve()
if not input_path.exists():
raise FileNotFoundError(f"El CSV no existe: {input_path}")
if not input_path.is_file():
raise ValueError(f"La ruta no apunta a un fichero CSV: {input_path}")
# Carpeta donde está el CSV
csv_dir = input_path.parent
# DataSource y ResultsSink apuntan a ESA carpeta
datasource = LocalDataSource(base_dir=str(csv_dir))
sink = LocalResultsSink(base_dir=str(csv_dir))
# Config de economía
economy_cfg = _build_economy_config(economy_data)
dimension_params: Dict[str, Mapping[str, Any]] = {
"economy_costs": {
"config": economy_cfg,
}
}
# Elegimos el fichero de configuración de dimensiones según `analysis`
if analysis == "basic":
dimensions_config_path = "beyond_metrics/configs/basic.json"
else:
dimensions_config_path = "beyond_metrics/configs/beyond_metrics_config.json"
# Callback post-run: añadir agentic_readiness al JSON final (sin escribir ficheros)
def agentic_post_run(results: Dict[str, Any], run_base: str, sink_: ResultsSink) -> None:
scorer = AgenticScorer()
try:
agentic = scorer.compute_and_return(results)
except Exception as e:
agentic = {"error": f"{type(e).__name__}: {e}"}
results["agentic_readiness"] = agentic
pipeline = build_pipeline(
dimensions_config_path=dimensions_config_path,
datasource=datasource,
sink=sink,
dimension_params=dimension_params,
post_run=[agentic_post_run],
)
# Timestamp de ejecución (para separar posibles artefactos como plots)
timestamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S")
if company_folder:
run_dir_rel = f"{company_folder.rstrip('/')}/{timestamp}"
else:
run_dir_rel = timestamp
# Ejecutar pipeline sin escribir results.json
results = pipeline.run(
input_path=input_path.name,
run_dir=run_dir_rel,
write_results_json=False,
)
return results

View File

View File

View File

View File

@@ -0,0 +1,43 @@
from __future__ import annotations
from typing import Any, Dict
from beyond_metrics.io import LocalDataSource, LocalResultsSink, ResultsSink
from beyond_metrics.pipeline import build_pipeline
from beyond_flows.scorers import AgenticScorer
def agentic_post_run(results: Dict[str, Any], run_base: str, sink: ResultsSink) -> None:
"""
Callback post-run que calcula el Agentic Readiness y lo añade al diccionario final
como la clave "agentic_readiness".
"""
scorer = AgenticScorer()
agentic = scorer.compute_and_return(results)
# Enriquecemos el JSON final (sin escribir un segundo fichero)
results["agentic_readiness"] = agentic
def run_pipeline_with_agentic(
input_csv,
base_results_dir,
dimensions_config_path="beyond_metrics/configs/beyond_metrics_config.json",
):
datasource = LocalDataSource(base_dir=".")
sink = LocalResultsSink(base_dir=".")
pipeline = build_pipeline(
dimensions_config_path=dimensions_config_path,
datasource=datasource,
sink=sink,
post_run=[agentic_post_run],
)
results = pipeline.run(
input_path=input_csv,
run_dir=base_results_dir,
)
return results

View File

@@ -0,0 +1,3 @@
from .agentic_score import AgenticScorer
__all__ = ["AgenticScorer"]

View File

@@ -0,0 +1,768 @@
"""
agentic_score.py
Calcula el Agentic Readiness Score de un contact center a partir
de un JSON con KPIs agregados (misma estructura que results.json).
Diseñado como clase para integrarse fácilmente en pipelines.
Características:
- Tolerante a datos faltantes: si una dimensión no se puede calcular
(porque faltan KPIs), se marca como `computed = False` y no se
incluye en el cálculo del score global.
- La llamada típica en un pipeline será:
from agentic_score import AgenticScorer
scorer = AgenticScorer()
result = scorer.run_on_folder("/ruta/a/carpeta")
Esa carpeta debe contener un `results.json` de entrada.
El módulo generará un `agentic_readiness.json` en la misma carpeta.
"""
from __future__ import annotations
import json
import math
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence, Union
Number = Union[int, float]
# =========================
# Helpers
# =========================
def _is_nan(x: Any) -> bool:
"""Devuelve True si x es NaN, None o el string 'NaN'."""
try:
if x is None:
return True
if isinstance(x, str) and x.lower() == "nan":
return True
return math.isnan(float(x))
except (TypeError, ValueError):
return False
def _safe_mean(values: Sequence[Optional[Number]]) -> Optional[float]:
nums: List[float] = []
for v in values:
if v is None:
continue
if _is_nan(v):
continue
nums.append(float(v))
if not nums:
return None
return sum(nums) / len(nums)
def _get_nested(d: Dict[str, Any], *keys: str, default: Any = None) -> Any:
"""Acceso seguro a diccionarios anidados."""
cur: Any = d
for k in keys:
if not isinstance(cur, dict) or k not in cur:
return default
cur = cur[k]
return cur
def _clamp(value: float, lo: float = 0.0, hi: float = 10.0) -> float:
return max(lo, min(hi, value))
def _normalize_numeric_sequence(field: Any) -> Optional[List[Number]]:
"""
Normaliza un campo que representa una secuencia numérica.
Soporta:
- Formato antiguo del pipeline: [10, 20, 30]
- Formato nuevo del pipeline: {"labels": [...], "values": [10, 20, 30]}
Devuelve:
- lista de números, si hay datos numéricos válidos
- None, si el campo no tiene una secuencia numérica interpretable
"""
if field is None:
return None
# Formato nuevo: {"labels": [...], "values": [...]}
if isinstance(field, dict) and "values" in field:
seq = field.get("values")
else:
seq = field
if not isinstance(seq, Sequence):
return None
out: List[Number] = []
for v in seq:
if isinstance(v, (int, float)):
out.append(v)
else:
# Intentamos conversión suave por si viene como string numérico
try:
out.append(float(v))
except (TypeError, ValueError):
continue
return out or None
# =========================
# Scoring functions
# =========================
def score_repetitividad(volume_by_skill: Optional[List[Number]]) -> Dict[str, Any]:
"""
Repetitividad basada en volumen medio por skill.
Regla (pensada por proceso/skill):
- 10 si volumen > 80
- 5 si 4080
- 0 si < 40
Si no hay datos (lista vacía o no numérica), la dimensión
se marca como no calculada (computed = False).
"""
if not volume_by_skill:
return {
"score": None,
"computed": False,
"reason": "sin_datos_volumen",
"details": {
"avg_volume_per_skill": None,
"volume_by_skill": volume_by_skill,
},
}
avg_volume = _safe_mean(volume_by_skill)
if avg_volume is None:
return {
"score": None,
"computed": False,
"reason": "volumen_no_numerico",
"details": {
"avg_volume_per_skill": None,
"volume_by_skill": volume_by_skill,
},
}
if avg_volume > 80:
score = 10.0
reason = "alto_volumen"
elif avg_volume >= 40:
score = 5.0
reason = "volumen_medio"
else:
score = 0.0
reason = "volumen_bajo"
return {
"score": score,
"computed": True,
"reason": reason,
"details": {
"avg_volume_per_skill": avg_volume,
"volume_by_skill": volume_by_skill,
"thresholds": {
"high": 80,
"medium": 40,
},
},
}
def score_predictibilidad(aht_ratio: Any,
escalation_rate: Any) -> Dict[str, Any]:
"""
Predictibilidad basada en:
- Variabilidad AHT: ratio P90/P50
- Tasa de escalación (%)
Regla:
- 10 si ratio < 1.5 y escalación < 10%
- 5 si ratio 1.52.0 o escalación 1020%
- 0 si ratio > 2.0 y escalación > 20%
- 3 fallback si datos parciales
Si no hay ni ratio ni escalación, la dimensión no se calcula.
"""
if aht_ratio is None and escalation_rate is None:
return {
"score": None,
"computed": False,
"reason": "sin_datos",
"details": {
"aht_p90_p50_ratio": None,
"escalation_rate_pct": None,
},
}
# Normalizamos ratio
if aht_ratio is None or _is_nan(aht_ratio):
ratio: Optional[float] = None
else:
ratio = float(aht_ratio)
# Normalizamos escalación
if escalation_rate is None or _is_nan(escalation_rate):
esc: Optional[float] = None
else:
esc = float(escalation_rate)
if ratio is None and esc is None:
return {
"score": None,
"computed": False,
"reason": "sin_datos",
"details": {
"aht_p90_p50_ratio": None,
"escalation_rate_pct": None,
},
}
score: float
reason: str
if ratio is not None and esc is not None:
if ratio < 1.5 and esc < 10.0:
score = 10.0
reason = "alta_predictibilidad"
elif (1.5 <= ratio <= 2.0) or (10.0 <= esc <= 20.0):
score = 5.0
reason = "predictibilidad_media"
elif ratio > 2.0 and esc > 20.0:
score = 0.0
reason = "baja_predictibilidad"
else:
score = 3.0
reason = "caso_intermedio"
else:
# Datos parciales: penalizamos pero no ponemos a 0
score = 3.0
reason = "datos_parciales"
return {
"score": score,
"computed": True,
"reason": reason,
"details": {
"aht_p90_p50_ratio": ratio,
"escalation_rate_pct": esc,
"rules": {
"high": {"max_ratio": 1.5, "max_esc_pct": 10},
"medium": {"ratio_range": [1.5, 2.0], "esc_range_pct": [10, 20]},
"low": {"min_ratio": 2.0, "min_esc_pct": 20},
},
},
}
def score_estructuracion(channel_distribution_pct: Any) -> Dict[str, Any]:
"""
Estructuración de datos usando proxy de canal.
Asumimos que el canal con mayor % es texto (en proyectos reales se puede
parametrizar esta asignación).
Regla:
- 10 si texto > 60%
- 5 si 3060%
- 0 si < 30%
Si no hay datos de canales, la dimensión no se calcula.
"""
if not channel_distribution_pct:
return {
"score": None,
"computed": False,
"reason": "sin_datos_canal",
"details": {
"estimated_text_share_pct": None,
"channel_distribution_pct": channel_distribution_pct,
},
}
try:
values: List[float] = []
for x in channel_distribution_pct:
if _is_nan(x):
continue
values.append(float(x))
if not values:
raise ValueError("sin valores numéricos")
max_share = max(values)
except Exception:
return {
"score": None,
"computed": False,
"reason": "canales_no_numericos",
"details": {
"estimated_text_share_pct": None,
"channel_distribution_pct": channel_distribution_pct,
},
}
if max_share > 60.0:
score = 10.0
reason = "alta_proporcion_texto"
elif max_share >= 30.0:
score = 5.0
reason = "proporcion_texto_media"
else:
score = 0.0
reason = "baja_proporcion_texto"
return {
"score": score,
"computed": True,
"reason": reason,
"details": {
"estimated_text_share_pct": max_share,
"channel_distribution_pct": channel_distribution_pct,
"thresholds_pct": {
"high": 60,
"medium": 30,
},
},
}
def score_complejidad(aht_ratio: Any,
escalation_rate: Any) -> Dict[str, Any]:
"""
Complejidad inversa del proceso (010).
1) Base: inversa lineal de la variabilidad AHT (ratio P90/P50):
- ratio = 1.0 -> 10
- ratio = 1.5 -> ~7.5
- ratio = 2.0 -> 5
- ratio = 2.5 -> 2.5
- ratio >= 3.0 -> 0
formula_base = (3 - ratio) / (3 - 1) * 10, acotado a [0,10]
2) Ajuste por escalación:
- restamos (escalation_rate / 5) puntos.
Nota: más score = proceso más "simple / automatizable".
Si no hay ni ratio ni escalación, la dimensión no se calcula.
"""
if aht_ratio is None or _is_nan(aht_ratio):
ratio: Optional[float] = None
else:
ratio = float(aht_ratio)
if escalation_rate is None or _is_nan(escalation_rate):
esc: Optional[float] = None
else:
esc = float(escalation_rate)
if ratio is None and esc is None:
return {
"score": None,
"computed": False,
"reason": "sin_datos",
"details": {
"aht_p90_p50_ratio": None,
"escalation_rate_pct": None,
},
}
# Base por variabilidad
if ratio is None:
base = 5.0 # fallback neutro
base_reason = "sin_ratio_usamos_valor_neutro"
else:
base_raw = (3.0 - ratio) / (3.0 - 1.0) * 10.0
base = _clamp(base_raw)
base_reason = "calculado_desde_ratio"
# Ajuste por escalación
if esc is None:
adj = 0.0
adj_reason = "sin_escalacion_sin_ajuste"
else:
adj = - (esc / 5.0) # cada 5 puntos de escalación resta 1
adj_reason = "ajuste_por_escalacion"
final_score = _clamp(base + adj)
return {
"score": final_score,
"computed": True,
"reason": "complejidad_inversa",
"details": {
"aht_p90_p50_ratio": ratio,
"escalation_rate_pct": esc,
"base_score": base,
"base_reason": base_reason,
"adjustment": adj,
"adjustment_reason": adj_reason,
},
}
def score_estabilidad(peak_offpeak_ratio: Any) -> Dict[str, Any]:
"""
Estabilidad del proceso basada en relación pico/off-peak.
Regla:
- 10 si ratio < 3
- 7 si 35
- 3 si 57
- 0 si > 7
Si no hay dato de ratio, la dimensión no se calcula.
"""
if peak_offpeak_ratio is None or _is_nan(peak_offpeak_ratio):
return {
"score": None,
"computed": False,
"reason": "sin_datos_peak_offpeak",
"details": {
"peak_offpeak_ratio": None,
},
}
r = float(peak_offpeak_ratio)
if r < 3.0:
score = 10.0
reason = "muy_estable"
elif r < 5.0:
score = 7.0
reason = "estable_moderado"
elif r < 7.0:
score = 3.0
reason = "pico_pronunciado"
else:
score = 0.0
reason = "muy_inestable"
return {
"score": score,
"computed": True,
"reason": reason,
"details": {
"peak_offpeak_ratio": r,
"thresholds": {
"very_stable": 3.0,
"stable": 5.0,
"unstable": 7.0,
},
},
}
def score_roi(annual_savings: Any) -> Dict[str, Any]:
"""
ROI potencial anual.
Regla:
- 10 si ahorro > 100k €/año
- 5 si 10k100k €/año
- 0 si < 10k €/año
Si no hay dato de ahorro, la dimensión no se calcula.
"""
if annual_savings is None or _is_nan(annual_savings):
return {
"score": None,
"computed": False,
"reason": "sin_datos_ahorro",
"details": {
"annual_savings_eur": None,
},
}
savings = float(annual_savings)
if savings > 100_000:
score = 10.0
reason = "roi_alto"
elif savings >= 10_000:
score = 5.0
reason = "roi_medio"
else:
score = 0.0
reason = "roi_bajo"
return {
"score": score,
"computed": True,
"reason": reason,
"details": {
"annual_savings_eur": savings,
"thresholds_eur": {
"high": 100_000,
"medium": 10_000,
},
},
}
def classify_agentic_score(score: Optional[float]) -> Dict[str, Any]:
"""
Clasificación final:
- 810: AUTOMATE 🤖
- 57.99: ASSIST 🤝
- 34.99: AUGMENT 🧠
- 02.99: HUMAN_ONLY 👤
Si score es None (ninguna dimensión disponible), devuelve NO_DATA.
"""
if score is None:
return {
"label": "NO_DATA",
"emoji": "",
"description": (
"No se ha podido calcular el Agentic Readiness Score porque "
"ninguna de las dimensiones tenía datos suficientes."
),
}
if score >= 8.0:
label = "AUTOMATE"
emoji = "🤖"
description = (
"Alta repetitividad, alta predictibilidad y ROI elevado. "
"Candidato a automatización completa (chatbot/IVR inteligente)."
)
elif score >= 5.0:
label = "ASSIST"
emoji = "🤝"
description = (
"Complejidad media o ROI limitado. Recomendado enfoque de copilot "
"para agentes (sugerencias en tiempo real, autocompletado, etc.)."
)
elif score >= 3.0:
label = "AUGMENT"
emoji = "🧠"
description = (
"Alta complejidad o bajo volumen. Mejor usar herramientas de apoyo "
"(knowledge base, guías dinámicas, scripts)."
)
else:
label = "HUMAN_ONLY"
emoji = "👤"
description = (
"Procesos de muy bajo volumen o extremadamente complejos. Mejor "
"mantener operación 100% humana de momento."
)
return {
"label": label,
"emoji": emoji,
"description": description,
}
# =========================
# Clase principal
# =========================
class AgenticScorer:
"""
Clase para calcular el Agentic Readiness Score a partir de resultados
agregados (results.json) y dejar la salida en agentic_readiness.json
en la misma carpeta.
"""
def __init__(
self,
input_filename: str = "results.json",
output_filename: str = "agentic_readiness.json",
) -> None:
self.input_filename = input_filename
self.output_filename = output_filename
self.base_weights: Dict[str, float] = {
"repetitividad": 0.25,
"predictibilidad": 0.20,
"estructuracion": 0.15,
"complejidad": 0.15,
"estabilidad": 0.10,
"roi": 0.15,
}
# --------- IO helpers ---------
def load_results(self, folder_path: Union[str, Path]) -> Dict[str, Any]:
folder = Path(folder_path)
input_path = folder / self.input_filename
if not input_path.exists():
raise FileNotFoundError(
f"No se ha encontrado el archivo de entrada '{self.input_filename}' "
f"en la carpeta: {folder}"
)
with input_path.open("r", encoding="utf-8") as f:
return json.load(f)
def save_agentic_readiness(self, folder_path: Union[str, Path], result: Dict[str, Any]) -> Path:
folder = Path(folder_path)
output_path = folder / self.output_filename
with output_path.open("w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False, indent=2)
return output_path
# --------- Core computation ---------
def compute_from_data(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""
Calcula el Agentic Readiness Score a partir de un dict de datos.
Tolerante a datos faltantes: renormaliza pesos usando solo
dimensiones con `computed = True`.
Compatibilidad con pipeline:
- Soporta tanto el formato antiguo:
"volume_by_skill": [10, 20, 30]
- como el nuevo:
"volume_by_skill": {"labels": [...], "values": [10, 20, 30]}
"""
volumetry = data.get("volumetry", {})
op = data.get("operational_performance", {})
econ = data.get("economy_costs", {})
# Normalizamos aquí los posibles formatos para contentar al type checker
volume_by_skill = _normalize_numeric_sequence(
volumetry.get("volume_by_skill")
)
channel_distribution_pct = _normalize_numeric_sequence(
volumetry.get("channel_distribution_pct")
)
peak_offpeak_ratio = volumetry.get("peak_offpeak_ratio")
aht_ratio = _get_nested(op, "aht_distribution", "p90_p50_ratio")
escalation_rate = op.get("escalation_rate")
annual_savings = _get_nested(econ, "potential_savings", "annual_savings")
# --- Calculamos sub-scores (cada uno decide si está 'computed' o no) ---
repet = score_repetitividad(volume_by_skill)
pred = score_predictibilidad(aht_ratio, escalation_rate)
estr = score_estructuracion(channel_distribution_pct)
comp = score_complejidad(aht_ratio, escalation_rate)
estab = score_estabilidad(peak_offpeak_ratio)
roi = score_roi(annual_savings)
sub_scores = {
"repetitividad": repet,
"predictibilidad": pred,
"estructuracion": estr,
"complejidad": comp,
"estabilidad": estab,
"roi": roi,
}
# --- Renormalización de pesos sólo con dimensiones disponibles ---
effective_weights: Dict[str, float] = {}
for name, base_w in self.base_weights.items():
dim = sub_scores.get(name, {})
if dim.get("computed"):
effective_weights[name] = base_w
total_effective_weight = sum(effective_weights.values())
if total_effective_weight > 0:
normalized_weights = {
name: w / total_effective_weight for name, w in effective_weights.items()
}
else:
normalized_weights = {}
# --- Score final ---
if not normalized_weights:
final_score: Optional[float] = None
else:
acc = 0.0
for name, dim in sub_scores.items():
if not dim.get("computed"):
continue
w = normalized_weights.get(name, 0.0)
acc += (dim.get("score") or 0.0) * w
final_score = round(acc, 2)
classification = classify_agentic_score(final_score)
result = {
"agentic_readiness": {
"version": "1.0",
"final_score": final_score,
"classification": classification,
"weights": {
"base_weights": self.base_weights,
"normalized_weights": normalized_weights,
},
"sub_scores": sub_scores,
"metadata": {
"source_module": "agentic_score.py",
"notes": (
"Modelo simplificado basado en KPIs agregados. "
"Renormaliza los pesos cuando faltan dimensiones."
),
},
}
}
return result
def compute_and_return(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""
Permite calcular el Agentic Readiness directamente desde
un objeto Python (dict), sin necesidad de carpetas ni archivos.
"""
return self.compute_from_data(data)
def run_on_folder(self, folder_path: Union[str, Path]) -> Dict[str, Any]:
"""
Punto de entrada típico para el pipeline:
- Lee <folder>/results.json
- Calcula Agentic Readiness
- Escribe <folder>/agentic_readiness.json
- Devuelve el dict con el resultado
"""
data = self.load_results(folder_path)
result = self.compute_from_data(data)
self.save_agentic_readiness(folder_path, result)
return result
# =========================
# CLI opcional
# =========================
def main(argv: List[str]) -> None:
if len(argv) < 2:
print(
"Uso: python agentic_score.py <carpeta_resultados>\n"
"La carpeta debe contener un 'results.json'. Se generará un "
"'agentic_readiness.json' en la misma carpeta.",
file=sys.stderr,
)
sys.exit(1)
folder = argv[1]
scorer = AgenticScorer()
try:
result = scorer.run_on_folder(folder)
except Exception as e:
print(f"Error al procesar la carpeta '{folder}': {e}", file=sys.stderr)
sys.exit(1)
# Por comodidad, también mostramos el score final por consola
ar = result.get("agentic_readiness", {})
print(json.dumps(result, ensure_ascii=False, indent=2))
final_score = ar.get("final_score")
classification = ar.get("classification", {})
label = classification.get("label")
emoji = classification.get("emoji")
if final_score is not None and label:
print(f"\nAgentic Readiness Score: {final_score} {emoji} ({label})")
if __name__ == "__main__":
main(sys.argv)

View File

@@ -0,0 +1,55 @@
"""
beyond_metrics package
======================
Capa pública del sistema BeyondMetrics.
Expone:
- Dimensiones (Volumetría, Eficiencia, ...)
- Pipeline principal
- Conectores de IO (local, S3, ...)
"""
from .dimensions import (
VolumetriaMetrics,
OperationalPerformanceMetrics,
SatisfactionExperienceMetrics,
EconomyCostMetrics,
)
from .pipeline import (
BeyondMetricsPipeline,
build_pipeline,
load_dimensions_config, # opcional, pero útil
)
from .io import (
DataSource,
ResultsSink,
LocalDataSource,
LocalResultsSink,
S3DataSource,
S3ResultsSink,
# si has añadido GoogleDrive, puedes exponerlo aquí también:
# GoogleDriveDataSource,
# GoogleDriveResultsSink,
)
__all__ = [
# Dimensiones
"VolumetriaMetrics",
"OperationalPerformanceMetrics",
"SatisfactionExperienceMetrics",
"EconomyCostMetrics",
# Pipeline
"BeyondMetricsPipeline",
"build_pipeline",
"load_dimensions_config",
# IO
"DataSource",
"ResultsSink",
"LocalDataSource",
"LocalResultsSink",
"S3DataSource",
"S3ResultsSink",
# "GoogleDriveDataSource",
# "GoogleDriveResultsSink",
]

View File

@@ -0,0 +1,310 @@
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Optional, Sequence
from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas
from reportlab.lib.utils import ImageReader
from openai import OpenAI
DEFAULT_SYSTEM_PROMPT = (
"Eres un consultor experto en contact centers. "
"Vas a recibir resultados analíticos de un sistema de métricas "
"(BeyondMetrics) en formato JSON. Tu tarea es generar un informe claro, "
"accionable y orientado a negocio, destacando los principales hallazgos, "
"riesgos y oportunidades de mejora."
)
@dataclass
class ReportAgentConfig:
"""
Configuración básica del agente de informes.
openai_api_key:
Se puede pasar explícitamente o leer de la variable de entorno OPENAI_API_KEY.
model:
Modelo de ChatGPT a utilizar, p.ej. 'gpt-4.1-mini' o similar.
system_prompt:
Prompt de sistema para controlar el estilo del informe.
"""
openai_api_key: Optional[str] = None
model: str = "gpt-4.1-mini"
system_prompt: str = DEFAULT_SYSTEM_PROMPT
class BeyondMetricsReportAgent:
"""
Agente muy sencillo que:
1) Lee el JSON de resultados de una ejecución de BeyondMetrics.
2) Construye un prompt con esos resultados.
3) Llama a ChatGPT para generar un informe en texto.
4) Guarda el informe en un PDF en disco, EMBEBIENDO las imágenes PNG
generadas por el pipeline como anexos.
MVP: centrado en texto + figuras incrustadas.
"""
def __init__(self, config: Optional[ReportAgentConfig] = None) -> None:
self.config = config or ReportAgentConfig()
api_key = self.config.openai_api_key or os.getenv("OPENAI_API_KEY")
if not api_key:
raise RuntimeError(
"Falta la API key de OpenAI. "
"Pásala en ReportAgentConfig(openai_api_key=...) o "
"define la variable de entorno OPENAI_API_KEY."
)
# Cliente de la nueva API de OpenAI
self._client = OpenAI(api_key=api_key)
# ------------------------------------------------------------------
# API pública principal
# ------------------------------------------------------------------
def generate_pdf_report(
self,
run_base: str,
output_pdf_path: Optional[str] = None,
extra_user_prompt: str = "",
) -> str:
"""
Genera un informe en PDF a partir de una carpeta de resultados.
Parámetros:
- run_base:
Carpeta base de la ejecución. Debe contener al menos 'results.json'
y, opcionalmente, imágenes PNG generadas por el pipeline.
- output_pdf_path:
Ruta completa del PDF de salida. Si es None, se crea
'beyondmetrics_report.pdf' dentro de run_base.
- extra_user_prompt:
Texto adicional para afinar la petición al agente
(p.ej. "enfatiza eficiencia y SLA", etc.)
Devuelve:
- La ruta del PDF generado.
"""
run_dir = Path(run_base)
results_json = run_dir / "results.json"
if not results_json.exists():
raise FileNotFoundError(
f"No se ha encontrado {results_json}. "
"Asegúrate de ejecutar primero el pipeline."
)
# 1) Leer JSON de resultados
with results_json.open("r", encoding="utf-8") as f:
results_data: Dict[str, Any] = json.load(f)
# 2) Buscar imágenes generadas
image_files = sorted(p for p in run_dir.glob("*.png"))
# 3) Construir prompt de usuario
user_prompt = self._build_user_prompt(
results=results_data,
image_files=[p.name for p in image_files],
extra_user_prompt=extra_user_prompt,
)
# 4) Llamar a ChatGPT para obtener el texto del informe
report_text = self._call_chatgpt(user_prompt)
# 5) Crear PDF con texto + imágenes embebidas
if output_pdf_path is None:
output_pdf_path = str(run_dir / "beyondmetrics_report.pdf")
self._write_pdf(output_pdf_path, report_text, image_files)
return output_pdf_path
# ------------------------------------------------------------------
# Construcción del prompt
# ------------------------------------------------------------------
def _build_user_prompt(
self,
results: Dict[str, Any],
image_files: Sequence[str],
extra_user_prompt: str = "",
) -> str:
"""
Construye el mensaje de usuario que se enviará al modelo.
Para un MVP, serializamos el JSON de resultados entero.
Más adelante se puede resumir si el JSON crece demasiado.
"""
results_str = json.dumps(results, indent=2, ensure_ascii=False)
images_section = (
"Imágenes generadas en la ejecución:\n"
+ "\n".join(f"- {name}" for name in image_files)
if image_files
else "No se han generado imágenes en esta ejecución."
)
extra = (
f"\n\nInstrucciones adicionales del usuario:\n{extra_user_prompt}"
if extra_user_prompt
else ""
)
prompt = (
"A continuación te proporciono los resultados de una ejecución de BeyondMetrics "
"en formato JSON. Debes elaborar un INFORME EJECUTIVO para un cliente de "
"contact center. El informe debe incluir:\n"
"- Resumen ejecutivo en lenguaje de negocio.\n"
"- Principales hallazgos por dimensión.\n"
"- Riesgos o problemas detectados.\n"
"- Recomendaciones accionables.\n\n"
"Resultados (JSON):\n"
f"{results_str}\n\n"
f"{images_section}"
f"{extra}"
)
return prompt
# ------------------------------------------------------------------
# Llamada a ChatGPT (nueva API)
# ------------------------------------------------------------------
def _call_chatgpt(self, user_prompt: str) -> str:
"""
Llama al modelo de ChatGPT y devuelve el contenido del mensaje de respuesta.
Implementado con la nueva API de OpenAI.
"""
resp = self._client.chat.completions.create(
model=self.config.model,
messages=[
{"role": "system", "content": self.config.system_prompt},
{"role": "user", "content": user_prompt},
],
temperature=0.3,
)
content = resp.choices[0].message.content
if not isinstance(content, str):
raise RuntimeError("La respuesta del modelo no contiene texto.")
return content
# ------------------------------------------------------------------
# Escritura de PDF (texto + imágenes)
# ------------------------------------------------------------------
def _write_pdf(
self,
output_path: str,
text: str,
image_paths: Sequence[Path],
) -> None:
"""
Crea un PDF A4 con:
1) Texto del informe (páginas iniciales).
2) Una sección de anexos donde se incrustan las imágenes PNG
generadas por el pipeline, escaladas para encajar en la página.
"""
output_path = str(output_path)
c = canvas.Canvas(output_path, pagesize=A4)
width, height = A4
margin_x = 50
margin_y = 50
max_width = width - 2 * margin_x
line_height = 14
c.setFont("Helvetica", 11)
# --- Escribir texto principal ---
def _wrap_line(line: str, max_chars: int = 100) -> list[str]:
parts: list[str] = []
current: list[str] = []
count = 0
for word in line.split():
if count + len(word) + 1 > max_chars:
parts.append(" ".join(current))
current = [word]
count = len(word) + 1
else:
current.append(word)
count += len(word) + 1
if current:
parts.append(" ".join(current))
return parts
y = height - margin_y
for raw_line in text.splitlines():
wrapped_lines = _wrap_line(raw_line)
for line in wrapped_lines:
if y < margin_y:
c.showPage()
c.setFont("Helvetica", 11)
y = height - margin_y
c.drawString(margin_x, y, line)
y -= line_height
# --- Anexar imágenes como figuras ---
if image_paths:
# Nueva página para las figuras
c.showPage()
c.setFont("Helvetica-Bold", 14)
c.drawString(margin_x, height - margin_y, "Anexo: Figuras")
c.setFont("Helvetica", 11)
current_y = height - margin_y - 2 * line_height
for img_path in image_paths:
# Si no cabe la imagen en la página, pasamos a la siguiente
available_height = current_y - margin_y
if available_height < 100: # espacio mínimo
c.showPage()
c.setFont("Helvetica-Bold", 14)
c.drawString(margin_x, height - margin_y, "Anexo: Figuras (cont.)")
c.setFont("Helvetica", 11)
current_y = height - margin_y - 2 * line_height
available_height = current_y - margin_y
# Título de la figura
title = f"Figura: {img_path.name}"
c.drawString(margin_x, current_y, title)
current_y -= line_height
# Cargar imagen y escalarla
try:
img = ImageReader(str(img_path))
iw, ih = img.getSize()
# Escala para encajar en ancho y alto disponibles
max_img_height = available_height - 2 * line_height
scale = min(max_width / iw, max_img_height / ih)
if scale <= 0:
scale = 1.0 # fallback
draw_w = iw * scale
draw_h = ih * scale
x = margin_x
y_img = current_y - draw_h
c.drawImage(
img,
x,
y_img,
width=draw_w,
height=draw_h,
preserveAspectRatio=True,
mask="auto",
)
current_y = y_img - 2 * line_height
except Exception as e:
# Si falla la carga, lo indicamos en el PDF
err_msg = f"No se pudo cargar la imagen {img_path.name}: {e}"
c.drawString(margin_x, current_y, err_msg)
current_y -= 2 * line_height
c.save()

View File

@@ -0,0 +1,27 @@
{
"dimensions": {
"volumetry": {
"class": "beyond_metrics.VolumetriaMetrics",
"enabled": true,
"metrics": [
"volume_by_channel",
"volume_by_skill"
]
},
"operational_performance": {
"class": "beyond_metrics.dimensions.OperationalPerformance.OperationalPerformanceMetrics",
"enabled": false,
"metrics": []
},
"customer_satisfaction": {
"class": "beyond_metrics.dimensions.SatisfactionExperience.SatisfactionExperienceMetrics",
"enabled": false,
"metrics": []
},
"economy_costs": {
"class": "beyond_metrics.dimensions.EconomyCost.EconomyCostMetrics",
"enabled": false,
"metrics": []
}
}
}

View File

@@ -0,0 +1,55 @@
{
"dimensions": {
"volumetry": {
"class": "beyond_metrics.VolumetriaMetrics",
"enabled": true,
"metrics": [
"volume_by_channel",
"volume_by_skill",
"channel_distribution_pct",
"skill_distribution_pct",
"heatmap_24x7",
"monthly_seasonality_cv",
"peak_offpeak_ratio",
"concentration_top20_skills_pct"
]
},
"operational_performance": {
"class": "beyond_metrics.dimensions.OperationalPerformance.OperationalPerformanceMetrics",
"enabled": true,
"metrics": [
"aht_distribution",
"talk_hold_acw_p50_by_skill",
"fcr_rate",
"escalation_rate",
"abandonment_rate",
"recurrence_rate_7d",
"repeat_channel_rate",
"occupancy_rate",
"performance_score"
]
},
"customer_satisfaction": {
"class": "beyond_metrics.dimensions.SatisfactionExperience.SatisfactionExperienceMetrics",
"enabled": true,
"metrics": [
"csat_avg_by_skill_channel",
"nps_avg_by_skill_channel",
"ces_avg_by_skill_channel",
"csat_aht_correlation",
"csat_aht_skill_summary"
]
},
"economy_costs": {
"class": "beyond_metrics.dimensions.EconomyCost.EconomyCostMetrics",
"enabled": true,
"metrics": [
"cpi_by_skill_channel",
"annual_cost_by_skill_channel",
"cost_breakdown",
"inefficiency_cost_by_skill_channel",
"potential_savings"
]
}
}
}

View File

@@ -0,0 +1,441 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, List, Optional, Any
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.axes import Axes
REQUIRED_COLUMNS_ECON: List[str] = [
"interaction_id",
"datetime_start",
"queue_skill",
"channel",
"duration_talk",
"hold_time",
"wrap_up_time",
]
@dataclass
class EconomyConfig:
"""
Parámetros manuales para la dimensión de Economía y Costes.
- labor_cost_per_hour: coste total/hora de un agente (fully loaded).
- overhead_rate: % overhead variable (ej. 0.1 = 10% sobre labor).
- tech_costs_annual: coste anual de tecnología (licencias, infra, ...).
- automation_cpi: coste por interacción automatizada (ej. 0.15€).
- automation_volume_share: % del volumen automatizable (0-1).
- automation_success_rate: % éxito de la automatización (0-1).
- customer_segments: mapping opcional skill -> segmento ("high"/"medium"/"low")
para futuros insights de ROI por segmento.
"""
labor_cost_per_hour: float
overhead_rate: float = 0.0
tech_costs_annual: float = 0.0
automation_cpi: Optional[float] = None
automation_volume_share: float = 0.0
automation_success_rate: float = 0.0
customer_segments: Optional[Dict[str, str]] = None
@dataclass
class EconomyCostMetrics:
"""
DIMENSIÓN 4: ECONOMÍA y COSTES
Propósito:
- Cuantificar el COSTE actual (CPI, coste anual).
- Estimar el impacto de overhead y tecnología.
- Calcular un primer estimado de "coste de ineficiencia" y ahorro potencial.
Requiere:
- Columnas del dataset transaccional (ver REQUIRED_COLUMNS_ECON).
Inputs opcionales vía EconomyConfig:
- labor_cost_per_hour (obligatorio para cualquier cálculo de €).
- overhead_rate, tech_costs_annual, automation_*.
- customer_segments (para insights de ROI por segmento).
"""
df: pd.DataFrame
config: Optional[EconomyConfig] = None
def __post_init__(self) -> None:
self._validate_columns()
self._prepare_data()
# ------------------------------------------------------------------ #
# Helpers internos
# ------------------------------------------------------------------ #
def _validate_columns(self) -> None:
missing = [c for c in REQUIRED_COLUMNS_ECON if c not in self.df.columns]
if missing:
raise ValueError(
f"Faltan columnas obligatorias para EconomyCostMetrics: {missing}"
)
def _prepare_data(self) -> None:
df = self.df.copy()
df["datetime_start"] = pd.to_datetime(df["datetime_start"], errors="coerce")
for col in ["duration_talk", "hold_time", "wrap_up_time"]:
df[col] = pd.to_numeric(df[col], errors="coerce")
df["queue_skill"] = df["queue_skill"].astype(str).str.strip()
df["channel"] = df["channel"].astype(str).str.strip()
# Handle time = talk + hold + wrap
df["handle_time"] = (
df["duration_talk"].fillna(0)
+ df["hold_time"].fillna(0)
+ df["wrap_up_time"].fillna(0)
) # segundos
self.df = df
@property
def is_empty(self) -> bool:
return self.df.empty
def _has_cost_config(self) -> bool:
return self.config is not None and self.config.labor_cost_per_hour is not None
# ------------------------------------------------------------------ #
# KPI 1: CPI por canal/skill
# ------------------------------------------------------------------ #
def cpi_by_skill_channel(self) -> pd.DataFrame:
"""
CPI (Coste Por Interacción) por skill/canal.
CPI = Labor_cost_per_interaction + Overhead_variable
- Labor_cost_per_interaction = (labor_cost_per_hour * AHT_hours)
- Overhead_variable = overhead_rate * Labor_cost_per_interaction
Si no hay config de costes -> devuelve DataFrame vacío.
"""
if not self._has_cost_config():
return pd.DataFrame()
cfg = self.config
assert cfg is not None # para el type checker
df = self.df.copy()
if df.empty:
return pd.DataFrame()
# AHT por skill/canal (en segundos)
grouped = df.groupby(["queue_skill", "channel"])["handle_time"].mean()
if grouped.empty:
return pd.DataFrame()
aht_sec = grouped
aht_hours = aht_sec / 3600.0
labor_cost = cfg.labor_cost_per_hour * aht_hours
overhead = labor_cost * cfg.overhead_rate
cpi = labor_cost + overhead
out = pd.DataFrame(
{
"aht_seconds": aht_sec.round(2),
"labor_cost": labor_cost.round(4),
"overhead_cost": overhead.round(4),
"cpi_total": cpi.round(4),
}
)
return out.sort_index()
# ------------------------------------------------------------------ #
# KPI 2: coste anual por skill/canal
# ------------------------------------------------------------------ #
def annual_cost_by_skill_channel(self) -> pd.DataFrame:
"""
Coste anual por skill/canal.
cost_annual = CPI * volumen (cantidad de interacciones de la muestra).
Nota: por simplicidad asumimos que el dataset refleja un periodo anual.
Si en el futuro quieres anualizar (ej. dataset = 1 mes) se puede añadir
un factor de escalado en EconomyConfig.
"""
cpi_table = self.cpi_by_skill_channel()
if cpi_table.empty:
return pd.DataFrame()
df = self.df.copy()
volume = (
df.groupby(["queue_skill", "channel"])["interaction_id"]
.nunique()
.rename("volume")
)
joined = cpi_table.join(volume, how="left").fillna({"volume": 0})
joined["annual_cost"] = (joined["cpi_total"] * joined["volume"]).round(2)
return joined
# ------------------------------------------------------------------ #
# KPI 3: desglose de costes (labor / tech / overhead)
# ------------------------------------------------------------------ #
def cost_breakdown(self) -> Dict[str, float]:
"""
Desglose % de costes: labor, overhead, tech.
labor_total = sum(labor_cost_per_interaction)
overhead_total = labor_total * overhead_rate
tech_total = tech_costs_annual (si se ha proporcionado)
Devuelve porcentajes sobre el total.
Si falta configuración de coste -> devuelve {}.
"""
if not self._has_cost_config():
return {}
cfg = self.config
assert cfg is not None
cpi_table = self.cpi_by_skill_channel()
if cpi_table.empty:
return {}
df = self.df.copy()
volume = (
df.groupby(["queue_skill", "channel"])["interaction_id"]
.nunique()
.rename("volume")
)
joined = cpi_table.join(volume, how="left").fillna({"volume": 0})
# Costes anuales de labor y overhead
annual_labor = (joined["labor_cost"] * joined["volume"]).sum()
annual_overhead = (joined["overhead_cost"] * joined["volume"]).sum()
annual_tech = cfg.tech_costs_annual
total = annual_labor + annual_overhead + annual_tech
if total <= 0:
return {}
return {
"labor_pct": round(annual_labor / total * 100, 2),
"overhead_pct": round(annual_overhead / total * 100, 2),
"tech_pct": round(annual_tech / total * 100, 2),
"labor_annual": round(annual_labor, 2),
"overhead_annual": round(annual_overhead, 2),
"tech_annual": round(annual_tech, 2),
"total_annual": round(total, 2),
}
# ------------------------------------------------------------------ #
# KPI 4: coste de ineficiencia (€ por variabilidad/escalación)
# ------------------------------------------------------------------ #
def inefficiency_cost_by_skill_channel(self) -> pd.DataFrame:
"""
Estimación muy simplificada de coste de ineficiencia:
Para cada skill/canal:
- AHT_p50, AHT_p90 (segundos).
- Delta = max(0, AHT_p90 - AHT_p50).
- Se asume que ~40% de las interacciones están por encima de la mediana.
- Ineff_seconds = Delta * volume * 0.4
- Ineff_cost = LaborCPI_per_second * Ineff_seconds
⚠️ Es un modelo aproximado para cuantificar "orden de magnitud".
"""
if not self._has_cost_config():
return pd.DataFrame()
cfg = self.config
assert cfg is not None
df = self.df.copy()
grouped = df.groupby(["queue_skill", "channel"])
stats = grouped["handle_time"].agg(
aht_p50=lambda s: float(np.percentile(s.dropna(), 50)),
aht_p90=lambda s: float(np.percentile(s.dropna(), 90)),
volume="count",
)
if stats.empty:
return pd.DataFrame()
# CPI para obtener coste/segundo de labor
cpi_table = self.cpi_by_skill_channel()
if cpi_table.empty:
return pd.DataFrame()
merged = stats.join(cpi_table[["labor_cost"]], how="left")
merged = merged.fillna(0.0)
delta = (merged["aht_p90"] - merged["aht_p50"]).clip(lower=0.0)
affected_fraction = 0.4 # aproximación
ineff_seconds = delta * merged["volume"] * affected_fraction
# labor_cost = coste por interacción con AHT medio;
# aproximamos coste/segundo como labor_cost / AHT_medio
aht_mean = grouped["handle_time"].mean()
merged["aht_mean"] = aht_mean
cost_per_second = merged["labor_cost"] / merged["aht_mean"].replace(0, np.nan)
cost_per_second = cost_per_second.fillna(0.0)
ineff_cost = (ineff_seconds * cost_per_second).round(2)
merged["ineff_seconds"] = ineff_seconds.round(2)
merged["ineff_cost"] = ineff_cost
return merged[["aht_p50", "aht_p90", "volume", "ineff_seconds", "ineff_cost"]]
# ------------------------------------------------------------------ #
# KPI 5: ahorro potencial anual por automatización
# ------------------------------------------------------------------ #
def potential_savings(self) -> Dict[str, Any]:
"""
Ahorro potencial anual basado en:
Ahorro = (CPI_humano - CPI_automatizado) * Volumen_automatizable * Tasa_éxito
Donde:
- CPI_humano = media ponderada de cpi_total.
- CPI_automatizado = config.automation_cpi
- Volumen_automatizable = volume_total * automation_volume_share
- Tasa_éxito = automation_success_rate
Si faltan parámetros en config -> devuelve {}.
"""
if not self._has_cost_config():
return {}
cfg = self.config
assert cfg is not None
if cfg.automation_cpi is None or cfg.automation_volume_share <= 0 or cfg.automation_success_rate <= 0:
return {}
cpi_table = self.annual_cost_by_skill_channel()
if cpi_table.empty:
return {}
total_volume = cpi_table["volume"].sum()
if total_volume <= 0:
return {}
# CPI humano medio ponderado
weighted_cpi = (
(cpi_table["cpi_total"] * cpi_table["volume"]).sum() / total_volume
)
volume_automatizable = total_volume * cfg.automation_volume_share
effective_volume = volume_automatizable * cfg.automation_success_rate
delta_cpi = max(0.0, weighted_cpi - cfg.automation_cpi)
annual_savings = delta_cpi * effective_volume
return {
"cpi_humano": round(weighted_cpi, 4),
"cpi_automatizado": round(cfg.automation_cpi, 4),
"volume_total": float(total_volume),
"volume_automatizable": float(volume_automatizable),
"effective_volume": float(effective_volume),
"annual_savings": round(annual_savings, 2),
}
# ------------------------------------------------------------------ #
# PLOTS
# ------------------------------------------------------------------ #
def plot_cost_waterfall(self) -> Axes:
"""
Waterfall de costes anuales (labor + tech + overhead).
"""
breakdown = self.cost_breakdown()
if not breakdown:
fig, ax = plt.subplots()
ax.text(0.5, 0.5, "Sin configuración de costes", ha="center", va="center")
ax.set_axis_off()
return ax
labels = ["Labor", "Overhead", "Tech"]
values = [
breakdown["labor_annual"],
breakdown["overhead_annual"],
breakdown["tech_annual"],
]
fig, ax = plt.subplots(figsize=(8, 4))
running = 0.0
positions = []
bottoms = []
for v in values:
positions.append(running)
bottoms.append(running)
running += v
# barras estilo waterfall
x = np.arange(len(labels))
ax.bar(x, values)
ax.set_xticks(x)
ax.set_xticklabels(labels)
ax.set_ylabel("€ anuales")
ax.set_title("Desglose anual de costes")
for idx, v in enumerate(values):
ax.text(idx, v, f"{v:,.0f}", ha="center", va="bottom")
ax.grid(axis="y", alpha=0.3)
return ax
def plot_cpi_by_channel(self) -> Axes:
"""
Gráfico de barras de CPI medio por canal.
"""
cpi_table = self.cpi_by_skill_channel()
if cpi_table.empty:
fig, ax = plt.subplots()
ax.text(0.5, 0.5, "Sin configuración de costes", ha="center", va="center")
ax.set_axis_off()
return ax
df = self.df.copy()
volume = (
df.groupby(["queue_skill", "channel"])["interaction_id"]
.nunique()
.rename("volume")
)
joined = cpi_table.join(volume, how="left").fillna({"volume": 0})
# CPI medio ponderado por canal
per_channel = (
joined.reset_index()
.groupby("channel")
.apply(lambda g: (g["cpi_total"] * g["volume"]).sum() / max(g["volume"].sum(), 1))
.rename("cpi_mean")
.round(4)
)
fig, ax = plt.subplots(figsize=(6, 4))
per_channel.plot(kind="bar", ax=ax)
ax.set_xlabel("Canal")
ax.set_ylabel("CPI medio (€)")
ax.set_title("Coste por interacción (CPI) por canal")
ax.grid(axis="y", alpha=0.3)
return ax

View File

@@ -0,0 +1,481 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, List
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.axes import Axes
REQUIRED_COLUMNS_OP: List[str] = [
"interaction_id",
"datetime_start",
"queue_skill",
"channel",
"duration_talk",
"hold_time",
"wrap_up_time",
"agent_id",
"transfer_flag",
]
@dataclass
class OperationalPerformanceMetrics:
"""
Dimensión: RENDIMIENTO OPERACIONAL Y DE SERVICIO
Propósito: medir el balance entre rapidez (eficiencia) y calidad de resolución,
más la variabilidad del servicio.
Requiere como mínimo:
- interaction_id
- datetime_start
- queue_skill
- channel
- duration_talk (segundos)
- hold_time (segundos)
- wrap_up_time (segundos)
- agent_id
- transfer_flag (bool/int)
Columnas opcionales:
- is_resolved (bool/int) -> para FCR
- abandoned_flag (bool/int) -> para tasa de abandono
- customer_id / caller_id -> para reincidencia y repetición de canal
- logged_time (segundos) -> para occupancy_rate
"""
df: pd.DataFrame
# Benchmarks / parámetros de normalización (puedes ajustarlos)
AHT_GOOD: float = 300.0 # 5 min
AHT_BAD: float = 900.0 # 15 min
VAR_RATIO_GOOD: float = 1.2 # P90/P50 ~1.2 muy estable
VAR_RATIO_BAD: float = 3.0 # P90/P50 >=3 muy inestable
def __post_init__(self) -> None:
self._validate_columns()
self._prepare_data()
# ------------------------------------------------------------------ #
# Helpers internos
# ------------------------------------------------------------------ #
def _validate_columns(self) -> None:
missing = [c for c in REQUIRED_COLUMNS_OP if c not in self.df.columns]
if missing:
raise ValueError(
f"Faltan columnas obligatorias para OperationalPerformanceMetrics: {missing}"
)
def _prepare_data(self) -> None:
df = self.df.copy()
# Tipos
df["datetime_start"] = pd.to_datetime(df["datetime_start"], errors="coerce")
for col in ["duration_talk", "hold_time", "wrap_up_time"]:
df[col] = pd.to_numeric(df[col], errors="coerce")
# Handle Time
df["handle_time"] = (
df["duration_talk"].fillna(0)
+ df["hold_time"].fillna(0)
+ df["wrap_up_time"].fillna(0)
)
# Normalización básica
df["queue_skill"] = df["queue_skill"].astype(str).str.strip()
df["channel"] = df["channel"].astype(str).str.strip()
df["agent_id"] = df["agent_id"].astype(str).str.strip()
# Flags opcionales convertidos a bool cuando existan
for flag_col in ["is_resolved", "abandoned_flag", "transfer_flag"]:
if flag_col in df.columns:
df[flag_col] = df[flag_col].astype(int).astype(bool)
# customer_id: usamos customer_id si existe, si no caller_id
if "customer_id" in df.columns:
df["customer_id"] = df["customer_id"].astype(str)
elif "caller_id" in df.columns:
df["customer_id"] = df["caller_id"].astype(str)
else:
df["customer_id"] = None
# logged_time opcional
# Normalizamos logged_time: siempre será una serie float con NaN si no existe
df["logged_time"] = pd.to_numeric(df.get("logged_time", np.nan), errors="coerce")
self.df = df
@property
def is_empty(self) -> bool:
return self.df.empty
# ------------------------------------------------------------------ #
# AHT y variabilidad
# ------------------------------------------------------------------ #
def aht_distribution(self) -> Dict[str, float]:
"""
Devuelve P10, P50, P90 del AHT y el ratio P90/P50 como medida de variabilidad.
"""
ht = self.df["handle_time"].dropna().astype(float)
if ht.empty:
return {}
p10 = float(np.percentile(ht, 10))
p50 = float(np.percentile(ht, 50))
p90 = float(np.percentile(ht, 90))
ratio = float(p90 / p50) if p50 > 0 else float("nan")
return {
"p10": round(p10, 2),
"p50": round(p50, 2),
"p90": round(p90, 2),
"p90_p50_ratio": round(ratio, 3),
}
def talk_hold_acw_p50_by_skill(self) -> pd.DataFrame:
"""
P50 de talk_time, hold_time y wrap_up_time por skill.
"""
df = self.df
def perc(s: pd.Series, q: float) -> float:
s = s.dropna().astype(float)
if s.empty:
return float("nan")
return float(np.percentile(s, q))
grouped = df.groupby("queue_skill")
result = pd.DataFrame(
{
"talk_p50": grouped["duration_talk"].apply(lambda s: perc(s, 50)),
"hold_p50": grouped["hold_time"].apply(lambda s: perc(s, 50)),
"acw_p50": grouped["wrap_up_time"].apply(lambda s: perc(s, 50)),
}
)
return result.round(2).sort_index()
# ------------------------------------------------------------------ #
# FCR, escalación, abandono, reincidencia, repetición canal
# ------------------------------------------------------------------ #
def fcr_rate(self) -> float:
"""
FCR = % de interacciones resueltas en el primer contacto.
Definido como % de filas con is_resolved == True.
Si la columna no existe, devuelve NaN.
"""
df = self.df
if "is_resolved" not in df.columns:
return float("nan")
total = len(df)
if total == 0:
return float("nan")
resolved = df["is_resolved"].sum()
return float(round(resolved / total * 100, 2))
def escalation_rate(self) -> float:
"""
% de interacciones que requieren escalación (transfer_flag == True).
"""
df = self.df
total = len(df)
if total == 0:
return float("nan")
escalated = df["transfer_flag"].sum()
return float(round(escalated / total * 100, 2))
def abandonment_rate(self) -> float:
"""
% de interacciones abandonadas.
Definido como % de filas con abandoned_flag == True.
Si la columna no existe, devuelve NaN.
"""
df = self.df
if "abandoned_flag" not in df.columns:
return float("nan")
total = len(df)
if total == 0:
return float("nan")
abandoned = df["abandoned_flag"].sum()
return float(round(abandoned / total * 100, 2))
def recurrence_rate_7d(self) -> float:
"""
% de clientes que vuelven a contactar en < 7 días.
Se basa en customer_id (o caller_id si no hay customer_id).
Calcula:
- Para cada cliente, ordena por datetime_start
- Si hay dos contactos consecutivos separados < 7 días, cuenta como "recurrente"
- Tasa = nº clientes recurrentes / nº total de clientes
"""
df = self.df.dropna(subset=["datetime_start"]).copy()
if df["customer_id"].isna().all():
return float("nan")
customers = df["customer_id"].dropna().unique()
if len(customers) == 0:
return float("nan")
recurrent_customers = 0
for cust in customers:
sub = df[df["customer_id"] == cust].sort_values("datetime_start")
if len(sub) < 2:
continue
deltas = sub["datetime_start"].diff().dropna()
if (deltas < pd.Timedelta(days=7)).any():
recurrent_customers += 1
if len(customers) == 0:
return float("nan")
return float(round(recurrent_customers / len(customers) * 100, 2))
def repeat_channel_rate(self) -> float:
"""
% de reincidencias (<7 días) en las que el cliente usa el MISMO canal.
Si no hay customer_id/caller_id o solo un contacto por cliente, devuelve NaN.
"""
df = self.df.dropna(subset=["datetime_start"]).copy()
if df["customer_id"].isna().all():
return float("nan")
df = df.sort_values(["customer_id", "datetime_start"])
df["next_customer"] = df["customer_id"].shift(-1)
df["next_datetime"] = df["datetime_start"].shift(-1)
df["next_channel"] = df["channel"].shift(-1)
same_customer = df["customer_id"] == df["next_customer"]
within_7d = (df["next_datetime"] - df["datetime_start"]) < pd.Timedelta(days=7)
recurrent_mask = same_customer & within_7d
if not recurrent_mask.any():
return float("nan")
same_channel = df["channel"] == df["next_channel"]
same_channel_recurrent = (recurrent_mask & same_channel).sum()
total_recurrent = recurrent_mask.sum()
return float(round(same_channel_recurrent / total_recurrent * 100, 2))
# ------------------------------------------------------------------ #
# Occupancy
# ------------------------------------------------------------------ #
def occupancy_rate(self) -> float:
"""
Tasa de ocupación:
occupancy = sum(handle_time) / sum(logged_time) * 100.
Requiere columna 'logged_time'. Si no existe o es todo 0, devuelve NaN.
"""
df = self.df
if "logged_time" not in df.columns:
return float("nan")
logged = df["logged_time"].fillna(0)
handle = df["handle_time"].fillna(0)
total_logged = logged.sum()
if total_logged == 0:
return float("nan")
occ = handle.sum() / total_logged
return float(round(occ * 100, 2))
# ------------------------------------------------------------------ #
# Score de rendimiento 0-10
# ------------------------------------------------------------------ #
def performance_score(self) -> Dict[str, float]:
"""
Calcula un score 0-10 combinando:
- AHT (bajo es mejor)
- FCR (alto es mejor)
- Variabilidad (P90/P50, bajo es mejor)
- Otros factores (ocupación / escalación)
Fórmula:
score = 0.4 * (10 - AHT_norm) +
0.3 * FCR_norm +
0.2 * (10 - Var_norm) +
0.1 * Otros_score
Donde *_norm son valores en escala 0-10.
"""
dist = self.aht_distribution()
if not dist:
return {"score": float("nan")}
p50 = dist["p50"]
ratio = dist["p90_p50_ratio"]
# AHT_normalized: 0 (mejor) a 10 (peor)
aht_norm = self._scale_to_0_10(p50, self.AHT_GOOD, self.AHT_BAD)
# FCR_normalized: 0-10 directamente desde % (0-100)
fcr_pct = self.fcr_rate()
fcr_norm = fcr_pct / 10.0 if not np.isnan(fcr_pct) else 0.0
# Variabilidad_normalized: 0 (ratio bueno) a 10 (ratio malo)
var_norm = self._scale_to_0_10(ratio, self.VAR_RATIO_GOOD, self.VAR_RATIO_BAD)
# Otros factores: combinamos ocupación (ideal ~80%) y escalación (ideal baja)
occ = self.occupancy_rate()
esc = self.escalation_rate()
other_score = self._compute_other_factors_score(occ, esc)
score = (
0.4 * (10.0 - aht_norm)
+ 0.3 * fcr_norm
+ 0.2 * (10.0 - var_norm)
+ 0.1 * other_score
)
# Clamp 0-10
score = max(0.0, min(10.0, score))
return {
"score": round(score, 2),
"aht_norm": round(aht_norm, 2),
"fcr_norm": round(fcr_norm, 2),
"var_norm": round(var_norm, 2),
"other_score": round(other_score, 2),
}
def _scale_to_0_10(self, value: float, good: float, bad: float) -> float:
"""
Escala linealmente un valor:
- good -> 0
- bad -> 10
Con saturación fuera de rango.
"""
if np.isnan(value):
return 5.0 # neutro
if good == bad:
return 5.0
if good < bad:
# Menor es mejor
if value <= good:
return 0.0
if value >= bad:
return 10.0
return 10.0 * (value - good) / (bad - good)
else:
# Mayor es mejor
if value >= good:
return 0.0
if value <= bad:
return 10.0
return 10.0 * (good - value) / (good - bad)
def _compute_other_factors_score(self, occ_pct: float, esc_pct: float) -> float:
"""
Otros factores (0-10) basados en:
- ocupación ideal alrededor de 80%
- tasa de escalación ideal baja (<10%)
"""
# Ocupación: 0 penalización si está entre 75-85, se penaliza fuera
if np.isnan(occ_pct):
occ_penalty = 5.0
else:
deviation = abs(occ_pct - 80.0)
occ_penalty = min(10.0, deviation / 5.0 * 2.0) # cada 5 puntos se suman 2, máx 10
occ_score = max(0.0, 10.0 - occ_penalty)
# Escalación: 0-10 donde 0% -> 10 puntos, >=40% -> 0
if np.isnan(esc_pct):
esc_score = 5.0
else:
if esc_pct <= 0:
esc_score = 10.0
elif esc_pct >= 40:
esc_score = 0.0
else:
esc_score = 10.0 * (1.0 - esc_pct / 40.0)
# Media simple de ambos
return (occ_score + esc_score) / 2.0
# ------------------------------------------------------------------ #
# Plots
# ------------------------------------------------------------------ #
def plot_aht_boxplot_by_skill(self) -> Axes:
"""
Boxplot del AHT por skill (P10-P50-P90 visual).
"""
df = self.df.copy()
if df.empty or "handle_time" not in df.columns:
fig, ax = plt.subplots()
ax.text(0.5, 0.5, "Sin datos de AHT", ha="center", va="center")
ax.set_axis_off()
return ax
df = df.dropna(subset=["handle_time"])
if df.empty:
fig, ax = plt.subplots()
ax.text(0.5, 0.5, "AHT no disponible", ha="center", va="center")
ax.set_axis_off()
return ax
fig, ax = plt.subplots(figsize=(8, 4))
df.boxplot(column="handle_time", by="queue_skill", ax=ax, showfliers=False)
ax.set_xlabel("Skill / Cola")
ax.set_ylabel("AHT (segundos)")
ax.set_title("Distribución de AHT por skill")
plt.suptitle("")
plt.xticks(rotation=45, ha="right")
ax.grid(axis="y", alpha=0.3)
return ax
def plot_resolution_funnel_by_skill(self) -> Axes:
"""
Funnel / barras apiladas de Talk + Hold + ACW por skill (P50).
Permite ver el equilibrio de tiempos por skill.
"""
p50 = self.talk_hold_acw_p50_by_skill()
if p50.empty:
fig, ax = plt.subplots()
ax.text(0.5, 0.5, "Sin datos para funnel", ha="center", va="center")
ax.set_axis_off()
return ax
fig, ax = plt.subplots(figsize=(10, 4))
skills = p50.index
talk = p50["talk_p50"]
hold = p50["hold_p50"]
acw = p50["acw_p50"]
x = np.arange(len(skills))
ax.bar(x, talk, label="Talk P50")
ax.bar(x, hold, bottom=talk, label="Hold P50")
ax.bar(x, acw, bottom=talk + hold, label="ACW P50")
ax.set_xticks(x)
ax.set_xticklabels(skills, rotation=45, ha="right")
ax.set_ylabel("Segundos")
ax.set_title("Funnel de resolución (P50) por skill")
ax.legend()
ax.grid(axis="y", alpha=0.3)
return ax

View File

@@ -0,0 +1,298 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, List, Any
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.axes import Axes
# Solo columnas del dataset “core”
REQUIRED_COLUMNS_SAT: List[str] = [
"interaction_id",
"datetime_start",
"queue_skill",
"channel",
"duration_talk",
"hold_time",
"wrap_up_time",
]
@dataclass
class SatisfactionExperienceMetrics:
"""
Dimensión 3: SATISFACCIÓN y EXPERIENCIA
Todas las columnas de satisfacción (csat/nps/ces/aht) son OPCIONALES.
Si no están, las métricas que las usan devuelven vacío/NaN pero
nunca rompen el pipeline.
"""
df: pd.DataFrame
def __post_init__(self) -> None:
self._validate_columns()
self._prepare_data()
# ------------------------------------------------------------------ #
# Helpers
# ------------------------------------------------------------------ #
def _validate_columns(self) -> None:
missing = [c for c in REQUIRED_COLUMNS_SAT if c not in self.df.columns]
if missing:
raise ValueError(
f"Faltan columnas obligatorias para SatisfactionExperienceMetrics: {missing}"
)
def _prepare_data(self) -> None:
df = self.df.copy()
df["datetime_start"] = pd.to_datetime(df["datetime_start"], errors="coerce")
# Duraciones base siempre existen
for col in ["duration_talk", "hold_time", "wrap_up_time"]:
df[col] = pd.to_numeric(df[col], errors="coerce")
# Handle time
df["handle_time"] = (
df["duration_talk"].fillna(0)
+ df["hold_time"].fillna(0)
+ df["wrap_up_time"].fillna(0)
)
# csat_score opcional
df["csat_score"] = pd.to_numeric(df.get("csat_score", np.nan), errors="coerce")
# aht opcional: si existe columna explícita la usamos, si no usamos handle_time
if "aht" in df.columns:
df["aht"] = pd.to_numeric(df["aht"], errors="coerce")
else:
df["aht"] = df["handle_time"]
# NPS / CES opcionales
df["nps_score"] = pd.to_numeric(df.get("nps_score", np.nan), errors="coerce")
df["ces_score"] = pd.to_numeric(df.get("ces_score", np.nan), errors="coerce")
df["queue_skill"] = df["queue_skill"].astype(str).str.strip()
df["channel"] = df["channel"].astype(str).str.strip()
self.df = df
@property
def is_empty(self) -> bool:
return self.df.empty
# ------------------------------------------------------------------ #
# KPIs
# ------------------------------------------------------------------ #
def csat_avg_by_skill_channel(self) -> pd.DataFrame:
"""
CSAT promedio por skill/canal.
Si no hay csat_score, devuelve DataFrame vacío.
"""
df = self.df
if "csat_score" not in df.columns or df["csat_score"].notna().sum() == 0:
return pd.DataFrame()
df = df.dropna(subset=["csat_score"])
if df.empty:
return pd.DataFrame()
pivot = (
df.pivot_table(
index="queue_skill",
columns="channel",
values="csat_score",
aggfunc="mean",
)
.sort_index()
.round(2)
)
return pivot
def nps_avg_by_skill_channel(self) -> pd.DataFrame:
"""
NPS medio por skill/canal, si existe nps_score.
"""
df = self.df
if "nps_score" not in df.columns or df["nps_score"].notna().sum() == 0:
return pd.DataFrame()
df = df.dropna(subset=["nps_score"])
if df.empty:
return pd.DataFrame()
pivot = (
df.pivot_table(
index="queue_skill",
columns="channel",
values="nps_score",
aggfunc="mean",
)
.sort_index()
.round(2)
)
return pivot
def ces_avg_by_skill_channel(self) -> pd.DataFrame:
"""
CES medio por skill/canal, si existe ces_score.
"""
df = self.df
if "ces_score" not in df.columns or df["ces_score"].notna().sum() == 0:
return pd.DataFrame()
df = df.dropna(subset=["ces_score"])
if df.empty:
return pd.DataFrame()
pivot = (
df.pivot_table(
index="queue_skill",
columns="channel",
values="ces_score",
aggfunc="mean",
)
.sort_index()
.round(2)
)
return pivot
def csat_aht_correlation(self) -> Dict[str, Any]:
"""
Correlación Pearson CSAT vs AHT.
Si falta csat o aht, o no hay varianza, devuelve NaN y código adecuado.
"""
df = self.df
if "csat_score" not in df.columns or df["csat_score"].notna().sum() == 0:
return {"r": float("nan"), "n": 0.0, "interpretation_code": "sin_datos"}
if "aht" not in df.columns or df["aht"].notna().sum() == 0:
return {"r": float("nan"), "n": 0.0, "interpretation_code": "sin_datos"}
df = df.dropna(subset=["csat_score", "aht"]).copy()
n = len(df)
if n < 2:
return {"r": float("nan"), "n": float(n), "interpretation_code": "insuficiente"}
x = df["aht"].astype(float)
y = df["csat_score"].astype(float)
if x.std(ddof=1) == 0 or y.std(ddof=1) == 0:
return {"r": float("nan"), "n": float(n), "interpretation_code": "sin_varianza"}
r = float(np.corrcoef(x, y)[0, 1])
if r < -0.3:
interpretation = "negativo"
elif r > 0.3:
interpretation = "positivo"
else:
interpretation = "neutral"
return {"r": round(r, 3), "n": float(n), "interpretation_code": interpretation}
def csat_aht_skill_summary(self) -> pd.DataFrame:
"""
Resumen por skill con clasificación del "sweet spot".
Si falta csat o aht, devuelve DataFrame vacío.
"""
df = self.df
if df["csat_score"].notna().sum() == 0 or df["aht"].notna().sum() == 0:
return pd.DataFrame(columns=["csat_avg", "aht_avg", "classification"])
df = df.dropna(subset=["csat_score", "aht"]).copy()
if df.empty:
return pd.DataFrame(columns=["csat_avg", "aht_avg", "classification"])
grouped = df.groupby("queue_skill").agg(
csat_avg=("csat_score", "mean"),
aht_avg=("aht", "mean"),
)
aht_all = df["aht"].astype(float)
csat_all = df["csat_score"].astype(float)
aht_p40 = float(np.percentile(aht_all, 40))
aht_p60 = float(np.percentile(aht_all, 60))
csat_p40 = float(np.percentile(csat_all, 40))
csat_p60 = float(np.percentile(csat_all, 60))
def classify(row) -> str:
csat = row["csat_avg"]
aht = row["aht_avg"]
if aht <= aht_p40 and csat >= csat_p60:
return "ideal_automatizar"
if aht >= aht_p60 and csat >= csat_p40:
return "requiere_humano"
return "neutral"
grouped["classification"] = grouped.apply(classify, axis=1)
return grouped.round({"csat_avg": 2, "aht_avg": 2})
# ------------------------------------------------------------------ #
# Plots
# ------------------------------------------------------------------ #
def plot_csat_vs_aht_scatter(self) -> Axes:
"""
Scatter CSAT vs AHT por skill.
Si no hay datos suficientes, devuelve un Axes con mensaje.
"""
df = self.df
if df["csat_score"].notna().sum() == 0 or df["aht"].notna().sum() == 0:
fig, ax = plt.subplots()
ax.text(0.5, 0.5, "Sin datos de CSAT/AHT", ha="center", va="center")
ax.set_axis_off()
return ax
df = df.dropna(subset=["csat_score", "aht"]).copy()
if df.empty:
fig, ax = plt.subplots()
ax.text(0.5, 0.5, "Sin datos de CSAT/AHT", ha="center", va="center")
ax.set_axis_off()
return ax
fig, ax = plt.subplots(figsize=(8, 5))
for skill, sub in df.groupby("queue_skill"):
ax.scatter(sub["aht"], sub["csat_score"], label=skill, alpha=0.7)
ax.set_xlabel("AHT (segundos)")
ax.set_ylabel("CSAT")
ax.set_title("CSAT vs AHT por skill")
ax.grid(alpha=0.3)
ax.legend(title="Skill", bbox_to_anchor=(1.05, 1), loc="upper left")
plt.tight_layout()
return ax
def plot_csat_distribution(self) -> Axes:
"""
Histograma de CSAT.
Si no hay csat_score, devuelve un Axes con mensaje.
"""
df = self.df
if "csat_score" not in df.columns or df["csat_score"].notna().sum() == 0:
fig, ax = plt.subplots()
ax.text(0.5, 0.5, "Sin datos de CSAT", ha="center", va="center")
ax.set_axis_off()
return ax
df = df.dropna(subset=["csat_score"]).copy()
if df.empty:
fig, ax = plt.subplots()
ax.text(0.5, 0.5, "Sin datos de CSAT", ha="center", va="center")
ax.set_axis_off()
return ax
fig, ax = plt.subplots(figsize=(6, 4))
ax.hist(df["csat_score"], bins=10, alpha=0.7)
ax.set_xlabel("CSAT")
ax.set_ylabel("Frecuencia")
ax.set_title("Distribución de CSAT")
ax.grid(axis="y", alpha=0.3)
return ax

View File

@@ -0,0 +1,268 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import List
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.axes import Axes
REQUIRED_COLUMNS_VOLUMETRIA: List[str] = [
"interaction_id",
"datetime_start",
"queue_skill",
"channel",
]
@dataclass
class VolumetriaMetrics:
"""
Métricas de volumetría basadas en el nuevo esquema de datos.
Columnas mínimas requeridas:
- interaction_id
- datetime_start
- queue_skill
- channel
Otras columnas pueden existir pero no son necesarias para estas métricas.
"""
df: pd.DataFrame
def __post_init__(self) -> None:
self._validate_columns()
self._prepare_data()
# ------------------------------------------------------------------ #
# Helpers internos
# ------------------------------------------------------------------ #
def _validate_columns(self) -> None:
missing = [c for c in REQUIRED_COLUMNS_VOLUMETRIA if c not in self.df.columns]
if missing:
raise ValueError(
f"Faltan columnas obligatorias para VolumetriaMetrics: {missing}"
)
def _prepare_data(self) -> None:
df = self.df.copy()
# Asegurar tipo datetime
df["datetime_start"] = pd.to_datetime(df["datetime_start"], errors="coerce")
# Normalizar strings
df["queue_skill"] = df["queue_skill"].astype(str).str.strip()
df["channel"] = df["channel"].astype(str).str.strip()
# Guardamos el df preparado
self.df = df
# ------------------------------------------------------------------ #
# Propiedades útiles
# ------------------------------------------------------------------ #
@property
def is_empty(self) -> bool:
return self.df.empty
# ------------------------------------------------------------------ #
# Métricas numéricas / tabulares
# ------------------------------------------------------------------ #
def volume_by_channel(self) -> pd.Series:
"""
Nº de interacciones por canal.
"""
return self.df.groupby("channel")["interaction_id"].nunique().sort_values(
ascending=False
)
def volume_by_skill(self) -> pd.Series:
"""
Nº de interacciones por skill / cola.
"""
return self.df.groupby("queue_skill")["interaction_id"].nunique().sort_values(
ascending=False
)
def channel_distribution_pct(self) -> pd.Series:
"""
Distribución porcentual del volumen por canal.
"""
counts = self.volume_by_channel()
total = counts.sum()
if total == 0:
return counts * 0.0
return (counts / total * 100).round(2)
def skill_distribution_pct(self) -> pd.Series:
"""
Distribución porcentual del volumen por skill.
"""
counts = self.volume_by_skill()
total = counts.sum()
if total == 0:
return counts * 0.0
return (counts / total * 100).round(2)
def heatmap_24x7(self) -> pd.DataFrame:
"""
Matriz [día_semana x hora] con nº de interacciones.
dayofweek: 0=Lunes ... 6=Domingo
"""
df = self.df.dropna(subset=["datetime_start"]).copy()
if df.empty:
# Devolvemos un df vacío pero con índice/columnas esperadas
idx = range(7)
cols = range(24)
return pd.DataFrame(0, index=idx, columns=cols)
df["dow"] = df["datetime_start"].dt.dayofweek
df["hour"] = df["datetime_start"].dt.hour
pivot = (
df.pivot_table(
index="dow",
columns="hour",
values="interaction_id",
aggfunc="nunique",
fill_value=0,
)
.reindex(index=range(7), fill_value=0)
.reindex(columns=range(24), fill_value=0)
)
return pivot
def monthly_seasonality_cv(self) -> float:
"""
Coeficiente de variación del volumen mensual.
CV = std / mean (en %).
"""
df = self.df.dropna(subset=["datetime_start"]).copy()
if df.empty:
return float("nan")
df["year_month"] = df["datetime_start"].dt.to_period("M")
monthly_counts = (
df.groupby("year_month")["interaction_id"].nunique().astype(float)
)
if len(monthly_counts) < 2:
return float("nan")
mean = monthly_counts.mean()
std = monthly_counts.std(ddof=1)
if mean == 0:
return float("nan")
return float(round(std / mean * 100, 2))
def peak_offpeak_ratio(self) -> float:
"""
Ratio de volumen entre horas pico y valle.
Definimos pico como horas 10:0019:59, resto valle.
"""
df = self.df.dropna(subset=["datetime_start"]).copy()
if df.empty:
return float("nan")
df["hour"] = df["datetime_start"].dt.hour
peak_hours = list(range(10, 20))
is_peak = df["hour"].isin(peak_hours)
peak_vol = df.loc[is_peak, "interaction_id"].nunique()
off_vol = df.loc[~is_peak, "interaction_id"].nunique()
if off_vol == 0:
return float("inf") if peak_vol > 0 else float("nan")
return float(round(peak_vol / off_vol, 3))
def concentration_top20_skills_pct(self) -> float:
"""
% del volumen concentrado en el top 20% de skills (por nº de interacciones).
"""
counts = (
self.df.groupby("queue_skill")["interaction_id"].nunique().sort_values(
ascending=False
)
)
n_skills = len(counts)
if n_skills == 0:
return float("nan")
top_n = max(1, int(np.ceil(0.2 * n_skills)))
top_vol = counts.head(top_n).sum()
total = counts.sum()
if total == 0:
return float("nan")
return float(round(top_vol / total * 100, 2))
# ------------------------------------------------------------------ #
# Plots
# ------------------------------------------------------------------ #
def plot_heatmap_24x7(self) -> Axes:
"""
Heatmap de volumen por día de la semana (0-6) y hora (0-23).
Devuelve Axes para que el pipeline pueda guardar la figura.
"""
data = self.heatmap_24x7()
fig, ax = plt.subplots(figsize=(10, 4))
im = ax.imshow(data.values, aspect="auto", origin="lower")
ax.set_xticks(range(24))
ax.set_xticklabels([str(h) for h in range(24)])
ax.set_yticks(range(7))
ax.set_yticklabels(["L", "M", "X", "J", "V", "S", "D"])
ax.set_xlabel("Hora del día")
ax.set_ylabel("Día de la semana")
ax.set_title("Volumen por día de la semana y hora")
plt.colorbar(im, ax=ax, label="Nº interacciones")
return ax
def plot_channel_distribution(self) -> Axes:
"""
Distribución de volumen por canal.
"""
series = self.volume_by_channel()
fig, ax = plt.subplots(figsize=(6, 4))
series.plot(kind="bar", ax=ax)
ax.set_xlabel("Canal")
ax.set_ylabel("Nº interacciones")
ax.set_title("Volumen por canal")
ax.grid(axis="y", alpha=0.3)
return ax
def plot_skill_pareto(self) -> Axes:
"""
Pareto simple de volumen por skill (solo barras de volumen).
"""
series = self.volume_by_skill()
fig, ax = plt.subplots(figsize=(10, 4))
series.plot(kind="bar", ax=ax)
ax.set_xlabel("Skill / Cola")
ax.set_ylabel("Nº interacciones")
ax.set_title("Pareto de volumen por skill")
ax.grid(axis="y", alpha=0.3)
plt.xticks(rotation=45, ha="right")
return ax

View File

@@ -0,0 +1,13 @@
from .Volumetria import VolumetriaMetrics
from .OperationalPerformance import OperationalPerformanceMetrics
from .SatisfactionExperience import SatisfactionExperienceMetrics
from .EconomyCost import EconomyCostMetrics, EconomyConfig
__all__ = [
# Dimensiones
"VolumetriaMetrics",
"OperationalPerformanceMetrics",
"SatisfactionExperienceMetrics",
"EconomyCostMetrics",
"EconomyConfig",
]

View File

@@ -0,0 +1,22 @@
from .base import DataSource, ResultsSink
from .local import LocalDataSource, LocalResultsSink
from .s3 import S3DataSource, S3ResultsSink
from .google_drive import (
GoogleDriveDataSource,
GoogleDriveConfig,
GoogleDriveResultsSink,
GoogleDriveSinkConfig,
)
__all__ = [
"DataSource",
"ResultsSink",
"LocalDataSource",
"LocalResultsSink",
"S3DataSource",
"S3ResultsSink",
"GoogleDriveDataSource",
"GoogleDriveConfig",
"GoogleDriveResultsSink",
"GoogleDriveSinkConfig",
]

View File

@@ -0,0 +1,36 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any, Dict
import pandas as pd
from matplotlib.figure import Figure
class DataSource(ABC):
"""Interfaz de lectura de datos (CSV)."""
@abstractmethod
def read_csv(self, path: str) -> pd.DataFrame:
"""
Lee un CSV y devuelve un DataFrame.
El significado de 'path' depende de la implementación:
- LocalDataSource: ruta en el sistema de ficheros
- S3DataSource: 's3://bucket/key'
"""
raise NotImplementedError
class ResultsSink(ABC):
"""Interfaz de escritura de resultados (JSON e imágenes)."""
@abstractmethod
def write_json(self, path: str, data: Dict[str, Any]) -> None:
"""Escribe un dict como JSON en 'path'."""
raise NotImplementedError
@abstractmethod
def write_figure(self, path: str, fig: Figure) -> None:
"""Guarda una figura matplotlib en 'path'."""
raise NotImplementedError

View File

@@ -0,0 +1,160 @@
# beyond_metrics/io/google_drive.py
from __future__ import annotations
import io
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, Dict, Any
import pandas as pd
from google.oauth2 import service_account
from googleapiclient.discovery import build
from googleapiclient.http import MediaIoBaseDownload, MediaIoBaseUpload
from .base import DataSource, ResultsSink
GDRIVE_SCOPES = ["https://www.googleapis.com/auth/drive.readonly",
"https://www.googleapis.com/auth/drive.file"]
def _extract_file_id(file_id_or_url: str) -> str:
"""
Acepta:
- un ID directo de Google Drive (ej: '1AbC...')
- una URL de Google Drive compartida
y devuelve siempre el file_id.
"""
if "http://" not in file_id_or_url and "https://" not in file_id_or_url:
return file_id_or_url.strip()
patterns = [
r"/d/([a-zA-Z0-9_-]{10,})", # https://drive.google.com/file/d/<ID>/view
r"id=([a-zA-Z0-9_-]{10,})", # https://drive.google.com/open?id=<ID>
]
for pattern in patterns:
m = re.search(pattern, file_id_or_url)
if m:
return m.group(1)
raise ValueError(f"No se pudo extraer un file_id de la URL de Google Drive: {file_id_or_url}")
# -------- DataSource --------
@dataclass
class GoogleDriveConfig:
credentials_path: str # ruta al JSON de service account
impersonate_user: Optional[str] = None
class GoogleDriveDataSource(DataSource):
"""
DataSource que lee CSVs desde Google Drive.
"""
def __init__(self, config: GoogleDriveConfig) -> None:
self._config = config
self._service = self._build_service(readonly=True)
def _build_service(self, readonly: bool = True):
scopes = ["https://www.googleapis.com/auth/drive.readonly"] if readonly else GDRIVE_SCOPES
creds = service_account.Credentials.from_service_account_file(
self._config.credentials_path,
scopes=scopes,
)
if self._config.impersonate_user:
creds = creds.with_subject(self._config.impersonate_user)
service = build("drive", "v3", credentials=creds)
return service
def read_csv(self, path: str) -> pd.DataFrame:
file_id = _extract_file_id(path)
request = self._service.files().get_media(fileId=file_id)
fh = io.BytesIO()
downloader = MediaIoBaseDownload(fh, request)
done = False
while not done:
_, done = downloader.next_chunk()
fh.seek(0)
df = pd.read_csv(fh)
return df
# -------- ResultsSink --------
@dataclass
class GoogleDriveSinkConfig:
credentials_path: str # ruta al JSON de service account
base_folder_id: str # ID de la carpeta de Drive donde escribir
impersonate_user: Optional[str] = None
class GoogleDriveResultsSink(ResultsSink):
"""
ResultsSink que sube JSONs e imágenes a una carpeta de Google Drive.
Nota: por simplicidad, usamos solo el nombre del fichero (basename de `path`).
Es decir, si le pasas 'data/output/123/results.json', en Drive se guardará
como 'results.json' dentro de base_folder_id.
"""
def __init__(self, config: GoogleDriveSinkConfig) -> None:
self._config = config
self._service = self._build_service()
def _build_service(self):
creds = service_account.Credentials.from_service_account_file(
self._config.credentials_path,
scopes=GDRIVE_SCOPES,
)
if self._config.impersonate_user:
creds = creds.with_subject(self._config.impersonate_user)
service = build("drive", "v3", credentials=creds)
return service
def _upload_bytes(self, data: bytes, mime_type: str, target_path: str) -> str:
"""
Sube un fichero en memoria a Drive y devuelve el file_id.
"""
filename = Path(target_path).name
media = MediaIoBaseUpload(io.BytesIO(data), mimetype=mime_type, resumable=False)
file_metadata = {
"name": filename,
"parents": [self._config.base_folder_id],
}
created = self._service.files().create(
body=file_metadata,
media_body=media,
fields="id",
).execute()
return created["id"]
def write_json(self, path: str, data: Dict[str, Any]) -> None:
payload = json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8")
self._upload_bytes(payload, "application/json", path)
def write_figure(self, path: str, fig) -> None:
from matplotlib.figure import Figure
if not isinstance(fig, Figure):
raise TypeError("write_figure espera un matplotlib.figure.Figure")
buf = io.BytesIO()
fig.savefig(buf, format="png", bbox_inches="tight")
buf.seek(0)
self._upload_bytes(buf.read(), "image/png", path)

View File

@@ -0,0 +1,57 @@
from __future__ import annotations
import json
import os
from typing import Any, Dict
import pandas as pd
from matplotlib.figure import Figure
from .base import DataSource, ResultsSink
class LocalDataSource(DataSource):
"""
DataSource que lee CSV desde el sistema de ficheros local.
- base_dir: se prefiere que todos los paths sean relativos a esta carpeta.
"""
def __init__(self, base_dir: str = ".") -> None:
self.base_dir = base_dir
def _full_path(self, path: str) -> str:
if os.path.isabs(path):
return path
return os.path.join(self.base_dir, path)
def read_csv(self, path: str) -> pd.DataFrame:
full = self._full_path(path)
return pd.read_csv(full)
class LocalResultsSink(ResultsSink):
"""
ResultsSink que escribe JSON e imágenes en el sistema de ficheros local.
"""
def __init__(self, base_dir: str = ".") -> None:
self.base_dir = base_dir
def _full_path(self, path: str) -> str:
if os.path.isabs(path):
full = path
else:
full = os.path.join(self.base_dir, path)
# Crear carpetas si no existen
os.makedirs(os.path.dirname(full), exist_ok=True)
return full
def write_json(self, path: str, data: Dict[str, Any]) -> None:
full = self._full_path(path)
with open(full, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def write_figure(self, path: str, fig: Figure) -> None:
full = self._full_path(path)
fig.savefig(full, bbox_inches="tight")

View File

@@ -0,0 +1,62 @@
from __future__ import annotations
import io
import json
from typing import Any, Dict, Tuple
import boto3
import pandas as pd
from matplotlib.figure import Figure
from .base import DataSource, ResultsSink
def _split_s3_path(path: str) -> Tuple[str, str]:
"""
Convierte 's3://bucket/key' en (bucket, key).
"""
if not path.startswith("s3://"):
raise ValueError(f"Ruta S3 inválida: {path}")
without_scheme = path[len("s3://") :]
parts = without_scheme.split("/", 1)
if len(parts) != 2:
raise ValueError(f"Ruta S3 inválida: {path}")
return parts[0], parts[1]
class S3DataSource(DataSource):
"""
DataSource que lee CSV desde S3 usando boto3.
"""
def __init__(self, boto3_client: Any | None = None) -> None:
self.s3 = boto3_client or boto3.client("s3")
def read_csv(self, path: str) -> pd.DataFrame:
bucket, key = _split_s3_path(path)
obj = self.s3.get_object(Bucket=bucket, Key=key)
body = obj["Body"].read()
buffer = io.BytesIO(body)
return pd.read_csv(buffer)
class S3ResultsSink(ResultsSink):
"""
ResultsSink que escribe JSON e imágenes en S3.
"""
def __init__(self, boto3_client: Any | None = None) -> None:
self.s3 = boto3_client or boto3.client("s3")
def write_json(self, path: str, data: Dict[str, Any]) -> None:
bucket, key = _split_s3_path(path)
body = json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8")
self.s3.put_object(Bucket=bucket, Key=key, Body=body)
def write_figure(self, path: str, fig: Figure) -> None:
bucket, key = _split_s3_path(path)
buf = io.BytesIO()
fig.savefig(buf, format="png", bbox_inches="tight")
buf.seek(0)
self.s3.put_object(Bucket=bucket, Key=key, Body=buf.getvalue(), ContentType="image/png")

View File

@@ -0,0 +1,291 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from importlib import import_module
from typing import Any, Dict, List, Mapping, Optional, cast, Callable
import logging
import os
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from matplotlib.axes import Axes
from matplotlib.figure import Figure
from .io import (
DataSource,
ResultsSink,
)
LOGGER = logging.getLogger(__name__)
def setup_basic_logging(level: str = "INFO") -> None:
"""
Configuración básica de logging, por si se necesita desde scripts.
"""
logging.basicConfig(
level=getattr(logging, level.upper(), logging.INFO),
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
)
def _import_class(path: str) -> type:
"""
Import dinámico de una clase a partir de un string tipo:
"beyond_metrics.dimensions.VolumetriaMetrics"
"""
LOGGER.debug("Importando clase %s", path)
module_name, class_name = path.rsplit(".", 1)
module = import_module(module_name)
cls = getattr(module, class_name)
return cls
def _serialize_for_json(obj: Any) -> Any:
"""
Convierte objetos típicos de numpy/pandas en tipos JSON-friendly.
"""
if obj is None or isinstance(obj, (str, int, float, bool)):
return obj
if isinstance(obj, (np.integer, np.floating)):
return float(obj)
if isinstance(obj, pd.DataFrame):
return obj.to_dict(orient="records")
if isinstance(obj, pd.Series):
return obj.to_list()
if isinstance(obj, (list, tuple)):
return [_serialize_for_json(x) for x in obj]
if isinstance(obj, dict):
return {str(k): _serialize_for_json(v) for k, v in obj.items()}
return str(obj)
PostRunCallback = Callable[[Dict[str, Any], str, ResultsSink], None]
@dataclass
class BeyondMetricsPipeline:
"""
Pipeline principal de BeyondMetrics.
- Lee un CSV desde un DataSource (local, S3, Google Drive, etc.).
- Ejecuta dimensiones configuradas en un dict de configuración.
- Serializa resultados numéricos/tabulares a JSON.
- Guarda las imágenes de los métodos que comienzan por 'plot_'.
"""
datasource: DataSource
sink: ResultsSink
dimensions_config: Mapping[str, Any]
dimension_params: Optional[Mapping[str, Mapping[str, Any]]] = None
post_run: Optional[List[PostRunCallback]] = None
def run(
self,
input_path: str,
run_dir: str,
*,
write_results_json: bool = True,
) -> Dict[str, Any]:
LOGGER.info("Inicio de ejecución de BeyondMetricsPipeline")
LOGGER.info("Leyendo CSV de entrada: %s", input_path)
# 1) Leer datos
df = self.datasource.read_csv(input_path)
LOGGER.info("CSV leído con %d filas y %d columnas", df.shape[0], df.shape[1])
# 2) Determinar carpeta/base de salida para esta ejecución
run_base = run_dir.rstrip("/")
LOGGER.info("Ruta base de esta ejecución: %s", run_base)
# 3) Ejecutar dimensiones
dimensions_cfg = self.dimensions_config
if not isinstance(dimensions_cfg, dict):
raise ValueError("El bloque 'dimensions' debe ser un dict.")
all_results: Dict[str, Any] = {}
for dim_name, dim_cfg in dimensions_cfg.items():
if not isinstance(dim_cfg, dict):
raise ValueError(f"Config inválida para dimensión '{dim_name}' (debe ser dict).")
if not dim_cfg.get("enabled", True):
LOGGER.info("Dimensión '%s' desactivada; se omite.", dim_name)
continue
class_path = dim_cfg.get("class")
if not class_path:
raise ValueError(f"Falta 'class' en la dimensión '{dim_name}'.")
metrics: List[str] = dim_cfg.get("metrics", [])
if not metrics:
LOGGER.info("Dimensión '%s' sin métricas configuradas; se omite.", dim_name)
continue
cls = _import_class(class_path)
extra_kwargs = {}
if self.dimension_params is not None:
extra_kwargs = self.dimension_params.get(dim_name, {}) or {}
# Las dimensiones reciben df en el constructor
instance = cls(df, **extra_kwargs)
dim_results: Dict[str, Any] = {}
for metric_name in metrics:
LOGGER.info(" - Ejecutando métrica '%s.%s'", dim_name, metric_name)
result = self._execute_metric(instance, metric_name, run_base, dim_name)
dim_results[metric_name] = result
all_results[dim_name] = dim_results
# 4) Guardar JSON de resultados (opcional)
if write_results_json:
results_json_path = f"{run_base}/results.json"
LOGGER.info("Guardando resultados en JSON: %s", results_json_path)
self.sink.write_json(results_json_path, all_results)
# 5) Ejecutar callbacks post-run (scorers, agentes, etc.)
if self.post_run:
LOGGER.info("Ejecutando %d callbacks post-run...", len(self.post_run))
for cb in self.post_run:
try:
LOGGER.info("Ejecutando post-run callback: %s", cb)
cb(all_results, run_base, self.sink)
except Exception:
LOGGER.exception("Error ejecutando post-run callback %s", cb)
LOGGER.info("Ejecución completada correctamente.")
return all_results
def _execute_metric(
self,
instance: Any,
metric_name: str,
run_base: str,
dim_name: str,
) -> Any:
"""
Ejecuta una métrica:
- Si empieza por 'plot_' -> se asume que devuelve Axes:
- se guarda la figura como PNG
- se devuelve {"type": "image", "path": "..."}
- Si no, se serializa el valor a JSON.
Además, para métricas categóricas (por skill/canal) de la dimensión
'volumetry', devolvemos explícitamente etiquetas y valores para que
el frontend pueda saber a qué pertenece cada número.
"""
method = getattr(instance, metric_name, None)
if method is None or not callable(method):
raise ValueError(
f"La métrica '{metric_name}' no existe en {type(instance).__name__}"
)
# Caso plots
if metric_name.startswith("plot_"):
ax = method()
if not isinstance(ax, Axes):
raise TypeError(
f"La métrica '{metric_name}' de '{type(instance).__name__}' "
f"debería devolver un matplotlib.axes.Axes"
)
fig = ax.get_figure()
if fig is None:
raise RuntimeError(
"Axes.get_figure() devolvió None, lo cual no debería pasar."
)
fig = cast(Figure, fig)
filename = f"{dim_name}_{metric_name}.png"
img_path = f"{run_base}/{filename}"
LOGGER.debug("Guardando figura en %s", img_path)
self.sink.write_figure(img_path, fig)
plt.close(fig)
return {
"type": "image",
"path": img_path,
}
# Caso numérico/tabular
value = method()
# Caso especial: series categóricas de volumetría (por skill / canal)
# Devolvemos {"labels": [...], "values": [...]} para mantener la
# información de etiquetas en el JSON.
if (
dim_name == "volumetry"
and isinstance(value, pd.Series)
and metric_name
in {
"volume_by_channel",
"volume_by_skill",
"channel_distribution_pct",
"skill_distribution_pct",
}
):
labels = [str(idx) for idx in value.index.tolist()]
# Aseguramos que todos los valores sean numéricos JSON-friendly
values = [float(v) for v in value.astype(float).tolist()]
return {
"labels": labels,
"values": values,
}
return _serialize_for_json(value)
def load_dimensions_config(path: str) -> Dict[str, Any]:
"""
Carga un JSON de configuración que contiene solo el bloque 'dimensions'.
"""
import json
from pathlib import Path
with Path(path).open("r", encoding="utf-8") as f:
cfg = json.load(f)
dimensions = cfg.get("dimensions")
if dimensions is None:
raise ValueError("El fichero de configuración debe contener un bloque 'dimensions'.")
return dimensions
def build_pipeline(
dimensions_config_path: str,
datasource: DataSource,
sink: ResultsSink,
dimension_params: Optional[Mapping[str, Mapping[str, Any]]] = None,
post_run: Optional[List[PostRunCallback]] = None,
) -> BeyondMetricsPipeline:
"""
Crea un BeyondMetricsPipeline a partir de:
- ruta al JSON con dimensiones/métricas
- un DataSource ya construido (local/S3/Drive)
- un ResultsSink ya construido (local/S3/Drive)
- una lista opcional de callbacks post_run que se ejecutan al final
(útil para scorers, agentes de IA, etc.)
"""
dims_cfg = load_dimensions_config(dimensions_config_path)
return BeyondMetricsPipeline(
datasource=datasource,
sink=sink,
dimensions_config=dims_cfg,
dimension_params=dimension_params,
post_run=post_run,
)

View File

@@ -0,0 +1,301 @@
interaction_id,datetime_start,queue_skill,channel,duration_talk,hold_time,wrap_up_time,agent_id,transfer_flag,caller_id
id_1,2024-01-07 11:28:00,ventas,voz,708,17,22,A3,False,+34693410762
id_2,2024-01-24 12:35:00,soporte,voz,1028,37,26,A2,True,+34613953367
id_3,2024-01-12 13:01:00,ventas,email,507,20,43,A5,False,+34642860080
id_4,2024-01-27 18:41:00,ventas,email,217,24,37,A4,False,+34632049003
id_5,2024-01-23 11:56:00,ventas,voz,624,19,30,A1,False,+34695672411
id_6,2024-01-15 19:06:00,soporte,voz,789,52,45,A5,False,+34668979792
id_7,2024-01-04 09:39:00,ventas,email,510,60,44,A2,True,+34652631083
id_8,2024-01-02 17:35:00,posventa,voz,321,44,42,A4,False,+34636433622
id_9,2024-01-28 16:39:00,retenciones,voz,694,43,75,A2,False,+34657055419
id_10,2024-01-28 16:28:00,posventa,chat,814,41,53,A3,False,+34691355424
id_11,2024-01-27 12:41:00,soporte,voz,432,23,44,A3,False,+34604545583
id_12,2024-01-23 14:23:00,ventas,voz,795,49,65,A4,False,+34614881728
id_13,2024-01-03 12:46:00,ventas,chat,794,45,62,A3,False,+34697636744
id_14,2024-01-15 17:41:00,retenciones,voz,667,2,35,A4,False,+34628433648
id_15,2024-01-20 11:29:00,ventas,chat,659,9,18,A1,False,+34621970758
id_16,2024-01-27 10:21:00,retenciones,email,638,35,53,A5,False,+34650441204
id_17,2024-01-06 19:27:00,retenciones,email,517,2,34,A2,False,+34653892568
id_18,2024-01-07 08:00:00,ventas,voz,750,22,18,A2,False,+34672931048
id_19,2024-01-03 14:05:00,retenciones,voz,68,36,34,A2,False,+34623234510
id_20,2024-01-27 13:31:00,ventas,chat,542,67,42,A3,False,+34613022704
id_21,2024-01-26 11:11:00,posventa,email,596,0,23,A3,True,+34667922032
id_22,2024-01-02 09:27:00,soporte,chat,401,26,44,A3,False,+34688917675
id_23,2024-01-26 13:31:00,soporte,email,447,33,20,A3,False,+34606659908
id_24,2024-01-20 09:05:00,ventas,email,466,54,34,A2,False,+34635562619
id_25,2024-01-27 13:15:00,soporte,voz,619,19,16,A4,False,+34677809459
id_26,2024-01-09 08:39:00,posventa,chat,613,42,37,A5,False,+34670721464
id_27,2024-01-25 09:22:00,soporte,email,974,37,30,A3,False,+34671450611
id_28,2024-01-28 08:01:00,ventas,voz,495,50,42,A1,False,+34623448674
id_29,2024-01-19 10:19:00,retenciones,chat,650,36,29,A3,False,+34654710375
id_30,2024-01-20 10:47:00,ventas,voz,646,35,29,A5,True,+34619576703
id_31,2024-01-14 09:01:00,retenciones,voz,809,56,51,A4,False,+34655403796
id_32,2024-01-16 11:55:00,posventa,email,409,49,46,A4,False,+34624947744
id_33,2024-01-16 19:58:00,soporte,chat,326,14,53,A2,False,+34658245345
id_34,2024-01-21 08:27:00,soporte,email,552,47,45,A5,False,+34640820227
id_35,2024-01-03 08:16:00,retenciones,email,597,33,49,A5,False,+34666880548
id_36,2024-01-24 08:45:00,posventa,email,530,20,51,A4,False,+34631725526
id_37,2024-01-01 10:17:00,retenciones,voz,742,31,49,A2,False,+34691299103
id_38,2024-01-15 13:22:00,posventa,voz,952,31,22,A1,False,+34694569232
id_39,2024-01-08 08:08:00,ventas,voz,408,57,53,A2,False,+34676294551
id_40,2024-01-13 19:57:00,posventa,email,479,18,38,A1,False,+34602503771
id_41,2024-01-02 09:31:00,posventa,chat,502,31,74,A5,False,+34671531065
id_42,2024-01-22 08:51:00,posventa,email,226,0,46,A3,False,+34692526139
id_43,2024-01-12 19:04:00,ventas,email,842,26,34,A1,False,+34657351079
id_44,2024-01-20 09:59:00,retenciones,email,341,3,10,A3,False,+34603695961
id_45,2024-01-05 17:52:00,ventas,chat,572,14,34,A4,False,+34620840174
id_46,2024-01-02 13:47:00,soporte,chat,631,35,73,A1,False,+34677183765
id_47,2024-01-29 14:02:00,retenciones,email,453,4,36,A2,False,+34698208175
id_48,2024-01-21 14:35:00,ventas,email,974,24,21,A3,False,+34694919177
id_49,2024-01-22 12:35:00,soporte,voz,574,20,40,A3,False,+34610495300
id_50,2024-01-13 11:15:00,soporte,email,1069,33,30,A5,True,+34699967871
id_51,2024-01-27 11:23:00,ventas,email,749,66,43,A3,False,+34604035851
id_52,2024-01-03 08:32:00,posventa,voz,545,7,48,A1,False,+34630185054
id_53,2024-01-14 09:50:00,retenciones,chat,291,52,22,A2,False,+34608454561
id_54,2024-01-19 16:06:00,ventas,voz,914,37,29,A5,False,+34610617244
id_55,2024-01-24 11:24:00,ventas,voz,474,47,48,A4,False,+34693437616
id_56,2024-01-03 11:17:00,ventas,email,457,39,17,A2,False,+34662976036
id_57,2024-01-12 08:54:00,soporte,email,765,46,59,A4,False,+34688146618
id_58,2024-01-19 08:35:00,soporte,chat,604,24,30,A5,False,+34656294783
id_59,2024-01-20 10:57:00,posventa,voz,436,71,24,A5,False,+34610198499
id_60,2024-01-21 14:15:00,soporte,email,357,16,39,A3,False,+34617310852
id_61,2024-01-03 14:28:00,posventa,email,434,23,46,A3,False,+34631665404
id_62,2024-01-06 14:45:00,posventa,email,487,21,43,A4,False,+34680497273
id_63,2024-01-02 12:28:00,ventas,voz,310,1,29,A2,False,+34675524262
id_64,2024-01-27 17:25:00,ventas,chat,557,15,11,A1,False,+34638538877
id_65,2024-01-26 11:27:00,soporte,email,729,40,52,A2,False,+34638503931
id_66,2024-01-17 16:55:00,ventas,voz,826,0,57,A1,False,+34639301804
id_67,2024-01-13 12:15:00,soporte,voz,644,30,41,A1,False,+34699204497
id_68,2024-01-15 16:48:00,retenciones,voz,445,21,67,A5,False,+34693691765
id_69,2024-01-03 14:56:00,soporte,chat,571,17,35,A4,False,+34693837987
id_70,2024-01-26 15:57:00,soporte,email,452,14,26,A2,False,+34642734307
id_71,2024-01-27 13:16:00,posventa,voz,470,0,17,A4,False,+34658842658
id_72,2024-01-29 13:17:00,posventa,voz,735,63,14,A1,False,+34686966281
id_73,2024-01-24 09:58:00,ventas,email,835,20,14,A4,False,+34628267184
id_74,2024-01-13 08:33:00,retenciones,chat,870,36,68,A4,False,+34629702914
id_75,2024-01-21 14:36:00,posventa,email,784,46,19,A1,False,+34606195200
id_76,2024-01-19 11:00:00,ventas,voz,472,51,63,A4,False,+34685072967
id_77,2024-01-07 17:57:00,retenciones,voz,910,32,57,A2,False,+34607102173
id_78,2024-01-27 16:16:00,soporte,voz,613,42,60,A5,False,+34648840101
id_79,2024-01-28 09:37:00,posventa,voz,922,55,26,A4,False,+34669562616
id_80,2024-01-06 08:23:00,soporte,chat,623,24,48,A4,False,+34643558992
id_81,2024-01-05 11:09:00,soporte,voz,597,45,35,A4,False,+34615734209
id_82,2024-01-13 18:42:00,ventas,voz,267,27,38,A5,False,+34613081782
id_83,2024-01-16 18:53:00,ventas,voz,418,41,28,A2,False,+34665438362
id_84,2024-01-29 09:57:00,soporte,chat,934,42,32,A4,False,+34616618153
id_85,2024-01-29 13:20:00,posventa,voz,936,77,51,A4,False,+34675589337
id_86,2024-01-13 18:08:00,ventas,voz,542,29,26,A4,False,+34650066487
id_87,2024-01-30 18:47:00,ventas,chat,577,25,49,A3,False,+34693842126
id_88,2024-01-15 15:21:00,posventa,chat,751,45,28,A4,False,+34607585153
id_89,2024-01-13 17:13:00,posventa,voz,773,39,34,A2,False,+34647756479
id_90,2024-01-25 14:36:00,retenciones,voz,780,35,20,A1,False,+34664413145
id_91,2024-01-30 19:24:00,ventas,email,643,48,55,A3,False,+34693198563
id_92,2024-01-15 13:33:00,ventas,voz,730,71,60,A5,False,+34616203305
id_93,2024-01-09 12:05:00,posventa,email,645,46,39,A5,False,+34679357216
id_94,2024-01-21 13:20:00,soporte,voz,272,20,30,A5,False,+34616514877
id_95,2024-01-30 15:54:00,ventas,voz,759,32,66,A1,False,+34616263882
id_96,2024-01-08 17:50:00,ventas,email,585,21,47,A2,False,+34682405138
id_97,2024-01-20 08:19:00,posventa,voz,508,23,17,A5,False,+34653215075
id_98,2024-01-13 18:02:00,soporte,email,759,27,33,A5,False,+34603757974
id_99,2024-01-10 19:01:00,ventas,email,801,18,52,A1,False,+34608112074
id_100,2024-01-15 15:37:00,posventa,email,374,0,24,A5,False,+34677822269
id_101,2024-01-28 19:49:00,retenciones,chat,674,17,41,A2,False,+34601135964
id_102,2024-01-08 18:40:00,retenciones,voz,568,45,49,A3,False,+34608910852
id_103,2024-01-19 18:13:00,posventa,voz,158,37,35,A3,False,+34607021862
id_104,2024-01-08 10:37:00,posventa,chat,789,0,62,A1,False,+34649875088
id_105,2024-01-01 14:01:00,ventas,chat,778,31,26,A5,False,+34611630982
id_106,2024-01-19 15:41:00,ventas,voz,657,36,33,A5,False,+34609496563
id_107,2024-01-30 14:26:00,soporte,email,502,38,23,A3,False,+34609803005
id_108,2024-01-04 13:01:00,soporte,chat,436,39,45,A1,False,+34637612325
id_109,2024-01-13 11:44:00,soporte,chat,546,10,33,A3,False,+34615043403
id_110,2024-01-13 09:26:00,soporte,chat,675,57,55,A5,False,+34607726890
id_111,2024-01-27 12:19:00,retenciones,email,244,59,49,A5,False,+34660628583
id_112,2024-01-30 09:24:00,ventas,email,588,47,25,A5,False,+34636346859
id_113,2024-01-04 11:04:00,retenciones,email,643,38,43,A1,False,+34676796917
id_114,2024-01-19 15:40:00,soporte,chat,372,28,51,A1,False,+34669111188
id_115,2024-01-26 11:53:00,ventas,email,505,49,24,A5,False,+34618492846
id_116,2024-01-09 12:08:00,soporte,chat,454,42,30,A3,False,+34688121122
id_117,2024-01-20 16:25:00,soporte,email,554,42,59,A3,False,+34625146282
id_118,2024-01-27 19:58:00,soporte,email,436,25,35,A3,False,+34654440645
id_119,2024-01-21 15:32:00,posventa,chat,816,0,24,A1,False,+34635966581
id_120,2024-01-22 19:39:00,ventas,email,703,81,40,A4,True,+34688213938
id_121,2024-01-27 15:18:00,retenciones,voz,510,0,18,A1,False,+34613781009
id_122,2024-01-01 10:46:00,retenciones,voz,893,78,69,A3,False,+34665954738
id_123,2024-01-04 08:07:00,soporte,email,318,14,23,A4,True,+34699831174
id_124,2024-01-29 15:00:00,posventa,email,950,0,45,A1,False,+34623656190
id_125,2024-01-29 15:53:00,ventas,voz,762,0,42,A2,False,+34685808215
id_126,2024-01-14 15:45:00,ventas,chat,795,22,57,A5,False,+34675094484
id_127,2024-01-14 14:47:00,soporte,chat,646,15,50,A2,False,+34606202258
id_128,2024-01-04 19:17:00,ventas,chat,693,5,27,A3,False,+34612790902
id_129,2024-01-12 11:04:00,ventas,chat,837,16,54,A2,False,+34624899065
id_130,2024-01-22 19:23:00,soporte,email,527,33,25,A1,False,+34609944790
id_131,2024-01-17 09:50:00,retenciones,email,940,31,28,A4,False,+34686131989
id_132,2024-01-11 11:25:00,soporte,voz,924,4,41,A2,False,+34678987338
id_133,2024-01-06 09:20:00,soporte,chat,598,57,37,A1,False,+34606238795
id_134,2024-01-23 12:41:00,soporte,email,464,21,30,A3,False,+34657701082
id_135,2024-01-08 09:11:00,posventa,email,580,75,57,A2,False,+34689813693
id_136,2024-01-26 09:59:00,retenciones,voz,651,32,36,A1,True,+34631970599
id_137,2024-01-11 11:48:00,posventa,voz,749,60,38,A4,False,+34642955157
id_138,2024-01-05 15:32:00,soporte,voz,711,35,14,A4,False,+34686654442
id_139,2024-01-17 18:44:00,retenciones,chat,674,9,8,A2,False,+34628104320
id_140,2024-01-25 18:39:00,soporte,voz,529,0,33,A2,False,+34678021860
id_141,2024-01-12 19:50:00,posventa,chat,724,0,29,A3,False,+34650760636
id_142,2024-01-17 17:56:00,ventas,chat,550,3,36,A3,False,+34636045138
id_143,2024-01-08 12:16:00,posventa,email,857,51,33,A5,False,+34610563214
id_144,2024-01-27 17:40:00,retenciones,voz,726,38,43,A1,False,+34623387092
id_145,2024-01-22 17:06:00,retenciones,voz,689,25,56,A5,False,+34628348817
id_146,2024-01-27 17:38:00,retenciones,voz,583,31,33,A1,False,+34652879148
id_147,2024-01-02 10:36:00,posventa,chat,94,55,61,A3,False,+34630715395
id_148,2024-01-29 13:24:00,posventa,email,219,32,65,A5,False,+34607152747
id_149,2024-01-30 09:54:00,ventas,chat,651,29,49,A5,False,+34640739629
id_150,2024-01-20 08:28:00,soporte,chat,565,29,31,A1,False,+34693144811
id_151,2024-01-15 16:09:00,posventa,voz,546,16,66,A5,True,+34646695565
id_152,2024-01-24 09:03:00,soporte,chat,633,43,64,A4,False,+34617562548
id_153,2024-01-14 16:14:00,ventas,email,910,10,54,A3,False,+34684445004
id_154,2024-01-02 16:06:00,retenciones,email,557,33,47,A1,False,+34654496748
id_155,2024-01-14 17:42:00,retenciones,email,496,18,48,A3,False,+34620521013
id_156,2024-01-15 14:48:00,posventa,chat,475,80,47,A4,False,+34643951994
id_157,2024-01-10 10:49:00,retenciones,email,633,29,38,A2,False,+34624586222
id_158,2024-01-25 16:03:00,soporte,chat,789,13,42,A4,False,+34667001666
id_159,2024-01-27 19:28:00,soporte,email,657,36,49,A5,False,+34609462743
id_160,2024-01-28 13:07:00,retenciones,chat,1002,25,41,A5,False,+34606155579
id_161,2024-01-16 19:04:00,ventas,chat,608,50,42,A3,False,+34653811239
id_162,2024-01-27 08:05:00,soporte,chat,772,52,40,A5,False,+34604684346
id_163,2024-01-27 16:12:00,retenciones,chat,700,47,55,A4,False,+34610115078
id_164,2024-01-05 18:44:00,posventa,email,906,26,42,A2,False,+34610528294
id_165,2024-01-18 11:01:00,posventa,email,605,55,42,A2,False,+34642106078
id_166,2024-01-02 19:23:00,ventas,chat,609,25,42,A5,False,+34679146438
id_167,2024-01-10 16:31:00,retenciones,voz,529,52,48,A2,False,+34675176851
id_168,2024-01-04 09:03:00,retenciones,voz,459,51,24,A2,True,+34684483977
id_169,2024-01-22 08:21:00,soporte,voz,503,32,45,A1,True,+34695019914
id_170,2024-01-01 13:46:00,soporte,chat,494,61,39,A5,False,+34636089369
id_171,2024-01-02 09:28:00,ventas,chat,617,53,31,A4,False,+34698023086
id_172,2024-01-14 11:21:00,soporte,email,775,38,43,A4,False,+34697042181
id_173,2024-01-19 16:04:00,posventa,chat,590,34,36,A2,False,+34601074961
id_174,2024-01-15 19:15:00,soporte,email,670,5,42,A1,False,+34689858638
id_175,2024-01-27 09:51:00,ventas,chat,702,24,64,A2,False,+34655940773
id_176,2024-01-16 16:59:00,soporte,email,900,32,29,A5,False,+34670047063
id_177,2024-01-08 18:12:00,posventa,voz,576,6,25,A5,False,+34613476005
id_178,2024-01-11 16:32:00,soporte,voz,923,48,52,A4,False,+34638836811
id_179,2024-01-25 13:21:00,soporte,chat,478,7,40,A5,False,+34685936029
id_180,2024-01-01 11:25:00,retenciones,chat,443,9,35,A5,False,+34608439469
id_181,2024-01-26 09:14:00,posventa,email,501,21,44,A4,False,+34601443717
id_182,2024-01-09 13:08:00,soporte,email,440,31,30,A4,False,+34642307399
id_183,2024-01-18 19:19:00,posventa,chat,809,19,59,A2,False,+34679790594
id_184,2024-01-09 19:41:00,retenciones,email,639,63,33,A4,False,+34614150540
id_185,2024-01-25 10:57:00,soporte,chat,529,0,48,A4,False,+34653307679
id_186,2024-01-19 19:17:00,ventas,email,675,40,10,A3,False,+34681718171
id_187,2024-01-10 10:34:00,soporte,chat,517,1,47,A3,False,+34699989204
id_188,2024-01-26 15:19:00,retenciones,email,516,57,51,A1,False,+34620808635
id_189,2024-01-24 09:48:00,soporte,voz,1118,38,38,A2,False,+34617066318
id_190,2024-01-02 11:05:00,posventa,email,525,12,19,A2,False,+34628175911
id_191,2024-01-21 08:34:00,soporte,voz,504,57,64,A4,False,+34654181889
id_192,2024-01-23 12:04:00,posventa,chat,855,27,28,A5,False,+34633523310
id_193,2024-01-14 15:38:00,posventa,chat,829,0,34,A1,False,+34634932801
id_194,2024-01-03 12:04:00,soporte,chat,376,52,29,A4,False,+34604600108
id_195,2024-01-23 18:09:00,ventas,email,180,35,36,A4,False,+34647602635
id_196,2024-01-01 16:53:00,posventa,voz,846,46,58,A3,False,+34601805808
id_197,2024-01-24 19:55:00,retenciones,chat,806,34,36,A4,False,+34653175588
id_198,2024-01-20 17:28:00,soporte,chat,560,5,49,A2,False,+34615702852
id_199,2024-01-01 08:50:00,retenciones,chat,783,36,54,A4,False,+34645587883
id_200,2024-01-06 11:22:00,ventas,chat,30,11,49,A3,False,+34604990961
id_201,2024-01-21 08:54:00,posventa,email,475,68,37,A2,False,+34642439798
id_202,2024-01-26 18:09:00,posventa,voz,643,33,42,A5,False,+34683149786
id_203,2024-01-08 16:45:00,ventas,chat,636,45,52,A5,False,+34613045697
id_204,2024-01-18 13:08:00,ventas,voz,963,23,43,A4,False,+34665969098
id_205,2024-01-04 09:37:00,soporte,chat,837,5,48,A2,False,+34622910282
id_206,2024-01-01 15:53:00,ventas,chat,740,3,57,A4,False,+34669070841
id_207,2024-01-07 12:18:00,retenciones,voz,401,0,30,A4,False,+34645938649
id_208,2024-01-11 10:07:00,retenciones,chat,335,51,63,A4,False,+34620554754
id_209,2024-01-21 17:07:00,retenciones,chat,100,75,19,A3,False,+34610104107
id_210,2024-01-04 18:47:00,ventas,chat,270,62,44,A1,False,+34691199914
id_211,2024-01-13 12:22:00,ventas,chat,783,26,22,A2,False,+34644810380
id_212,2024-01-22 10:33:00,ventas,email,906,37,23,A1,False,+34659468269
id_213,2024-01-27 19:34:00,ventas,voz,434,33,29,A2,False,+34645844569
id_214,2024-01-25 18:06:00,ventas,email,911,10,48,A2,False,+34692540486
id_215,2024-01-24 08:41:00,soporte,voz,435,26,33,A4,False,+34679690286
id_216,2024-01-27 14:21:00,ventas,email,830,30,40,A2,False,+34692796686
id_217,2024-01-10 15:57:00,posventa,email,747,24,44,A3,False,+34689380419
id_218,2024-01-18 15:18:00,retenciones,voz,901,38,53,A5,False,+34671537554
id_219,2024-01-04 17:32:00,ventas,voz,371,24,47,A3,False,+34617644180
id_220,2024-01-17 13:47:00,ventas,email,527,8,28,A1,False,+34655666186
id_221,2024-01-10 16:13:00,retenciones,email,490,33,33,A4,False,+34684143761
id_222,2024-01-03 17:53:00,posventa,email,461,64,33,A2,True,+34614578363
id_223,2024-01-14 10:03:00,ventas,email,713,62,34,A2,False,+34682424160
id_224,2024-01-04 10:08:00,ventas,email,559,7,29,A3,False,+34629737667
id_225,2024-01-13 08:59:00,ventas,email,646,0,44,A1,False,+34650825596
id_226,2024-01-26 10:21:00,retenciones,email,766,43,69,A4,False,+34690493043
id_227,2024-01-08 13:43:00,retenciones,voz,862,43,35,A5,False,+34639970870
id_228,2024-01-16 19:16:00,retenciones,voz,362,0,32,A4,False,+34623255666
id_229,2024-01-08 13:04:00,posventa,voz,773,45,51,A4,False,+34630267797
id_230,2024-01-18 15:38:00,retenciones,email,368,35,55,A4,False,+34613116915
id_231,2024-01-02 11:32:00,soporte,chat,463,6,16,A2,False,+34641765312
id_232,2024-01-11 10:51:00,retenciones,email,361,33,38,A5,False,+34623683333
id_233,2024-01-03 13:42:00,posventa,chat,937,67,40,A4,False,+34636432549
id_234,2024-01-18 10:29:00,retenciones,chat,106,41,48,A2,False,+34679110216
id_235,2024-01-15 12:34:00,ventas,chat,707,12,40,A4,False,+34642866200
id_236,2024-01-02 16:53:00,soporte,chat,598,48,16,A5,False,+34699907919
id_237,2024-01-22 11:02:00,posventa,chat,440,37,54,A1,False,+34675013292
id_238,2024-01-28 19:31:00,posventa,chat,30,34,25,A2,False,+34604746771
id_239,2024-01-18 19:05:00,posventa,voz,268,55,57,A3,False,+34668082045
id_240,2024-01-11 09:30:00,retenciones,chat,196,18,33,A3,False,+34694597309
id_241,2024-01-14 10:06:00,retenciones,email,522,33,42,A1,False,+34692210534
id_242,2024-01-28 11:28:00,soporte,voz,600,39,74,A2,False,+34624525886
id_243,2024-01-05 11:34:00,posventa,chat,365,0,48,A5,False,+34647378941
id_244,2024-01-23 09:26:00,soporte,email,751,57,34,A4,False,+34652010809
id_245,2024-01-24 14:04:00,posventa,chat,401,29,10,A1,False,+34618608310
id_246,2024-01-21 17:03:00,ventas,chat,1012,22,48,A2,False,+34603815144
id_247,2024-01-29 11:28:00,posventa,email,894,25,29,A2,False,+34600442939
id_248,2024-01-16 08:09:00,retenciones,email,807,28,42,A5,False,+34654254875
id_249,2024-01-11 14:33:00,retenciones,chat,410,0,45,A5,False,+34632038060
id_250,2024-01-19 12:31:00,retenciones,chat,548,29,43,A5,True,+34629084871
id_251,2024-01-25 14:42:00,retenciones,chat,818,41,5,A4,False,+34698090211
id_252,2024-01-11 11:14:00,retenciones,chat,637,8,13,A3,False,+34677457397
id_253,2024-01-08 17:37:00,soporte,voz,605,13,42,A2,False,+34631099208
id_254,2024-01-02 09:02:00,retenciones,voz,649,35,26,A5,False,+34681193128
id_255,2024-01-25 17:54:00,soporte,voz,471,48,40,A2,False,+34689198479
id_256,2024-01-28 09:10:00,posventa,chat,653,13,43,A5,False,+34680925517
id_257,2024-01-28 17:24:00,retenciones,voz,497,14,43,A2,False,+34654610032
id_258,2024-01-24 12:34:00,retenciones,voz,702,5,57,A3,False,+34636213515
id_259,2024-01-09 09:20:00,soporte,chat,550,62,47,A1,False,+34697101535
id_260,2024-01-11 13:21:00,soporte,chat,746,37,30,A1,False,+34684370894
id_261,2024-01-19 14:23:00,ventas,email,405,0,52,A3,False,+34652315765
id_262,2024-01-19 14:28:00,soporte,email,770,33,27,A4,False,+34616413806
id_263,2024-01-08 17:57:00,ventas,voz,558,12,31,A5,False,+34661509503
id_264,2024-01-14 14:26:00,retenciones,chat,717,19,23,A4,False,+34698683379
id_265,2024-01-04 13:41:00,posventa,chat,443,42,38,A2,False,+34606739013
id_266,2024-01-24 10:36:00,ventas,chat,683,24,25,A4,False,+34648085527
id_267,2024-01-22 10:25:00,ventas,voz,316,0,17,A4,False,+34652496899
id_268,2024-01-29 10:23:00,posventa,voz,852,25,35,A5,False,+34692573559
id_269,2024-01-30 15:33:00,ventas,voz,921,61,25,A2,False,+34615663645
id_270,2024-01-26 13:52:00,retenciones,voz,677,31,62,A2,False,+34696432867
id_271,2024-01-30 14:48:00,ventas,email,431,27,47,A4,False,+34663848248
id_272,2024-01-28 13:44:00,soporte,email,326,39,23,A4,False,+34694499886
id_273,2024-01-27 13:46:00,posventa,chat,525,51,68,A3,False,+34679394364
id_274,2024-01-10 09:02:00,posventa,chat,908,19,51,A5,False,+34675057004
id_275,2024-01-19 12:18:00,ventas,email,506,0,47,A1,False,+34661069572
id_276,2024-01-04 13:25:00,soporte,email,493,34,34,A2,False,+34646206264
id_277,2024-01-04 13:40:00,retenciones,email,670,8,45,A1,False,+34682096675
id_278,2024-01-21 15:43:00,soporte,voz,485,10,25,A2,False,+34626133385
id_279,2024-01-16 13:30:00,ventas,voz,898,24,39,A1,True,+34600658003
id_280,2024-01-18 17:42:00,posventa,chat,450,32,23,A5,False,+34615433222
id_281,2024-01-06 09:31:00,posventa,chat,649,31,50,A1,True,+34653873131
id_282,2024-01-24 16:36:00,soporte,chat,619,7,48,A3,False,+34613981528
id_283,2024-01-21 19:56:00,posventa,voz,478,62,43,A2,False,+34650538135
id_284,2024-01-29 09:27:00,retenciones,email,481,24,42,A5,False,+34652777488
id_285,2024-01-02 13:45:00,soporte,chat,385,0,46,A1,False,+34623689071
id_286,2024-01-19 14:21:00,soporte,email,780,48,29,A2,False,+34652499002
id_287,2024-01-10 10:50:00,retenciones,voz,474,42,29,A1,False,+34628997485
id_288,2024-01-20 13:14:00,ventas,voz,497,36,36,A3,False,+34623593741
id_289,2024-01-27 16:39:00,retenciones,chat,776,28,37,A1,False,+34602276787
id_290,2024-01-23 16:58:00,posventa,email,1238,56,31,A3,False,+34669863927
id_291,2024-01-12 17:07:00,posventa,chat,783,16,68,A5,False,+34690067502
id_292,2024-01-15 15:17:00,posventa,chat,816,39,27,A1,False,+34618303750
id_293,2024-01-16 10:44:00,ventas,chat,546,39,31,A3,False,+34633833647
id_294,2024-01-11 12:03:00,retenciones,voz,496,12,57,A1,False,+34671335020
id_295,2024-01-23 12:20:00,soporte,email,415,53,27,A5,False,+34602592536
id_296,2024-01-20 09:25:00,ventas,email,672,33,34,A3,False,+34661963740
id_297,2024-01-09 11:37:00,ventas,voz,961,37,35,A4,False,+34693480811
id_298,2024-01-09 12:23:00,posventa,chat,208,55,39,A2,False,+34675211737
id_299,2024-01-16 12:27:00,posventa,email,486,21,30,A4,False,+34663349631
id_300,2024-01-22 08:04:00,ventas,chat,194,38,45,A3,False,+34605432019
1 interaction_id datetime_start queue_skill channel duration_talk hold_time wrap_up_time agent_id transfer_flag caller_id
2 id_1 2024-01-07 11:28:00 ventas voz 708 17 22 A3 False +34693410762
3 id_2 2024-01-24 12:35:00 soporte voz 1028 37 26 A2 True +34613953367
4 id_3 2024-01-12 13:01:00 ventas email 507 20 43 A5 False +34642860080
5 id_4 2024-01-27 18:41:00 ventas email 217 24 37 A4 False +34632049003
6 id_5 2024-01-23 11:56:00 ventas voz 624 19 30 A1 False +34695672411
7 id_6 2024-01-15 19:06:00 soporte voz 789 52 45 A5 False +34668979792
8 id_7 2024-01-04 09:39:00 ventas email 510 60 44 A2 True +34652631083
9 id_8 2024-01-02 17:35:00 posventa voz 321 44 42 A4 False +34636433622
10 id_9 2024-01-28 16:39:00 retenciones voz 694 43 75 A2 False +34657055419
11 id_10 2024-01-28 16:28:00 posventa chat 814 41 53 A3 False +34691355424
12 id_11 2024-01-27 12:41:00 soporte voz 432 23 44 A3 False +34604545583
13 id_12 2024-01-23 14:23:00 ventas voz 795 49 65 A4 False +34614881728
14 id_13 2024-01-03 12:46:00 ventas chat 794 45 62 A3 False +34697636744
15 id_14 2024-01-15 17:41:00 retenciones voz 667 2 35 A4 False +34628433648
16 id_15 2024-01-20 11:29:00 ventas chat 659 9 18 A1 False +34621970758
17 id_16 2024-01-27 10:21:00 retenciones email 638 35 53 A5 False +34650441204
18 id_17 2024-01-06 19:27:00 retenciones email 517 2 34 A2 False +34653892568
19 id_18 2024-01-07 08:00:00 ventas voz 750 22 18 A2 False +34672931048
20 id_19 2024-01-03 14:05:00 retenciones voz 68 36 34 A2 False +34623234510
21 id_20 2024-01-27 13:31:00 ventas chat 542 67 42 A3 False +34613022704
22 id_21 2024-01-26 11:11:00 posventa email 596 0 23 A3 True +34667922032
23 id_22 2024-01-02 09:27:00 soporte chat 401 26 44 A3 False +34688917675
24 id_23 2024-01-26 13:31:00 soporte email 447 33 20 A3 False +34606659908
25 id_24 2024-01-20 09:05:00 ventas email 466 54 34 A2 False +34635562619
26 id_25 2024-01-27 13:15:00 soporte voz 619 19 16 A4 False +34677809459
27 id_26 2024-01-09 08:39:00 posventa chat 613 42 37 A5 False +34670721464
28 id_27 2024-01-25 09:22:00 soporte email 974 37 30 A3 False +34671450611
29 id_28 2024-01-28 08:01:00 ventas voz 495 50 42 A1 False +34623448674
30 id_29 2024-01-19 10:19:00 retenciones chat 650 36 29 A3 False +34654710375
31 id_30 2024-01-20 10:47:00 ventas voz 646 35 29 A5 True +34619576703
32 id_31 2024-01-14 09:01:00 retenciones voz 809 56 51 A4 False +34655403796
33 id_32 2024-01-16 11:55:00 posventa email 409 49 46 A4 False +34624947744
34 id_33 2024-01-16 19:58:00 soporte chat 326 14 53 A2 False +34658245345
35 id_34 2024-01-21 08:27:00 soporte email 552 47 45 A5 False +34640820227
36 id_35 2024-01-03 08:16:00 retenciones email 597 33 49 A5 False +34666880548
37 id_36 2024-01-24 08:45:00 posventa email 530 20 51 A4 False +34631725526
38 id_37 2024-01-01 10:17:00 retenciones voz 742 31 49 A2 False +34691299103
39 id_38 2024-01-15 13:22:00 posventa voz 952 31 22 A1 False +34694569232
40 id_39 2024-01-08 08:08:00 ventas voz 408 57 53 A2 False +34676294551
41 id_40 2024-01-13 19:57:00 posventa email 479 18 38 A1 False +34602503771
42 id_41 2024-01-02 09:31:00 posventa chat 502 31 74 A5 False +34671531065
43 id_42 2024-01-22 08:51:00 posventa email 226 0 46 A3 False +34692526139
44 id_43 2024-01-12 19:04:00 ventas email 842 26 34 A1 False +34657351079
45 id_44 2024-01-20 09:59:00 retenciones email 341 3 10 A3 False +34603695961
46 id_45 2024-01-05 17:52:00 ventas chat 572 14 34 A4 False +34620840174
47 id_46 2024-01-02 13:47:00 soporte chat 631 35 73 A1 False +34677183765
48 id_47 2024-01-29 14:02:00 retenciones email 453 4 36 A2 False +34698208175
49 id_48 2024-01-21 14:35:00 ventas email 974 24 21 A3 False +34694919177
50 id_49 2024-01-22 12:35:00 soporte voz 574 20 40 A3 False +34610495300
51 id_50 2024-01-13 11:15:00 soporte email 1069 33 30 A5 True +34699967871
52 id_51 2024-01-27 11:23:00 ventas email 749 66 43 A3 False +34604035851
53 id_52 2024-01-03 08:32:00 posventa voz 545 7 48 A1 False +34630185054
54 id_53 2024-01-14 09:50:00 retenciones chat 291 52 22 A2 False +34608454561
55 id_54 2024-01-19 16:06:00 ventas voz 914 37 29 A5 False +34610617244
56 id_55 2024-01-24 11:24:00 ventas voz 474 47 48 A4 False +34693437616
57 id_56 2024-01-03 11:17:00 ventas email 457 39 17 A2 False +34662976036
58 id_57 2024-01-12 08:54:00 soporte email 765 46 59 A4 False +34688146618
59 id_58 2024-01-19 08:35:00 soporte chat 604 24 30 A5 False +34656294783
60 id_59 2024-01-20 10:57:00 posventa voz 436 71 24 A5 False +34610198499
61 id_60 2024-01-21 14:15:00 soporte email 357 16 39 A3 False +34617310852
62 id_61 2024-01-03 14:28:00 posventa email 434 23 46 A3 False +34631665404
63 id_62 2024-01-06 14:45:00 posventa email 487 21 43 A4 False +34680497273
64 id_63 2024-01-02 12:28:00 ventas voz 310 1 29 A2 False +34675524262
65 id_64 2024-01-27 17:25:00 ventas chat 557 15 11 A1 False +34638538877
66 id_65 2024-01-26 11:27:00 soporte email 729 40 52 A2 False +34638503931
67 id_66 2024-01-17 16:55:00 ventas voz 826 0 57 A1 False +34639301804
68 id_67 2024-01-13 12:15:00 soporte voz 644 30 41 A1 False +34699204497
69 id_68 2024-01-15 16:48:00 retenciones voz 445 21 67 A5 False +34693691765
70 id_69 2024-01-03 14:56:00 soporte chat 571 17 35 A4 False +34693837987
71 id_70 2024-01-26 15:57:00 soporte email 452 14 26 A2 False +34642734307
72 id_71 2024-01-27 13:16:00 posventa voz 470 0 17 A4 False +34658842658
73 id_72 2024-01-29 13:17:00 posventa voz 735 63 14 A1 False +34686966281
74 id_73 2024-01-24 09:58:00 ventas email 835 20 14 A4 False +34628267184
75 id_74 2024-01-13 08:33:00 retenciones chat 870 36 68 A4 False +34629702914
76 id_75 2024-01-21 14:36:00 posventa email 784 46 19 A1 False +34606195200
77 id_76 2024-01-19 11:00:00 ventas voz 472 51 63 A4 False +34685072967
78 id_77 2024-01-07 17:57:00 retenciones voz 910 32 57 A2 False +34607102173
79 id_78 2024-01-27 16:16:00 soporte voz 613 42 60 A5 False +34648840101
80 id_79 2024-01-28 09:37:00 posventa voz 922 55 26 A4 False +34669562616
81 id_80 2024-01-06 08:23:00 soporte chat 623 24 48 A4 False +34643558992
82 id_81 2024-01-05 11:09:00 soporte voz 597 45 35 A4 False +34615734209
83 id_82 2024-01-13 18:42:00 ventas voz 267 27 38 A5 False +34613081782
84 id_83 2024-01-16 18:53:00 ventas voz 418 41 28 A2 False +34665438362
85 id_84 2024-01-29 09:57:00 soporte chat 934 42 32 A4 False +34616618153
86 id_85 2024-01-29 13:20:00 posventa voz 936 77 51 A4 False +34675589337
87 id_86 2024-01-13 18:08:00 ventas voz 542 29 26 A4 False +34650066487
88 id_87 2024-01-30 18:47:00 ventas chat 577 25 49 A3 False +34693842126
89 id_88 2024-01-15 15:21:00 posventa chat 751 45 28 A4 False +34607585153
90 id_89 2024-01-13 17:13:00 posventa voz 773 39 34 A2 False +34647756479
91 id_90 2024-01-25 14:36:00 retenciones voz 780 35 20 A1 False +34664413145
92 id_91 2024-01-30 19:24:00 ventas email 643 48 55 A3 False +34693198563
93 id_92 2024-01-15 13:33:00 ventas voz 730 71 60 A5 False +34616203305
94 id_93 2024-01-09 12:05:00 posventa email 645 46 39 A5 False +34679357216
95 id_94 2024-01-21 13:20:00 soporte voz 272 20 30 A5 False +34616514877
96 id_95 2024-01-30 15:54:00 ventas voz 759 32 66 A1 False +34616263882
97 id_96 2024-01-08 17:50:00 ventas email 585 21 47 A2 False +34682405138
98 id_97 2024-01-20 08:19:00 posventa voz 508 23 17 A5 False +34653215075
99 id_98 2024-01-13 18:02:00 soporte email 759 27 33 A5 False +34603757974
100 id_99 2024-01-10 19:01:00 ventas email 801 18 52 A1 False +34608112074
101 id_100 2024-01-15 15:37:00 posventa email 374 0 24 A5 False +34677822269
102 id_101 2024-01-28 19:49:00 retenciones chat 674 17 41 A2 False +34601135964
103 id_102 2024-01-08 18:40:00 retenciones voz 568 45 49 A3 False +34608910852
104 id_103 2024-01-19 18:13:00 posventa voz 158 37 35 A3 False +34607021862
105 id_104 2024-01-08 10:37:00 posventa chat 789 0 62 A1 False +34649875088
106 id_105 2024-01-01 14:01:00 ventas chat 778 31 26 A5 False +34611630982
107 id_106 2024-01-19 15:41:00 ventas voz 657 36 33 A5 False +34609496563
108 id_107 2024-01-30 14:26:00 soporte email 502 38 23 A3 False +34609803005
109 id_108 2024-01-04 13:01:00 soporte chat 436 39 45 A1 False +34637612325
110 id_109 2024-01-13 11:44:00 soporte chat 546 10 33 A3 False +34615043403
111 id_110 2024-01-13 09:26:00 soporte chat 675 57 55 A5 False +34607726890
112 id_111 2024-01-27 12:19:00 retenciones email 244 59 49 A5 False +34660628583
113 id_112 2024-01-30 09:24:00 ventas email 588 47 25 A5 False +34636346859
114 id_113 2024-01-04 11:04:00 retenciones email 643 38 43 A1 False +34676796917
115 id_114 2024-01-19 15:40:00 soporte chat 372 28 51 A1 False +34669111188
116 id_115 2024-01-26 11:53:00 ventas email 505 49 24 A5 False +34618492846
117 id_116 2024-01-09 12:08:00 soporte chat 454 42 30 A3 False +34688121122
118 id_117 2024-01-20 16:25:00 soporte email 554 42 59 A3 False +34625146282
119 id_118 2024-01-27 19:58:00 soporte email 436 25 35 A3 False +34654440645
120 id_119 2024-01-21 15:32:00 posventa chat 816 0 24 A1 False +34635966581
121 id_120 2024-01-22 19:39:00 ventas email 703 81 40 A4 True +34688213938
122 id_121 2024-01-27 15:18:00 retenciones voz 510 0 18 A1 False +34613781009
123 id_122 2024-01-01 10:46:00 retenciones voz 893 78 69 A3 False +34665954738
124 id_123 2024-01-04 08:07:00 soporte email 318 14 23 A4 True +34699831174
125 id_124 2024-01-29 15:00:00 posventa email 950 0 45 A1 False +34623656190
126 id_125 2024-01-29 15:53:00 ventas voz 762 0 42 A2 False +34685808215
127 id_126 2024-01-14 15:45:00 ventas chat 795 22 57 A5 False +34675094484
128 id_127 2024-01-14 14:47:00 soporte chat 646 15 50 A2 False +34606202258
129 id_128 2024-01-04 19:17:00 ventas chat 693 5 27 A3 False +34612790902
130 id_129 2024-01-12 11:04:00 ventas chat 837 16 54 A2 False +34624899065
131 id_130 2024-01-22 19:23:00 soporte email 527 33 25 A1 False +34609944790
132 id_131 2024-01-17 09:50:00 retenciones email 940 31 28 A4 False +34686131989
133 id_132 2024-01-11 11:25:00 soporte voz 924 4 41 A2 False +34678987338
134 id_133 2024-01-06 09:20:00 soporte chat 598 57 37 A1 False +34606238795
135 id_134 2024-01-23 12:41:00 soporte email 464 21 30 A3 False +34657701082
136 id_135 2024-01-08 09:11:00 posventa email 580 75 57 A2 False +34689813693
137 id_136 2024-01-26 09:59:00 retenciones voz 651 32 36 A1 True +34631970599
138 id_137 2024-01-11 11:48:00 posventa voz 749 60 38 A4 False +34642955157
139 id_138 2024-01-05 15:32:00 soporte voz 711 35 14 A4 False +34686654442
140 id_139 2024-01-17 18:44:00 retenciones chat 674 9 8 A2 False +34628104320
141 id_140 2024-01-25 18:39:00 soporte voz 529 0 33 A2 False +34678021860
142 id_141 2024-01-12 19:50:00 posventa chat 724 0 29 A3 False +34650760636
143 id_142 2024-01-17 17:56:00 ventas chat 550 3 36 A3 False +34636045138
144 id_143 2024-01-08 12:16:00 posventa email 857 51 33 A5 False +34610563214
145 id_144 2024-01-27 17:40:00 retenciones voz 726 38 43 A1 False +34623387092
146 id_145 2024-01-22 17:06:00 retenciones voz 689 25 56 A5 False +34628348817
147 id_146 2024-01-27 17:38:00 retenciones voz 583 31 33 A1 False +34652879148
148 id_147 2024-01-02 10:36:00 posventa chat 94 55 61 A3 False +34630715395
149 id_148 2024-01-29 13:24:00 posventa email 219 32 65 A5 False +34607152747
150 id_149 2024-01-30 09:54:00 ventas chat 651 29 49 A5 False +34640739629
151 id_150 2024-01-20 08:28:00 soporte chat 565 29 31 A1 False +34693144811
152 id_151 2024-01-15 16:09:00 posventa voz 546 16 66 A5 True +34646695565
153 id_152 2024-01-24 09:03:00 soporte chat 633 43 64 A4 False +34617562548
154 id_153 2024-01-14 16:14:00 ventas email 910 10 54 A3 False +34684445004
155 id_154 2024-01-02 16:06:00 retenciones email 557 33 47 A1 False +34654496748
156 id_155 2024-01-14 17:42:00 retenciones email 496 18 48 A3 False +34620521013
157 id_156 2024-01-15 14:48:00 posventa chat 475 80 47 A4 False +34643951994
158 id_157 2024-01-10 10:49:00 retenciones email 633 29 38 A2 False +34624586222
159 id_158 2024-01-25 16:03:00 soporte chat 789 13 42 A4 False +34667001666
160 id_159 2024-01-27 19:28:00 soporte email 657 36 49 A5 False +34609462743
161 id_160 2024-01-28 13:07:00 retenciones chat 1002 25 41 A5 False +34606155579
162 id_161 2024-01-16 19:04:00 ventas chat 608 50 42 A3 False +34653811239
163 id_162 2024-01-27 08:05:00 soporte chat 772 52 40 A5 False +34604684346
164 id_163 2024-01-27 16:12:00 retenciones chat 700 47 55 A4 False +34610115078
165 id_164 2024-01-05 18:44:00 posventa email 906 26 42 A2 False +34610528294
166 id_165 2024-01-18 11:01:00 posventa email 605 55 42 A2 False +34642106078
167 id_166 2024-01-02 19:23:00 ventas chat 609 25 42 A5 False +34679146438
168 id_167 2024-01-10 16:31:00 retenciones voz 529 52 48 A2 False +34675176851
169 id_168 2024-01-04 09:03:00 retenciones voz 459 51 24 A2 True +34684483977
170 id_169 2024-01-22 08:21:00 soporte voz 503 32 45 A1 True +34695019914
171 id_170 2024-01-01 13:46:00 soporte chat 494 61 39 A5 False +34636089369
172 id_171 2024-01-02 09:28:00 ventas chat 617 53 31 A4 False +34698023086
173 id_172 2024-01-14 11:21:00 soporte email 775 38 43 A4 False +34697042181
174 id_173 2024-01-19 16:04:00 posventa chat 590 34 36 A2 False +34601074961
175 id_174 2024-01-15 19:15:00 soporte email 670 5 42 A1 False +34689858638
176 id_175 2024-01-27 09:51:00 ventas chat 702 24 64 A2 False +34655940773
177 id_176 2024-01-16 16:59:00 soporte email 900 32 29 A5 False +34670047063
178 id_177 2024-01-08 18:12:00 posventa voz 576 6 25 A5 False +34613476005
179 id_178 2024-01-11 16:32:00 soporte voz 923 48 52 A4 False +34638836811
180 id_179 2024-01-25 13:21:00 soporte chat 478 7 40 A5 False +34685936029
181 id_180 2024-01-01 11:25:00 retenciones chat 443 9 35 A5 False +34608439469
182 id_181 2024-01-26 09:14:00 posventa email 501 21 44 A4 False +34601443717
183 id_182 2024-01-09 13:08:00 soporte email 440 31 30 A4 False +34642307399
184 id_183 2024-01-18 19:19:00 posventa chat 809 19 59 A2 False +34679790594
185 id_184 2024-01-09 19:41:00 retenciones email 639 63 33 A4 False +34614150540
186 id_185 2024-01-25 10:57:00 soporte chat 529 0 48 A4 False +34653307679
187 id_186 2024-01-19 19:17:00 ventas email 675 40 10 A3 False +34681718171
188 id_187 2024-01-10 10:34:00 soporte chat 517 1 47 A3 False +34699989204
189 id_188 2024-01-26 15:19:00 retenciones email 516 57 51 A1 False +34620808635
190 id_189 2024-01-24 09:48:00 soporte voz 1118 38 38 A2 False +34617066318
191 id_190 2024-01-02 11:05:00 posventa email 525 12 19 A2 False +34628175911
192 id_191 2024-01-21 08:34:00 soporte voz 504 57 64 A4 False +34654181889
193 id_192 2024-01-23 12:04:00 posventa chat 855 27 28 A5 False +34633523310
194 id_193 2024-01-14 15:38:00 posventa chat 829 0 34 A1 False +34634932801
195 id_194 2024-01-03 12:04:00 soporte chat 376 52 29 A4 False +34604600108
196 id_195 2024-01-23 18:09:00 ventas email 180 35 36 A4 False +34647602635
197 id_196 2024-01-01 16:53:00 posventa voz 846 46 58 A3 False +34601805808
198 id_197 2024-01-24 19:55:00 retenciones chat 806 34 36 A4 False +34653175588
199 id_198 2024-01-20 17:28:00 soporte chat 560 5 49 A2 False +34615702852
200 id_199 2024-01-01 08:50:00 retenciones chat 783 36 54 A4 False +34645587883
201 id_200 2024-01-06 11:22:00 ventas chat 30 11 49 A3 False +34604990961
202 id_201 2024-01-21 08:54:00 posventa email 475 68 37 A2 False +34642439798
203 id_202 2024-01-26 18:09:00 posventa voz 643 33 42 A5 False +34683149786
204 id_203 2024-01-08 16:45:00 ventas chat 636 45 52 A5 False +34613045697
205 id_204 2024-01-18 13:08:00 ventas voz 963 23 43 A4 False +34665969098
206 id_205 2024-01-04 09:37:00 soporte chat 837 5 48 A2 False +34622910282
207 id_206 2024-01-01 15:53:00 ventas chat 740 3 57 A4 False +34669070841
208 id_207 2024-01-07 12:18:00 retenciones voz 401 0 30 A4 False +34645938649
209 id_208 2024-01-11 10:07:00 retenciones chat 335 51 63 A4 False +34620554754
210 id_209 2024-01-21 17:07:00 retenciones chat 100 75 19 A3 False +34610104107
211 id_210 2024-01-04 18:47:00 ventas chat 270 62 44 A1 False +34691199914
212 id_211 2024-01-13 12:22:00 ventas chat 783 26 22 A2 False +34644810380
213 id_212 2024-01-22 10:33:00 ventas email 906 37 23 A1 False +34659468269
214 id_213 2024-01-27 19:34:00 ventas voz 434 33 29 A2 False +34645844569
215 id_214 2024-01-25 18:06:00 ventas email 911 10 48 A2 False +34692540486
216 id_215 2024-01-24 08:41:00 soporte voz 435 26 33 A4 False +34679690286
217 id_216 2024-01-27 14:21:00 ventas email 830 30 40 A2 False +34692796686
218 id_217 2024-01-10 15:57:00 posventa email 747 24 44 A3 False +34689380419
219 id_218 2024-01-18 15:18:00 retenciones voz 901 38 53 A5 False +34671537554
220 id_219 2024-01-04 17:32:00 ventas voz 371 24 47 A3 False +34617644180
221 id_220 2024-01-17 13:47:00 ventas email 527 8 28 A1 False +34655666186
222 id_221 2024-01-10 16:13:00 retenciones email 490 33 33 A4 False +34684143761
223 id_222 2024-01-03 17:53:00 posventa email 461 64 33 A2 True +34614578363
224 id_223 2024-01-14 10:03:00 ventas email 713 62 34 A2 False +34682424160
225 id_224 2024-01-04 10:08:00 ventas email 559 7 29 A3 False +34629737667
226 id_225 2024-01-13 08:59:00 ventas email 646 0 44 A1 False +34650825596
227 id_226 2024-01-26 10:21:00 retenciones email 766 43 69 A4 False +34690493043
228 id_227 2024-01-08 13:43:00 retenciones voz 862 43 35 A5 False +34639970870
229 id_228 2024-01-16 19:16:00 retenciones voz 362 0 32 A4 False +34623255666
230 id_229 2024-01-08 13:04:00 posventa voz 773 45 51 A4 False +34630267797
231 id_230 2024-01-18 15:38:00 retenciones email 368 35 55 A4 False +34613116915
232 id_231 2024-01-02 11:32:00 soporte chat 463 6 16 A2 False +34641765312
233 id_232 2024-01-11 10:51:00 retenciones email 361 33 38 A5 False +34623683333
234 id_233 2024-01-03 13:42:00 posventa chat 937 67 40 A4 False +34636432549
235 id_234 2024-01-18 10:29:00 retenciones chat 106 41 48 A2 False +34679110216
236 id_235 2024-01-15 12:34:00 ventas chat 707 12 40 A4 False +34642866200
237 id_236 2024-01-02 16:53:00 soporte chat 598 48 16 A5 False +34699907919
238 id_237 2024-01-22 11:02:00 posventa chat 440 37 54 A1 False +34675013292
239 id_238 2024-01-28 19:31:00 posventa chat 30 34 25 A2 False +34604746771
240 id_239 2024-01-18 19:05:00 posventa voz 268 55 57 A3 False +34668082045
241 id_240 2024-01-11 09:30:00 retenciones chat 196 18 33 A3 False +34694597309
242 id_241 2024-01-14 10:06:00 retenciones email 522 33 42 A1 False +34692210534
243 id_242 2024-01-28 11:28:00 soporte voz 600 39 74 A2 False +34624525886
244 id_243 2024-01-05 11:34:00 posventa chat 365 0 48 A5 False +34647378941
245 id_244 2024-01-23 09:26:00 soporte email 751 57 34 A4 False +34652010809
246 id_245 2024-01-24 14:04:00 posventa chat 401 29 10 A1 False +34618608310
247 id_246 2024-01-21 17:03:00 ventas chat 1012 22 48 A2 False +34603815144
248 id_247 2024-01-29 11:28:00 posventa email 894 25 29 A2 False +34600442939
249 id_248 2024-01-16 08:09:00 retenciones email 807 28 42 A5 False +34654254875
250 id_249 2024-01-11 14:33:00 retenciones chat 410 0 45 A5 False +34632038060
251 id_250 2024-01-19 12:31:00 retenciones chat 548 29 43 A5 True +34629084871
252 id_251 2024-01-25 14:42:00 retenciones chat 818 41 5 A4 False +34698090211
253 id_252 2024-01-11 11:14:00 retenciones chat 637 8 13 A3 False +34677457397
254 id_253 2024-01-08 17:37:00 soporte voz 605 13 42 A2 False +34631099208
255 id_254 2024-01-02 09:02:00 retenciones voz 649 35 26 A5 False +34681193128
256 id_255 2024-01-25 17:54:00 soporte voz 471 48 40 A2 False +34689198479
257 id_256 2024-01-28 09:10:00 posventa chat 653 13 43 A5 False +34680925517
258 id_257 2024-01-28 17:24:00 retenciones voz 497 14 43 A2 False +34654610032
259 id_258 2024-01-24 12:34:00 retenciones voz 702 5 57 A3 False +34636213515
260 id_259 2024-01-09 09:20:00 soporte chat 550 62 47 A1 False +34697101535
261 id_260 2024-01-11 13:21:00 soporte chat 746 37 30 A1 False +34684370894
262 id_261 2024-01-19 14:23:00 ventas email 405 0 52 A3 False +34652315765
263 id_262 2024-01-19 14:28:00 soporte email 770 33 27 A4 False +34616413806
264 id_263 2024-01-08 17:57:00 ventas voz 558 12 31 A5 False +34661509503
265 id_264 2024-01-14 14:26:00 retenciones chat 717 19 23 A4 False +34698683379
266 id_265 2024-01-04 13:41:00 posventa chat 443 42 38 A2 False +34606739013
267 id_266 2024-01-24 10:36:00 ventas chat 683 24 25 A4 False +34648085527
268 id_267 2024-01-22 10:25:00 ventas voz 316 0 17 A4 False +34652496899
269 id_268 2024-01-29 10:23:00 posventa voz 852 25 35 A5 False +34692573559
270 id_269 2024-01-30 15:33:00 ventas voz 921 61 25 A2 False +34615663645
271 id_270 2024-01-26 13:52:00 retenciones voz 677 31 62 A2 False +34696432867
272 id_271 2024-01-30 14:48:00 ventas email 431 27 47 A4 False +34663848248
273 id_272 2024-01-28 13:44:00 soporte email 326 39 23 A4 False +34694499886
274 id_273 2024-01-27 13:46:00 posventa chat 525 51 68 A3 False +34679394364
275 id_274 2024-01-10 09:02:00 posventa chat 908 19 51 A5 False +34675057004
276 id_275 2024-01-19 12:18:00 ventas email 506 0 47 A1 False +34661069572
277 id_276 2024-01-04 13:25:00 soporte email 493 34 34 A2 False +34646206264
278 id_277 2024-01-04 13:40:00 retenciones email 670 8 45 A1 False +34682096675
279 id_278 2024-01-21 15:43:00 soporte voz 485 10 25 A2 False +34626133385
280 id_279 2024-01-16 13:30:00 ventas voz 898 24 39 A1 True +34600658003
281 id_280 2024-01-18 17:42:00 posventa chat 450 32 23 A5 False +34615433222
282 id_281 2024-01-06 09:31:00 posventa chat 649 31 50 A1 True +34653873131
283 id_282 2024-01-24 16:36:00 soporte chat 619 7 48 A3 False +34613981528
284 id_283 2024-01-21 19:56:00 posventa voz 478 62 43 A2 False +34650538135
285 id_284 2024-01-29 09:27:00 retenciones email 481 24 42 A5 False +34652777488
286 id_285 2024-01-02 13:45:00 soporte chat 385 0 46 A1 False +34623689071
287 id_286 2024-01-19 14:21:00 soporte email 780 48 29 A2 False +34652499002
288 id_287 2024-01-10 10:50:00 retenciones voz 474 42 29 A1 False +34628997485
289 id_288 2024-01-20 13:14:00 ventas voz 497 36 36 A3 False +34623593741
290 id_289 2024-01-27 16:39:00 retenciones chat 776 28 37 A1 False +34602276787
291 id_290 2024-01-23 16:58:00 posventa email 1238 56 31 A3 False +34669863927
292 id_291 2024-01-12 17:07:00 posventa chat 783 16 68 A5 False +34690067502
293 id_292 2024-01-15 15:17:00 posventa chat 816 39 27 A1 False +34618303750
294 id_293 2024-01-16 10:44:00 ventas chat 546 39 31 A3 False +34633833647
295 id_294 2024-01-11 12:03:00 retenciones voz 496 12 57 A1 False +34671335020
296 id_295 2024-01-23 12:20:00 soporte email 415 53 27 A5 False +34602592536
297 id_296 2024-01-20 09:25:00 ventas email 672 33 34 A3 False +34661963740
298 id_297 2024-01-09 11:37:00 ventas voz 961 37 35 A4 False +34693480811
299 id_298 2024-01-09 12:23:00 posventa chat 208 55 39 A2 False +34675211737
300 id_299 2024-01-16 12:27:00 posventa email 486 21 30 A4 False +34663349631
301 id_300 2024-01-22 08:04:00 ventas chat 194 38 45 A3 False +34605432019

View File

@@ -0,0 +1,46 @@
version: "3.9"
services:
api:
build:
context: .
dockerfile: Dockerfile
# Si algún día subes la imagen a un registry, podrías usar:
# image: ghcr.io/TU_USUARIO/beyondcx-heatmap-api:latest
container_name: beyondcx-api
restart: unless-stopped
ports:
- "${API_PORT:-8000}:8000"
environment:
BASIC_AUTH_USERNAME: "${BASIC_AUTH_USERNAME:-admin}"
BASIC_AUTH_PASSWORD: "${BASIC_AUTH_PASSWORD:-admin}"
volumes:
- "${DATA_DIR:-./data}:/app/data"
networks:
- beyondcx-net
nginx:
image: nginx:stable
container_name: beyondcx-nginx
restart: unless-stopped
depends_on:
- api
ports:
- "80:80"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
networks:
- beyondcx-net
networks:
beyondcx-net:
driver: bridge

25
backend/docs/notas git.md Normal file
View File

@@ -0,0 +1,25 @@
git status # ver qué ha cambiado
git add . # añadir cambios
git commit -m "Describe lo que has hecho"
git push # subir al remoto
# Ejecutar tests
source .venv/bin/activate
python -m pytest -v
# Instalar el paquete
python pip install -e .
# Ejecutar el API
uvicorn beyond_api.main:app --reload
# Ejemplo Curl API
curl -X POST "http://127.0.0.1:8000/analysis" \
-u admin:admin \
-F "analysis=basic" \
-F "csv_file=@data/example/synthetic_interactions.csv" \
-F "economy_json={\"labor_cost_per_hour\":30,\"automation_volume_share\":0.7,\"customer_segments\":{\"VIP\":\"high\",\"Basico\":\"medium\"}}"
# Lo siguiente:
# Disponer de varios json y pasarlos en la peticiòn
# Meter etiquetas en la respuesta por skill

View File

@@ -0,0 +1,12 @@
server {
listen 80;
server_name _; # en local nos da igual el dominio
location / {
proxy_pass http://api:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

1
backend/output.json Normal file

File diff suppressed because one or more lines are too long

31
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,31 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "beyond-metrics"
version = "0.1.0"
description = "Librería de métricas de volumetría para contact centers"
authors = [{ name = "Nacho" }]
requires-python = ">=3.9"
dependencies = [
"pandas",
"numpy",
"matplotlib",
"openai",
"reportlab",
"google-api-python-client>=2.153.0",
"google-auth>=2.35.0",
"google-auth-oauthlib>=1.2.1",
# --- API REST ---
"fastapi",
"uvicorn[standard]",
"python-multipart", # necesario para subir ficheros
"boto3",
]
[tool.setuptools.packages.find]
where = ["."]
include = ["beyond_metrics", "beyond_flows", "beyond_api"]

168
backend/tests/test_api.sh Executable file
View File

@@ -0,0 +1,168 @@
#!/usr/bin/env bash
set -euo pipefail
# ===========================
# Configuración
# ===========================
HOST="${HOST:-localhost}"
PORT="${PORT:-8000}"
API_URL="http://$HOST:$PORT/analysis"
# Credenciales Basic Auth (ajusta si usas otras)
API_USER="${API_USER:-beyond}"
API_PASS="${API_PASS:-beyond2026}"
# Ruta del CSV en tu máquina para subirlo
LOCAL_CSV_FILE="${LOCAL_CSV_FILE:-data/example/synthetic_interactions.csv}"
# Carpetas de salida
OUT_DIR="${OUT_DIR:-./test_results}"
mkdir -p "$OUT_DIR"
print_header() {
echo
echo "============================================================"
echo "$1"
echo "============================================================"
}
# ===========================
# 1. Health-check simple (sin auth)
# ===========================
print_header "1) Comprobando que el servidor responde (sin auth) - debería devolver 401"
set +e
curl -s -o /dev/null -w "HTTP status: %{http_code}\n" \
-X POST "$API_URL"
set -e
# ===========================
# 2. Test: subir CSV (analysis=premium por defecto)
# ===========================
print_header "2) Subiendo CSV local con análisis 'premium' (default) y guardando JSON"
if [ ! -f "$LOCAL_CSV_FILE" ]; then
echo "⚠️ Aviso: el fichero LOCAL_CSV_FILE='$LOCAL_CSV_FILE' no existe."
echo " Cambia la variable LOCAL_CSV_FILE o copia el CSV a esa ruta."
else
curl -v \
-u "$API_USER:$API_PASS" \
-X POST "$API_URL" \
-F "csv_file=@${LOCAL_CSV_FILE}" \
-o "${OUT_DIR}/resultados_premium.json"
echo "✅ JSON guardado en: ${OUT_DIR}/resultados_premium.json"
echo " Primeras líneas:"
head -n 20 "${OUT_DIR}/resultados_premium.json" || true
fi
# ===========================
# 3. Test: subir CSV con analysis=basic
# ===========================
print_header "3) Subiendo CSV local con análisis 'basic' y guardando JSON"
if [ ! -f "$LOCAL_CSV_FILE" ]; then
echo "⚠️ Saltando este test porque LOCAL_CSV_FILE='$LOCAL_CSV_FILE' no existe."
else
curl -v \
-u "$API_USER:$API_PASS" \
-X POST "$API_URL" \
-F "csv_file=@${LOCAL_CSV_FILE}" \
-F "analysis=basic" \
-o "${OUT_DIR}/resultados_basic.json"
echo "✅ JSON guardado en: ${OUT_DIR}/resultados_basic.json"
echo " Primeras líneas:"
head -n 20 "${OUT_DIR}/resultados_basic.json" || true
fi
# ===========================
# 4. Test: con economy_json personalizado (premium)
# ===========================
print_header "4) Subiendo CSV con configuración económica personalizada (analysis=premium)"
if [ ! -f "$LOCAL_CSV_FILE" ]; then
echo "⚠️ Saltando este test porque LOCAL_CSV_FILE='$LOCAL_CSV_FILE' no existe."
else
curl -v \
-u "$API_USER:$API_PASS" \
-X POST "$API_URL" \
-F "csv_file=@${LOCAL_CSV_FILE}" \
-F 'economy_json={"labor_cost_per_hour":30,"automation_volume_share":0.7,"customer_segments":{"VIP":"high","Basico":"medium"}}' \
-F "analysis=premium" \
-o "${OUT_DIR}/resultados_economy_premium.json"
echo "✅ JSON con economía personalizada guardado en: ${OUT_DIR}/resultados_economy_premium.json"
echo " Primeras líneas:"
head -n 20 "${OUT_DIR}/resultados_economy_premium.json" || true
fi
# ===========================
# 5. Test de error: economy_json inválido
# ===========================
print_header "5) Petición con economy_json inválido - debe devolver 400"
set +e
curl -v \
-u "$API_USER:$API_PASS" \
-X POST "$API_URL" \
-F "csv_file=@${LOCAL_CSV_FILE}" \
-F "economy_json={invalid json" \
-o "${OUT_DIR}/error_economy_invalid.json"
STATUS=$?
set -e
echo "✅ Respuesta guardada en: ${OUT_DIR}/error_economy_invalid.json"
cat "${OUT_DIR}/error_economy_invalid.json" || true
# ===========================
# 6. Test de error: analysis inválido
# ===========================
print_header "6) Petición con analysis inválido - debe devolver 400"
set +e
curl -v \
-u "$API_USER:$API_PASS" \
-X POST "$API_URL" \
-F "csv_file=@${LOCAL_CSV_FILE}" \
-F "analysis=ultra" \
-o "${OUT_DIR}/error_analysis_invalid.json"
set -e
echo "✅ Respuesta guardada en: ${OUT_DIR}/error_analysis_invalid.json"
cat "${OUT_DIR}/error_analysis_invalid.json" || true
# ===========================
# 7. Test de error: sin csv_file (debe devolver 422)
# ===========================
print_header "7) Petición inválida (sin csv_file) - debe devolver 422 (FastAPI validation)"
set +e
curl -v \
-u "$API_USER:$API_PASS" \
-X POST "$API_URL" \
-o "${OUT_DIR}/error_missing_csv.json"
set -e
echo "✅ Respuesta guardada en: ${OUT_DIR}/error_missing_csv.json"
cat "${OUT_DIR}/error_missing_csv.json" || true
# ===========================
# 8. Test de error: credenciales incorrectas
# ===========================
print_header "8) Petición con credenciales incorrectas - debe devolver 401"
set +e
curl -v \
-u "wrong:wrong" \
-X POST "$API_URL" \
-F "csv_file=@${LOCAL_CSV_FILE}" \
-o "${OUT_DIR}/error_auth.json"
set -e
echo "✅ Respuesta de error de auth guardada en: ${OUT_DIR}/error_auth.json"
cat "${OUT_DIR}/error_auth.json" || true
echo
echo "✨ Tests terminados. Revisa la carpeta: ${OUT_DIR}"

View File

@@ -0,0 +1,128 @@
import math
from datetime import datetime
import matplotlib
import pandas as pd
from beyond_metrics.dimensions.EconomyCost import EconomyCostMetrics, EconomyConfig
matplotlib.use("Agg")
def _sample_df() -> pd.DataFrame:
data = [
{
"interaction_id": "id1",
"datetime_start": datetime(2024, 1, 1, 10, 0),
"queue_skill": "ventas",
"channel": "voz",
"duration_talk": 600,
"hold_time": 60,
"wrap_up_time": 30,
},
{
"interaction_id": "id2",
"datetime_start": datetime(2024, 1, 1, 10, 5),
"queue_skill": "ventas",
"channel": "voz",
"duration_talk": 300,
"hold_time": 30,
"wrap_up_time": 20,
},
{
"interaction_id": "id3",
"datetime_start": datetime(2024, 1, 1, 11, 0),
"queue_skill": "soporte",
"channel": "chat",
"duration_talk": 400,
"hold_time": 20,
"wrap_up_time": 30,
},
]
return pd.DataFrame(data)
def test_init_and_required_columns():
df = _sample_df()
cfg = EconomyConfig(labor_cost_per_hour=20.0, overhead_rate=0.1, tech_costs_annual=10000.0)
em = EconomyCostMetrics(df, cfg)
assert not em.is_empty
# Falta de columna obligatoria -> ValueError
df_missing = df.drop(columns=["duration_talk"])
import pytest
with pytest.raises(ValueError):
EconomyCostMetrics(df_missing, cfg)
def test_metrics_without_config_do_not_crash():
df = _sample_df()
em = EconomyCostMetrics(df, None)
assert em.cpi_by_skill_channel().empty
assert em.annual_cost_by_skill_channel().empty
assert em.cost_breakdown() == {}
assert em.inefficiency_cost_by_skill_channel().empty
assert em.potential_savings() == {}
def test_basic_cpi_and_annual_cost():
df = _sample_df()
cfg = EconomyConfig(labor_cost_per_hour=20.0, overhead_rate=0.1)
em = EconomyCostMetrics(df, cfg)
cpi = em.cpi_by_skill_channel()
assert not cpi.empty
# Debe haber filas para ventas/voz y soporte/chat
assert ("ventas", "voz") in cpi.index
assert ("soporte", "chat") in cpi.index
annual = em.annual_cost_by_skill_channel()
assert "annual_cost" in annual.columns
# costes positivos
assert (annual["annual_cost"] > 0).any()
def test_cost_breakdown_and_potential_savings():
df = _sample_df()
cfg = EconomyConfig(
labor_cost_per_hour=20.0,
overhead_rate=0.1,
tech_costs_annual=5000.0,
automation_cpi=0.2,
automation_volume_share=0.5,
automation_success_rate=0.8,
)
em = EconomyCostMetrics(df, cfg)
breakdown = em.cost_breakdown()
assert "labor_pct" in breakdown
assert "overhead_pct" in breakdown
assert "tech_pct" in breakdown
total_pct = (
breakdown["labor_pct"]
+ breakdown["overhead_pct"]
+ breakdown["tech_pct"]
)
# Permitimos pequeño error por redondeo a 2 decimales
assert abs(total_pct - 100.0) < 0.2
savings = em.potential_savings()
assert "annual_savings" in savings
assert savings["annual_savings"] >= 0.0
def test_plot_methods_return_axes():
from matplotlib.axes import Axes
df = _sample_df()
cfg = EconomyConfig(labor_cost_per_hour=20.0, overhead_rate=0.1)
em = EconomyCostMetrics(df, cfg)
ax1 = em.plot_cost_waterfall()
ax2 = em.plot_cpi_by_channel()
assert isinstance(ax1, Axes)
assert isinstance(ax2, Axes)

View File

@@ -0,0 +1,238 @@
import math
from datetime import datetime, timedelta
import matplotlib
import numpy as np
import pandas as pd
from beyond_metrics.dimensions.OperationalPerformance import OperationalPerformanceMetrics
matplotlib.use("Agg")
def _sample_df() -> pd.DataFrame:
"""
Dataset sintético pequeño para probar la dimensión de rendimiento operacional.
Incluye:
- varios skills
- FCR, abandonos, transferencias
- reincidencia <7 días
- logged_time para occupancy
"""
base = datetime(2024, 1, 1, 10, 0, 0)
rows = [
# cliente C1, resolved, no abandon, voz, ventas
{
"interaction_id": "id1",
"datetime_start": base,
"queue_skill": "ventas",
"channel": "voz",
"duration_talk": 600,
"hold_time": 60,
"wrap_up_time": 30,
"agent_id": "A1",
"transfer_flag": 0,
"is_resolved": 1,
"abandoned_flag": 0,
"customer_id": "C1",
"logged_time": 900,
},
# C1 vuelve en 3 días mismo canal/skill
{
"interaction_id": "id2",
"datetime_start": base + timedelta(days=3),
"queue_skill": "ventas",
"channel": "voz",
"duration_talk": 700,
"hold_time": 30,
"wrap_up_time": 40,
"agent_id": "A1",
"transfer_flag": 1,
"is_resolved": 1,
"abandoned_flag": 0,
"customer_id": "C1",
"logged_time": 900,
},
# cliente C2, soporte, chat, no resuelto, transferido
{
"interaction_id": "id3",
"datetime_start": base + timedelta(hours=1),
"queue_skill": "soporte",
"channel": "chat",
"duration_talk": 400,
"hold_time": 20,
"wrap_up_time": 30,
"agent_id": "A2",
"transfer_flag": 1,
"is_resolved": 0,
"abandoned_flag": 0,
"customer_id": "C2",
"logged_time": 800,
},
# cliente C3, abandonado
{
"interaction_id": "id4",
"datetime_start": base + timedelta(hours=2),
"queue_skill": "soporte",
"channel": "voz",
"duration_talk": 100,
"hold_time": 50,
"wrap_up_time": 10,
"agent_id": "A2",
"transfer_flag": 0,
"is_resolved": 0,
"abandoned_flag": 1,
"customer_id": "C3",
"logged_time": 600,
},
# cliente C4, una sola interacción, email
{
"interaction_id": "id5",
"datetime_start": base + timedelta(days=10),
"queue_skill": "ventas",
"channel": "email",
"duration_talk": 300,
"hold_time": 0,
"wrap_up_time": 20,
"agent_id": "A1",
"transfer_flag": 0,
"is_resolved": 1,
"abandoned_flag": 0,
"customer_id": "C4",
"logged_time": 700,
},
]
return pd.DataFrame(rows)
# ----------------------------------------------------------------------
# Inicialización y validación básica
# ----------------------------------------------------------------------
def test_init_and_required_columns():
df = _sample_df()
op = OperationalPerformanceMetrics(df)
assert not op.is_empty
# Falta columna obligatoria -> ValueError
df_missing = df.drop(columns=["duration_talk"])
try:
OperationalPerformanceMetrics(df_missing)
assert False, "Debería lanzar ValueError si falta duration_talk"
except ValueError:
pass
# ----------------------------------------------------------------------
# AHT y distribución
# ----------------------------------------------------------------------
def test_aht_distribution_basic():
df = _sample_df()
op = OperationalPerformanceMetrics(df)
dist = op.aht_distribution()
assert "p10" in dist and "p50" in dist and "p90" in dist and "p90_p50_ratio" in dist
# Comprobamos que el ratio P90/P50 es razonable (>1)
assert dist["p90_p50_ratio"] >= 1.0
# ----------------------------------------------------------------------
# FCR, escalación, abandono
# ----------------------------------------------------------------------
def test_fcr_escalation_abandonment_rates():
df = _sample_df()
op = OperationalPerformanceMetrics(df)
fcr = op.fcr_rate()
esc = op.escalation_rate()
aband = op.abandonment_rate()
# FCR: interacciones resueltas / total
# is_resolved=1 en id1, id2, id5 -> 3 de 5
assert math.isclose(fcr, 60.0, rel_tol=1e-6)
# Escalación: transfer_flag=1 en id2, id3 -> 2 de 5
assert math.isclose(esc, 40.0, rel_tol=1e-6)
# Abandono: abandoned_flag=1 en id4 -> 1 de 5
assert math.isclose(aband, 20.0, rel_tol=1e-6)
# ----------------------------------------------------------------------
# Reincidencia y repetición de canal
# ----------------------------------------------------------------------
def test_recurrence_and_repeat_channel():
df = _sample_df()
op = OperationalPerformanceMetrics(df)
rec = op.recurrence_rate_7d()
rep = op.repeat_channel_rate()
# Clientes: C1, C2, C3, C4 -> 4 clientes
# Recurrente: C1 (tiene 2 contactos en 3 días). Solo 1 de 4 -> 25%
assert math.isclose(rec, 25.0, rel_tol=1e-6)
# Reincidencias (<7d):
# Solo el par de C1: voz -> voz, mismo canal => 100%
assert math.isclose(rep, 100.0, rel_tol=1e-6)
# ----------------------------------------------------------------------
# Occupancy
# ----------------------------------------------------------------------
def test_occupancy_rate():
df = _sample_df()
op = OperationalPerformanceMetrics(df)
occ = op.occupancy_rate()
# handle_time = (600+60+30) + (700+30+40) + (400+20+30) + (100+50+10) + (300+0+20)
# = 690 + 770 + 450 + 160 + 320 = 2390
# logged_time total = 900 + 900 + 800 + 600 + 700 = 3900
expected_occ = 2390 / 3900 * 100
assert math.isclose(occ, round(expected_occ, 2), rel_tol=1e-6)
# ----------------------------------------------------------------------
# Performance Score
# ----------------------------------------------------------------------
def test_performance_score_structure_and_range():
df = _sample_df()
op = OperationalPerformanceMetrics(df)
score_info = op.performance_score()
assert "score" in score_info
assert 0.0 <= score_info["score"] <= 10.0
# ----------------------------------------------------------------------
# Plots
# ----------------------------------------------------------------------
def test_plot_methods_return_axes():
df = _sample_df()
op = OperationalPerformanceMetrics(df)
ax1 = op.plot_aht_boxplot_by_skill()
ax2 = op.plot_resolution_funnel_by_skill()
from matplotlib.axes import Axes
assert isinstance(ax1, Axes)
assert isinstance(ax2, Axes)

View File

@@ -0,0 +1,200 @@
import math
from datetime import datetime, timedelta
import pytest
import matplotlib
import numpy as np
import pandas as pd
from beyond_metrics.dimensions.SatisfactionExperience import SatisfactionExperienceMetrics
matplotlib.use("Agg")
def _sample_df_negative_corr() -> pd.DataFrame:
"""
Dataset sintético donde CSAT decrece claramente cuando AHT aumenta,
para que la correlación sea negativa (< -0.3).
"""
base = datetime(2024, 1, 1, 10, 0, 0)
rows = []
# AHT crece, CSAT baja
aht_values = [200, 300, 400, 500, 600, 700, 800, 900]
csat_values = [5.0, 4.7, 4.3, 3.8, 3.3, 2.8, 2.3, 2.0]
skills = ["ventas", "retencion"]
channels = ["voz", "chat"]
for i, (aht, csat) in enumerate(zip(aht_values, csat_values), start=1):
rows.append(
{
"interaction_id": f"id{i}",
"datetime_start": base + timedelta(minutes=5 * i),
"queue_skill": skills[i % len(skills)],
"channel": channels[i % len(channels)],
"csat_score": csat,
"duration_talk": aht * 0.7,
"hold_time": aht * 0.2,
"wrap_up_time": aht * 0.1,
}
)
return pd.DataFrame(rows)
def _sample_df_full() -> pd.DataFrame:
"""
Dataset más completo con NPS y CES para otras pruebas.
"""
base = datetime(2024, 1, 1, 10, 0, 0)
rows = []
for i in range(1, 11):
aht = 300 + 30 * i
csat = 3.0 + 0.1 * i # ligero incremento
nps = -20 + 5 * i
ces = 4.0 - 0.05 * i
rows.append(
{
"interaction_id": f"id{i}",
"datetime_start": base + timedelta(minutes=10 * i),
"queue_skill": "ventas" if i <= 5 else "retencion",
"channel": "voz" if i % 2 == 0 else "chat",
"csat_score": csat,
"duration_talk": aht * 0.7,
"hold_time": aht * 0.2,
"wrap_up_time": aht * 0.1,
"nps_score": nps,
"ces_score": ces,
}
)
return pd.DataFrame(rows)
# ----------------------------------------------------------------------
# Inicialización y validación
# ----------------------------------------------------------------------
def test_init_and_required_columns():
df = _sample_df_negative_corr()
sm = SatisfactionExperienceMetrics(df)
assert not sm.is_empty
# Quitar una columna REALMENTE obligatoria -> debe lanzar ValueError
df_missing = df.drop(columns=["duration_talk"])
with pytest.raises(ValueError):
SatisfactionExperienceMetrics(df_missing)
# Quitar csat_score ya NO debe romper: es opcional
df_no_csat = df.drop(columns=["csat_score"])
sm2 = SatisfactionExperienceMetrics(df_no_csat)
# simplemente no tendrá métricas de csat
assert sm2.is_empty is False
# ----------------------------------------------------------------------
# CSAT promedio y tablas
# ----------------------------------------------------------------------
def test_csat_avg_by_skill_channel():
df = _sample_df_full()
sm = SatisfactionExperienceMetrics(df)
table = sm.csat_avg_by_skill_channel()
# Debe tener al menos 2 skills y 2 canales
assert "ventas" in table.index
assert "retencion" in table.index
# Algún canal
assert any(col in table.columns for col in ["voz", "chat"])
def test_nps_and_ces_tables():
df = _sample_df_full()
sm = SatisfactionExperienceMetrics(df)
nps = sm.nps_avg_by_skill_channel()
ces = sm.ces_avg_by_skill_channel()
# Deben devolver DataFrame no vacío
assert not nps.empty
assert not ces.empty
assert "ventas" in nps.index
assert "ventas" in ces.index
# ----------------------------------------------------------------------
# Correlación CSAT vs AHT
# ----------------------------------------------------------------------
def test_csat_aht_correlation_negative():
df = _sample_df_negative_corr()
sm = SatisfactionExperienceMetrics(df)
corr = sm.csat_aht_correlation()
r = corr["r"]
code = corr["interpretation_code"]
assert r < -0.3
assert code == "negativo"
# ----------------------------------------------------------------------
# Clasificación por skill (sweet spot)
# ----------------------------------------------------------------------
def test_csat_aht_skill_summary_structure():
df = _sample_df_full()
sm = SatisfactionExperienceMetrics(df)
summary = sm.csat_aht_skill_summary()
assert "csat_avg" in summary.columns
assert "aht_avg" in summary.columns
assert "classification" in summary.columns
assert set(summary.index) == {"ventas", "retencion"}
# ----------------------------------------------------------------------
# Plots
# ----------------------------------------------------------------------
def test_plot_methods_return_axes():
df = _sample_df_full()
sm = SatisfactionExperienceMetrics(df)
ax1 = sm.plot_csat_vs_aht_scatter()
ax2 = sm.plot_csat_distribution()
from matplotlib.axes import Axes
assert isinstance(ax1, Axes)
assert isinstance(ax2, Axes)
def test_dataset_without_csat_does_not_break():
# Dataset “core” sin csat/nps/ces
df = pd.DataFrame(
{
"interaction_id": ["id1", "id2"],
"datetime_start": [datetime(2024, 1, 1, 10), datetime(2024, 1, 1, 11)],
"queue_skill": ["ventas", "soporte"],
"channel": ["voz", "chat"],
"duration_talk": [300, 400],
"hold_time": [30, 20],
"wrap_up_time": [20, 30],
}
)
sm = SatisfactionExperienceMetrics(df)
# No debe petar, simplemente devolver vacío/NaN
assert sm.csat_avg_by_skill_channel().empty
corr = sm.csat_aht_correlation()
assert math.isnan(corr["r"])

View File

@@ -0,0 +1,221 @@
import math
from datetime import datetime
import matplotlib
import pandas as pd
from beyond_metrics.dimensions.Volumetria import VolumetriaMetrics
# Usamos backend "Agg" para que matplotlib no intente abrir ventanas
matplotlib.use("Agg")
def _sample_df() -> pd.DataFrame:
"""
DataFrame de prueba con el nuevo esquema de columnas:
Campos usados por VolumetriaMetrics:
- interaction_id
- datetime_start
- queue_skill
- channel
5 interacciones:
- 3 por canal "voz", 2 por canal "chat"
- 3 en skill "ventas", 2 en skill "soporte"
- 3 en enero, 2 en febrero
"""
data = [
{
"interaction_id": "id1",
"datetime_start": datetime(2024, 1, 1, 9, 0),
"queue_skill": "ventas",
"channel": "voz",
},
{
"interaction_id": "id2",
"datetime_start": datetime(2024, 1, 1, 9, 30),
"queue_skill": "ventas",
"channel": "voz",
},
{
"interaction_id": "id3",
"datetime_start": datetime(2024, 1, 1, 10, 0),
"queue_skill": "soporte",
"channel": "voz",
},
{
"interaction_id": "id4",
"datetime_start": datetime(2024, 2, 1, 10, 0),
"queue_skill": "ventas",
"channel": "chat",
},
{
"interaction_id": "id5",
"datetime_start": datetime(2024, 2, 2, 11, 0),
"queue_skill": "soporte",
"channel": "chat",
},
]
return pd.DataFrame(data)
# ----------------------------------------------------------------------
# VALIDACIÓN BÁSICA
# ----------------------------------------------------------------------
def test_init_validates_required_columns():
df = _sample_df()
# No debe lanzar error con las columnas por defecto
vm = VolumetriaMetrics(df)
assert not vm.is_empty
# Si falta alguna columna requerida, debe lanzar ValueError
for col in ["interaction_id", "datetime_start", "queue_skill", "channel"]:
df_missing = df.drop(columns=[col])
try:
VolumetriaMetrics(df_missing)
assert False, f"Debería fallar al faltar la columna: {col}"
except ValueError:
pass
# ----------------------------------------------------------------------
# VOLUMEN Y DISTRIBUCIONES
# ----------------------------------------------------------------------
def test_volume_by_channel_and_skill():
df = _sample_df()
vm = VolumetriaMetrics(df)
vol_channel = vm.volume_by_channel()
vol_skill = vm.volume_by_skill()
# Canales
assert vol_channel.sum() == len(df)
assert vol_channel["voz"] == 3
assert vol_channel["chat"] == 2
# Skills
assert vol_skill.sum() == len(df)
assert vol_skill["ventas"] == 3
assert vol_skill["soporte"] == 2
def test_channel_and_skill_distribution_pct():
df = _sample_df()
vm = VolumetriaMetrics(df)
dist_channel = vm.channel_distribution_pct()
dist_skill = vm.skill_distribution_pct()
# 3/5 = 60%, 2/5 = 40%
assert math.isclose(dist_channel["voz"], 60.0, rel_tol=1e-6)
assert math.isclose(dist_channel["chat"], 40.0, rel_tol=1e-6)
assert math.isclose(dist_skill["ventas"], 60.0, rel_tol=1e-6)
assert math.isclose(dist_skill["soporte"], 40.0, rel_tol=1e-6)
# ----------------------------------------------------------------------
# HEATMAP Y SAZONALIDAD
# ----------------------------------------------------------------------
def test_heatmap_24x7_shape_and_values():
df = _sample_df()
vm = VolumetriaMetrics(df)
heatmap = vm.heatmap_24x7()
# 7 días x 24 horas
assert heatmap.shape == (7, 24)
# Comprobamos algunas celdas concretas
# 2024-01-01 es lunes (dayofweek=0), llamadas a las 9h (2) y 10h (1)
assert heatmap.loc[0, 9] == 2
assert heatmap.loc[0, 10] == 1
# 2024-02-01 es jueves (dayofweek=3), 10h
assert heatmap.loc[3, 10] == 1
# 2024-02-02 es viernes (dayofweek=4), 11h
assert heatmap.loc[4, 11] == 1
def test_monthly_seasonality_cv():
df = _sample_df()
vm = VolumetriaMetrics(df)
cv = vm.monthly_seasonality_cv()
# Volumen mensual: [3, 2]
# mean = 2.5, std (ddof=1) ≈ 0.7071 -> CV ≈ 28.28%
assert math.isclose(cv, 28.28, rel_tol=1e-2)
def test_peak_offpeak_ratio():
df = _sample_df()
vm = VolumetriaMetrics(df)
ratio = vm.peak_offpeak_ratio()
# Horas pico definidas en la clase: 10-19
# Pico: 10h,10h,11h -> 3 interacciones
# Valle: 9h,9h -> 2 interacciones
# Ratio = 3/2 = 1.5
assert math.isclose(ratio, 1.5, rel_tol=1e-6)
def test_concentration_top20_skills_pct():
df = _sample_df()
vm = VolumetriaMetrics(df)
conc = vm.concentration_top20_skills_pct()
# Skills: ventas=3, soporte=2, total=5
# Top 20% de skills (ceil(0.2 * 2) = 1 skill) -> ventas=3
# 3/5 = 60%
assert math.isclose(conc, 60.0, rel_tol=1e-6)
# ----------------------------------------------------------------------
# CASO DATAFRAME VACÍO
# ----------------------------------------------------------------------
def test_empty_dataframe_behaviour():
df_empty = pd.DataFrame(
columns=["interaction_id", "datetime_start", "queue_skill", "channel"]
)
vm = VolumetriaMetrics(df_empty)
assert vm.is_empty
assert vm.volume_by_channel().empty
assert vm.volume_by_skill().empty
assert math.isnan(vm.monthly_seasonality_cv())
assert math.isnan(vm.peak_offpeak_ratio())
assert math.isnan(vm.concentration_top20_skills_pct())
# ----------------------------------------------------------------------
# PLOTS
# ----------------------------------------------------------------------
def test_plot_methods_return_axes():
df = _sample_df()
vm = VolumetriaMetrics(df)
ax1 = vm.plot_heatmap_24x7()
ax2 = vm.plot_channel_distribution()
ax3 = vm.plot_skill_pareto()
from matplotlib.axes import Axes
assert isinstance(ax1, Axes)
assert isinstance(ax2, Axes)
assert isinstance(ax3, Axes)