Compare commits
56 Commits
claude/add
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e93944cb9c | |||
|
|
9c779eccb4 | ||
|
|
d634a38670 | ||
|
|
7be286e2c9 | ||
|
|
dbdf791d7b | ||
|
|
39806559d7 | ||
|
|
524b3d89b3 | ||
|
|
b3c4724100 | ||
|
|
0ac6249087 | ||
|
|
4f9d1d50cb | ||
|
|
a250559509 | ||
|
|
8599ffba73 | ||
|
|
3a6652fdce | ||
|
|
0e29d998c9 | ||
|
|
38df9d6071 | ||
|
|
57239e86a2 | ||
|
|
3eca28e182 | ||
|
|
5dcd605168 | ||
|
|
2f128b0dae | ||
|
|
556a3f3d11 | ||
|
|
d83789d8a2 | ||
|
|
3f77897a4c | ||
|
|
98f42bfac6 | ||
|
|
08a9ecb099 | ||
|
|
d7fd852bec | ||
|
|
cce483c5b1 | ||
|
|
b4cd8933c2 | ||
|
|
c7580f60ef | ||
|
|
33dbb27b0c | ||
|
|
7659abd405 | ||
|
|
69fce1dc28 | ||
|
|
bafd8e3f61 | ||
|
|
76a93e0dd0 | ||
|
|
20bcf94137 | ||
|
|
627504586f | ||
|
|
d645eda97c | ||
|
|
dc93b6d9e0 | ||
|
|
496da958c2 | ||
|
|
00f766913b | ||
|
|
75ddb23000 | ||
|
|
0b778557d3 | ||
|
|
badbc82478 | ||
|
|
76ed597e47 | ||
|
|
a3a645008c | ||
|
|
bbaf34f507 | ||
|
|
2a52eb6508 | ||
|
|
0a98843d6c | ||
|
|
9caa382010 | ||
|
|
83a32a48b2 | ||
|
|
8c7f5fa827 | ||
|
|
94178eaaae | ||
|
|
f18bdea812 | ||
|
|
b991824c04 | ||
|
|
283a188e57 | ||
|
|
06f0030cd2 | ||
|
|
a5bc02c6bd |
163
TRANSLATION_STATUS.md
Normal file
163
TRANSLATION_STATUS.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# Translation Status - Beyond CX Analytics
|
||||
|
||||
## ✅ Completed Modules
|
||||
|
||||
### Agentic Readiness Module
|
||||
- **Status:** ✅ **COMPLETED**
|
||||
- **Commit:** `b991824`
|
||||
- **Files:**
|
||||
- ✅ `frontend/utils/agenticReadinessV2.ts` - All functions, comments, and descriptions translated
|
||||
- ✅ `frontend/components/tabs/AgenticReadinessTab.tsx` - RED_FLAG_CONFIGS and comments translated
|
||||
- ✅ `frontend/locales/en.json` & `es.json` - New subfactors section added
|
||||
- ✅ `backend/beyond_flows/scorers/agentic_score.py` - All docstrings, comments, and reason codes translated
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Modules Pending Translation
|
||||
|
||||
### HIGH PRIORITY - Core Utils (Frontend)
|
||||
|
||||
#### 1. **realDataAnalysis.ts**
|
||||
- **Lines of Spanish:** ~92 occurrences
|
||||
- **Scope:**
|
||||
- Function names: `clasificarTierSimple()`, `clasificarTier()`
|
||||
- 20+ inline comments in Spanish
|
||||
- Function documentation
|
||||
- **Impact:** HIGH - Core analysis engine
|
||||
- **Estimated effort:** 2-3 hours
|
||||
|
||||
#### 2. **analysisGenerator.ts**
|
||||
- **Lines of Spanish:** ~49 occurrences
|
||||
- **Scope:**
|
||||
- Multiple inline comments
|
||||
- References to `clasificarTierSimple()`
|
||||
- Data transformation comments
|
||||
- **Impact:** HIGH - Main data generator
|
||||
- **Estimated effort:** 1-2 hours
|
||||
|
||||
#### 3. **backendMapper.ts**
|
||||
- **Lines of Spanish:** ~13 occurrences
|
||||
- **Scope:**
|
||||
- Function documentation
|
||||
- Mapping logic comments
|
||||
- **Impact:** MEDIUM - Backend integration
|
||||
- **Estimated effort:** 30-60 minutes
|
||||
|
||||
---
|
||||
|
||||
### MEDIUM PRIORITY - Utilities (Frontend)
|
||||
|
||||
#### 4. **dataTransformation.ts**
|
||||
- **Lines of Spanish:** ~8 occurrences
|
||||
- **Impact:** MEDIUM
|
||||
- **Estimated effort:** 30 minutes
|
||||
|
||||
#### 5. **segmentClassifier.ts**
|
||||
- **Lines of Spanish:** ~3 occurrences
|
||||
- **Impact:** LOW
|
||||
- **Estimated effort:** 15 minutes
|
||||
|
||||
#### 6. **fileParser.ts**
|
||||
- **Lines of Spanish:** ~3 occurrences
|
||||
- **Impact:** LOW
|
||||
- **Estimated effort:** 15 minutes
|
||||
|
||||
#### 7. **apiClient.ts**
|
||||
- **Lines of Spanish:** ~2 occurrences
|
||||
- **Impact:** LOW
|
||||
- **Estimated effort:** 10 minutes
|
||||
|
||||
#### 8. **serverCache.ts**
|
||||
- **Lines of Spanish:** ~2 occurrences
|
||||
- **Impact:** LOW
|
||||
- **Estimated effort:** 10 minutes
|
||||
|
||||
---
|
||||
|
||||
### MEDIUM PRIORITY - Backend Dimensions
|
||||
|
||||
#### 9. **backend/beyond_metrics/dimensions/OperationalPerformance.py**
|
||||
- **Lines of Spanish:** ~7 occurrences
|
||||
- **Impact:** MEDIUM
|
||||
- **Estimated effort:** 30 minutes
|
||||
|
||||
#### 10. **backend/beyond_metrics/dimensions/SatisfactionExperience.py**
|
||||
- **Lines of Spanish:** ~8 occurrences
|
||||
- **Impact:** MEDIUM
|
||||
- **Estimated effort:** 30 minutes
|
||||
|
||||
#### 11. **backend/beyond_metrics/dimensions/EconomyCost.py**
|
||||
- **Lines of Spanish:** ~4 occurrences
|
||||
- **Impact:** MEDIUM
|
||||
- **Estimated effort:** 20 minutes
|
||||
|
||||
---
|
||||
|
||||
### LOW PRIORITY - API & Services
|
||||
|
||||
#### 12. **backend/beyond_api/api/analysis.py**
|
||||
- **Lines of Spanish:** ~1 occurrence
|
||||
- **Impact:** LOW
|
||||
- **Estimated effort:** 5 minutes
|
||||
|
||||
#### 13. **backend/beyond_api/api/auth.py**
|
||||
- **Lines of Spanish:** ~1 occurrence
|
||||
- **Impact:** LOW
|
||||
- **Estimated effort:** 5 minutes
|
||||
|
||||
#### 14. **backend/beyond_api/services/analysis_service.py**
|
||||
- **Lines of Spanish:** ~2 occurrences
|
||||
- **Impact:** LOW
|
||||
- **Estimated effort:** 10 minutes
|
||||
|
||||
#### 15. **backend/beyond_metrics/io/base.py**
|
||||
- **Lines of Spanish:** ~1 occurrence
|
||||
- **Impact:** LOW
|
||||
- **Estimated effort:** 5 minutes
|
||||
|
||||
#### 16. **backend/beyond_metrics/io/google_drive.py**
|
||||
- **Lines of Spanish:** ~2 occurrences
|
||||
- **Impact:** LOW
|
||||
- **Estimated effort:** 10 minutes
|
||||
|
||||
---
|
||||
|
||||
## 📊 Summary Statistics
|
||||
|
||||
| Category | Files | Total Occurrences | Estimated Time |
|
||||
|----------|-------|-------------------|----------------|
|
||||
| ✅ Completed | 4 | ~150 | 3 hours (DONE) |
|
||||
| 🔴 High Priority | 3 | 154 | 4-6 hours |
|
||||
| 🟡 Medium Priority | 8 | 35 | 2-3 hours |
|
||||
| 🟢 Low Priority | 5 | 7 | 45 minutes |
|
||||
| **TOTAL PENDING** | **16** | **196** | **~8 hours** |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Recommended Translation Order
|
||||
|
||||
### Phase 1: Critical Path (High Priority)
|
||||
1. `realDataAnalysis.ts` - Core analysis engine with `clasificarTier()` functions
|
||||
2. `analysisGenerator.ts` - Main data generation orchestrator
|
||||
3. `backendMapper.ts` - Backend integration layer
|
||||
|
||||
### Phase 2: Supporting Utils (Medium Priority)
|
||||
4. `dataTransformation.ts`
|
||||
5. Backend dimension files (`OperationalPerformance.py`, `SatisfactionExperience.py`, `EconomyCost.py`)
|
||||
|
||||
### Phase 3: Final Cleanup (Low Priority)
|
||||
6. Remaining utility files and API services
|
||||
|
||||
---
|
||||
|
||||
## 📝 Notes
|
||||
|
||||
- **Variable names** like `volumen_mes`, `escalación`, etc. in data interfaces should **remain as-is** for API compatibility
|
||||
- **Function names** that are part of the public API should be carefully reviewed before renaming
|
||||
- **i18n strings** in locales files should continue to have both EN/ES versions
|
||||
- **Reason codes** and internal enums should be in English for consistency
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2026-02-07
|
||||
**Status:** agenticReadiness module completed, 16 modules pending
|
||||
@@ -11,3 +11,7 @@ build
|
||||
data/output
|
||||
*.zip
|
||||
.DS_Store
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
.env
|
||||
tests
|
||||
@@ -17,11 +17,11 @@ 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.
|
||||
Builds EconomyConfig validating types and preventing the type checker
|
||||
from mixing floats and dicts in a single dictionary.
|
||||
"""
|
||||
|
||||
# Valores por defecto
|
||||
# Default values
|
||||
default_customer_segments: Dict[str, str] = {
|
||||
"VIP": "high",
|
||||
"Premium": "high",
|
||||
@@ -45,9 +45,9 @@ def _build_economy_config(economy_data: Optional[Mapping[str, Any]]) -> EconomyC
|
||||
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}")
|
||||
raise ValueError(f"The field '{field}' must be numeric (float). Received value: {value!r}")
|
||||
|
||||
# Campos escalares
|
||||
# Scalar fields
|
||||
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)
|
||||
@@ -55,16 +55,16 @@ def _build_economy_config(economy_data: Optional[Mapping[str, Any]]) -> EconomyC
|
||||
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 may or may not be present; if present, validate it
|
||||
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}")
|
||||
raise ValueError("customer_segments must be a dictionary {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}"
|
||||
f"The value of customer_segments['{k}'] must be str. Received value: {v!r}"
|
||||
)
|
||||
customer_segments[str(k)] = v
|
||||
|
||||
@@ -86,31 +86,31 @@ def run_analysis(
|
||||
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"
|
||||
Executes the pipeline on a CSV and returns:
|
||||
- (results_dir, None) if return_type == "path"
|
||||
- (results_dir, zip_path) if 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 can be absolute or relative, but results
|
||||
will ALWAYS be written to the CSV's folder, inside a
|
||||
subfolder named timestamp (and optionally prefixed
|
||||
by company_folder).
|
||||
"""
|
||||
|
||||
input_path = input_path.resolve()
|
||||
|
||||
if not input_path.exists():
|
||||
raise FileNotFoundError(f"El CSV no existe: {input_path}")
|
||||
raise FileNotFoundError(f"CSV does not exist: {input_path}")
|
||||
if not input_path.is_file():
|
||||
raise ValueError(f"La ruta no apunta a un fichero CSV: {input_path}")
|
||||
raise ValueError(f"Path does not point to a CSV file: {input_path}")
|
||||
|
||||
# Carpeta donde está el CSV
|
||||
# Folder where the CSV is located
|
||||
csv_dir = input_path.parent
|
||||
|
||||
# DataSource y ResultsSink apuntan a ESA carpeta
|
||||
# DataSource and ResultsSink point to THAT folder
|
||||
datasource = LocalDataSource(base_dir=str(csv_dir))
|
||||
sink = LocalResultsSink(base_dir=str(csv_dir))
|
||||
|
||||
# Config de economía
|
||||
# Economy config
|
||||
economy_cfg = _build_economy_config(economy_data)
|
||||
|
||||
dimension_params: Dict[str, Mapping[str, Any]] = {
|
||||
@@ -119,13 +119,13 @@ def run_analysis(
|
||||
}
|
||||
}
|
||||
|
||||
# Callback de scoring
|
||||
# Scoring callback
|
||||
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
|
||||
# Don't break the entire execution if the scorer fails
|
||||
agentic = {
|
||||
"error": f"{type(e).__name__}: {e}",
|
||||
}
|
||||
@@ -139,45 +139,45 @@ def run_analysis(
|
||||
post_run=[agentic_post_run],
|
||||
)
|
||||
|
||||
# Timestamp de ejecución (nombre de la carpeta de resultados)
|
||||
# Execution timestamp (results folder name)
|
||||
timestamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S")
|
||||
|
||||
# Ruta lógica de resultados (RELATIVA al base_dir del sink)
|
||||
# Logical results path (RELATIVE to sink's base_dir)
|
||||
if company_folder:
|
||||
# Ej: "Cliente_X/20251208-153045"
|
||||
# E.g. "Cliente_X/20251208-153045"
|
||||
run_dir_rel = f"{company_folder.rstrip('/')}/{timestamp}"
|
||||
else:
|
||||
# Ej: "20251208-153045"
|
||||
# E.g. "20251208-153045"
|
||||
run_dir_rel = timestamp
|
||||
|
||||
# Ejecutar pipeline: el CSV se pasa relativo a csv_dir
|
||||
# Execute pipeline: CSV is passed relative to csv_dir
|
||||
pipeline.run(
|
||||
input_path=input_path.name,
|
||||
run_dir=run_dir_rel,
|
||||
)
|
||||
|
||||
# Carpeta real con los resultados
|
||||
# Actual folder with results
|
||||
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 results -------------------------------------------------------
|
||||
# Create the ZIP in the SAME folder as the CSV, with name based on 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
|
||||
# Store it relative to the results folder
|
||||
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
|
||||
from typing import Any, Mapping, Dict # ensure these imports are at the top
|
||||
|
||||
|
||||
def run_analysis_collect_json(
|
||||
@@ -187,33 +187,33 @@ def run_analysis_collect_json(
|
||||
company_folder: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Ejecuta el pipeline y devuelve un único JSON con todos los resultados.
|
||||
Executes the pipeline and returns a single JSON with all results.
|
||||
|
||||
A diferencia de run_analysis:
|
||||
- NO escribe results.json
|
||||
- NO escribe agentic_readiness.json
|
||||
- agentic_readiness se incrusta en el dict de resultados
|
||||
Unlike run_analysis:
|
||||
- Does NOT write results.json
|
||||
- Does NOT write agentic_readiness.json
|
||||
- agentic_readiness is embedded in the results dict
|
||||
|
||||
El parámetro `analysis` permite elegir el nivel de análisis:
|
||||
The `analysis` parameter allows choosing the analysis level:
|
||||
- "basic" -> beyond_metrics/configs/basic.json
|
||||
- "premium" -> beyond_metrics/configs/beyond_metrics_config.json
|
||||
"""
|
||||
|
||||
# Normalizamos y validamos la ruta del CSV
|
||||
# Normalize and validate the CSV path
|
||||
input_path = input_path.resolve()
|
||||
if not input_path.exists():
|
||||
raise FileNotFoundError(f"El CSV no existe: {input_path}")
|
||||
raise FileNotFoundError(f"CSV does not exist: {input_path}")
|
||||
if not input_path.is_file():
|
||||
raise ValueError(f"La ruta no apunta a un fichero CSV: {input_path}")
|
||||
raise ValueError(f"Path does not point to a CSV file: {input_path}")
|
||||
|
||||
# Carpeta donde está el CSV
|
||||
# Folder where the CSV is located
|
||||
csv_dir = input_path.parent
|
||||
|
||||
# DataSource y ResultsSink apuntan a ESA carpeta
|
||||
# DataSource and ResultsSink point to THAT folder
|
||||
datasource = LocalDataSource(base_dir=str(csv_dir))
|
||||
sink = LocalResultsSink(base_dir=str(csv_dir))
|
||||
|
||||
# Config de economía
|
||||
# Economy config
|
||||
economy_cfg = _build_economy_config(economy_data)
|
||||
|
||||
dimension_params: Dict[str, Mapping[str, Any]] = {
|
||||
@@ -222,13 +222,13 @@ def run_analysis_collect_json(
|
||||
}
|
||||
}
|
||||
|
||||
# Elegimos el fichero de configuración de dimensiones según `analysis`
|
||||
# Choose the dimensions config file based on `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)
|
||||
# Post-run callback: add agentic_readiness to the final JSON (without writing files)
|
||||
def agentic_post_run(results: Dict[str, Any], run_base: str, sink_: ResultsSink) -> None:
|
||||
scorer = AgenticScorer()
|
||||
try:
|
||||
@@ -245,14 +245,14 @@ def run_analysis_collect_json(
|
||||
post_run=[agentic_post_run],
|
||||
)
|
||||
|
||||
# Timestamp de ejecución (para separar posibles artefactos como plots)
|
||||
# Execution timestamp (to separate possible artifacts like 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
|
||||
# Execute pipeline without writing results.json
|
||||
results = pipeline.run(
|
||||
input_path=input_path.name,
|
||||
run_dir=run_dir_rel,
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
"""
|
||||
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).
|
||||
Calculates the Agentic Readiness Score of a contact center from
|
||||
a JSON file with aggregated KPIs (same structure as results.json).
|
||||
|
||||
Diseñado como clase para integrarse fácilmente en pipelines.
|
||||
Designed as a class to integrate easily into 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á:
|
||||
Features:
|
||||
- Tolerant to missing data: if a dimension cannot be calculated
|
||||
(due to missing KPIs), it is marked as `computed = False` and not
|
||||
included in the global score calculation.
|
||||
- Typical pipeline call:
|
||||
from agentic_score import AgenticScorer
|
||||
scorer = AgenticScorer()
|
||||
result = scorer.run_on_folder("/ruta/a/carpeta")
|
||||
result = scorer.run_on_folder("/path/to/folder")
|
||||
|
||||
Esa carpeta debe contener un `results.json` de entrada.
|
||||
El módulo generará un `agentic_readiness.json` en la misma carpeta.
|
||||
The folder must contain a `results.json` input file.
|
||||
The module will generate an `agentic_readiness.json` in the same folder.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -35,7 +35,7 @@ Number = Union[int, float]
|
||||
# =========================
|
||||
|
||||
def _is_nan(x: Any) -> bool:
|
||||
"""Devuelve True si x es NaN, None o el string 'NaN'."""
|
||||
"""Returns True if x is NaN, None or the string 'NaN'."""
|
||||
try:
|
||||
if x is None:
|
||||
return True
|
||||
@@ -60,7 +60,7 @@ def _safe_mean(values: Sequence[Optional[Number]]) -> Optional[float]:
|
||||
|
||||
|
||||
def _get_nested(d: Dict[str, Any], *keys: str, default: Any = None) -> Any:
|
||||
"""Acceso seguro a diccionarios anidados."""
|
||||
"""Safe access to nested dictionaries."""
|
||||
cur: Any = d
|
||||
for k in keys:
|
||||
if not isinstance(cur, dict) or k not in cur:
|
||||
@@ -75,20 +75,20 @@ def _clamp(value: float, lo: float = 0.0, hi: float = 10.0) -> float:
|
||||
|
||||
def _normalize_numeric_sequence(field: Any) -> Optional[List[Number]]:
|
||||
"""
|
||||
Normaliza un campo que representa una secuencia numérica.
|
||||
Normalizes a field representing a numeric sequence.
|
||||
|
||||
Soporta:
|
||||
- Formato antiguo del pipeline: [10, 20, 30]
|
||||
- Formato nuevo del pipeline: {"labels": [...], "values": [10, 20, 30]}
|
||||
Supports:
|
||||
- Old pipeline format: [10, 20, 30]
|
||||
- New pipeline format: {"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
|
||||
Returns:
|
||||
- list of numbers, if there is valid numeric data
|
||||
- None, if the field does not have an interpretable numeric sequence
|
||||
"""
|
||||
if field is None:
|
||||
return None
|
||||
|
||||
# Formato nuevo: {"labels": [...], "values": [...]}
|
||||
# New format: {"labels": [...], "values": [...]}
|
||||
if isinstance(field, dict) and "values" in field:
|
||||
seq = field.get("values")
|
||||
else:
|
||||
@@ -102,7 +102,7 @@ def _normalize_numeric_sequence(field: Any) -> Optional[List[Number]]:
|
||||
if isinstance(v, (int, float)):
|
||||
out.append(v)
|
||||
else:
|
||||
# Intentamos conversión suave por si viene como string numérico
|
||||
# Try soft conversion in case it's a numeric string
|
||||
try:
|
||||
out.append(float(v))
|
||||
except (TypeError, ValueError):
|
||||
@@ -117,21 +117,21 @@ def _normalize_numeric_sequence(field: Any) -> Optional[List[Number]]:
|
||||
|
||||
def score_repetitividad(volume_by_skill: Optional[List[Number]]) -> Dict[str, Any]:
|
||||
"""
|
||||
Repetitividad basada en volumen medio por skill.
|
||||
Repeatability based on average volume per skill.
|
||||
|
||||
Regla (pensada por proceso/skill):
|
||||
- 10 si volumen > 80
|
||||
- 5 si 40–80
|
||||
- 0 si < 40
|
||||
Rule (designed per process/skill):
|
||||
- 10 if volume > 80
|
||||
- 5 if 40–80
|
||||
- 0 if < 40
|
||||
|
||||
Si no hay datos (lista vacía o no numérica), la dimensión
|
||||
se marca como no calculada (computed = False).
|
||||
If there is no data (empty or non-numeric list), the dimension
|
||||
is marked as not calculated (computed = False).
|
||||
"""
|
||||
if not volume_by_skill:
|
||||
return {
|
||||
"score": None,
|
||||
"computed": False,
|
||||
"reason": "sin_datos_volumen",
|
||||
"reason": "no_volume_data",
|
||||
"details": {
|
||||
"avg_volume_per_skill": None,
|
||||
"volume_by_skill": volume_by_skill,
|
||||
@@ -143,7 +143,7 @@ def score_repetitividad(volume_by_skill: Optional[List[Number]]) -> Dict[str, An
|
||||
return {
|
||||
"score": None,
|
||||
"computed": False,
|
||||
"reason": "volumen_no_numerico",
|
||||
"reason": "volume_not_numeric",
|
||||
"details": {
|
||||
"avg_volume_per_skill": None,
|
||||
"volume_by_skill": volume_by_skill,
|
||||
@@ -152,13 +152,13 @@ def score_repetitividad(volume_by_skill: Optional[List[Number]]) -> Dict[str, An
|
||||
|
||||
if avg_volume > 80:
|
||||
score = 10.0
|
||||
reason = "alto_volumen"
|
||||
reason = "high_volume"
|
||||
elif avg_volume >= 40:
|
||||
score = 5.0
|
||||
reason = "volumen_medio"
|
||||
reason = "medium_volume"
|
||||
else:
|
||||
score = 0.0
|
||||
reason = "volumen_bajo"
|
||||
reason = "low_volume"
|
||||
|
||||
return {
|
||||
"score": score,
|
||||
@@ -178,36 +178,36 @@ def score_repetitividad(volume_by_skill: Optional[List[Number]]) -> Dict[str, An
|
||||
def score_predictibilidad(aht_ratio: Any,
|
||||
escalation_rate: Any) -> Dict[str, Any]:
|
||||
"""
|
||||
Predictibilidad basada en:
|
||||
- Variabilidad AHT: ratio P90/P50
|
||||
- Tasa de escalación (%)
|
||||
Predictability based on:
|
||||
- AHT variability: ratio P90/P50
|
||||
- Escalation rate (%)
|
||||
|
||||
Regla:
|
||||
- 10 si ratio < 1.5 y escalación < 10%
|
||||
- 5 si ratio 1.5–2.0 o escalación 10–20%
|
||||
- 0 si ratio > 2.0 y escalación > 20%
|
||||
- 3 fallback si datos parciales
|
||||
Rule:
|
||||
- 10 if ratio < 1.5 and escalation < 10%
|
||||
- 5 if ratio 1.5–2.0 or escalation 10–20%
|
||||
- 0 if ratio > 2.0 and escalation > 20%
|
||||
- 3 fallback if data parciales
|
||||
|
||||
Si no hay ni ratio ni escalación, la dimensión no se calcula.
|
||||
If there is no ratio nor escalation, the dimension is not calculated.
|
||||
"""
|
||||
if aht_ratio is None and escalation_rate is None:
|
||||
return {
|
||||
"score": None,
|
||||
"computed": False,
|
||||
"reason": "sin_datos",
|
||||
"reason": "no_data",
|
||||
"details": {
|
||||
"aht_p90_p50_ratio": None,
|
||||
"escalation_rate_pct": None,
|
||||
},
|
||||
}
|
||||
|
||||
# Normalizamos ratio
|
||||
# Normalize ratio
|
||||
if aht_ratio is None or _is_nan(aht_ratio):
|
||||
ratio: Optional[float] = None
|
||||
else:
|
||||
ratio = float(aht_ratio)
|
||||
|
||||
# Normalizamos escalación
|
||||
# Normalize escalation
|
||||
if escalation_rate is None or _is_nan(escalation_rate):
|
||||
esc: Optional[float] = None
|
||||
else:
|
||||
@@ -217,7 +217,7 @@ def score_predictibilidad(aht_ratio: Any,
|
||||
return {
|
||||
"score": None,
|
||||
"computed": False,
|
||||
"reason": "sin_datos",
|
||||
"reason": "no_data",
|
||||
"details": {
|
||||
"aht_p90_p50_ratio": None,
|
||||
"escalation_rate_pct": None,
|
||||
@@ -230,20 +230,20 @@ def score_predictibilidad(aht_ratio: Any,
|
||||
if ratio is not None and esc is not None:
|
||||
if ratio < 1.5 and esc < 10.0:
|
||||
score = 10.0
|
||||
reason = "alta_predictibilidad"
|
||||
reason = "high_predictability"
|
||||
elif (1.5 <= ratio <= 2.0) or (10.0 <= esc <= 20.0):
|
||||
score = 5.0
|
||||
reason = "predictibilidad_media"
|
||||
reason = "medium_predictability"
|
||||
elif ratio > 2.0 and esc > 20.0:
|
||||
score = 0.0
|
||||
reason = "baja_predictibilidad"
|
||||
reason = "low_predictability"
|
||||
else:
|
||||
score = 3.0
|
||||
reason = "caso_intermedio"
|
||||
reason = "intermediate_case"
|
||||
else:
|
||||
# Datos parciales: penalizamos pero no ponemos a 0
|
||||
# Partial data: penalize but do not set to 0
|
||||
score = 3.0
|
||||
reason = "datos_parciales"
|
||||
reason = "partial_data"
|
||||
|
||||
return {
|
||||
"score": score,
|
||||
@@ -263,23 +263,23 @@ def score_predictibilidad(aht_ratio: Any,
|
||||
|
||||
def score_estructuracion(channel_distribution_pct: Any) -> Dict[str, Any]:
|
||||
"""
|
||||
Estructuración de datos usando proxy de canal.
|
||||
Data structuring using channel proxy.
|
||||
|
||||
Asumimos que el canal con mayor % es texto (en proyectos reales se puede
|
||||
We assume the channel with the highest % is text (en proyectos reales se puede
|
||||
parametrizar esta asignación).
|
||||
|
||||
Regla:
|
||||
- 10 si texto > 60%
|
||||
Rule:
|
||||
- 10 if text > 60%
|
||||
- 5 si 30–60%
|
||||
- 0 si < 30%
|
||||
|
||||
Si no hay datos de canales, la dimensión no se calcula.
|
||||
If there is no datas of channels, the dimension is not calculated.
|
||||
"""
|
||||
if not channel_distribution_pct:
|
||||
return {
|
||||
"score": None,
|
||||
"computed": False,
|
||||
"reason": "sin_datos_canal",
|
||||
"reason": "no_channel_data",
|
||||
"details": {
|
||||
"estimated_text_share_pct": None,
|
||||
"channel_distribution_pct": channel_distribution_pct,
|
||||
@@ -299,7 +299,7 @@ def score_estructuracion(channel_distribution_pct: Any) -> Dict[str, Any]:
|
||||
return {
|
||||
"score": None,
|
||||
"computed": False,
|
||||
"reason": "canales_no_numericos",
|
||||
"reason": "channels_not_numeric",
|
||||
"details": {
|
||||
"estimated_text_share_pct": None,
|
||||
"channel_distribution_pct": channel_distribution_pct,
|
||||
@@ -308,13 +308,13 @@ def score_estructuracion(channel_distribution_pct: Any) -> Dict[str, Any]:
|
||||
|
||||
if max_share > 60.0:
|
||||
score = 10.0
|
||||
reason = "alta_proporcion_texto"
|
||||
reason = "high_text_proportion"
|
||||
elif max_share >= 30.0:
|
||||
score = 5.0
|
||||
reason = "proporcion_texto_media"
|
||||
reason = "medium_text_proportion"
|
||||
else:
|
||||
score = 0.0
|
||||
reason = "baja_proporcion_texto"
|
||||
reason = "low_text_proportion"
|
||||
|
||||
return {
|
||||
"score": score,
|
||||
@@ -334,9 +334,9 @@ def score_estructuracion(channel_distribution_pct: Any) -> Dict[str, Any]:
|
||||
def score_complejidad(aht_ratio: Any,
|
||||
escalation_rate: Any) -> Dict[str, Any]:
|
||||
"""
|
||||
Complejidad inversa del proceso (0–10).
|
||||
Inverse complexity of the process (0–10).
|
||||
|
||||
1) Base: inversa lineal de la variabilidad AHT (ratio P90/P50):
|
||||
1) Base: linear inverse de la variabilidad AHT (ratio P90/P50):
|
||||
- ratio = 1.0 -> 10
|
||||
- ratio = 1.5 -> ~7.5
|
||||
- ratio = 2.0 -> 5
|
||||
@@ -345,12 +345,12 @@ def score_complejidad(aht_ratio: Any,
|
||||
|
||||
formula_base = (3 - ratio) / (3 - 1) * 10, acotado a [0,10]
|
||||
|
||||
2) Ajuste por escalación:
|
||||
2) Escalation adjustment:
|
||||
- restamos (escalation_rate / 5) puntos.
|
||||
|
||||
Nota: más score = proceso más "simple / automatizable".
|
||||
Nota: higher score = process more "simple / automatizable".
|
||||
|
||||
Si no hay ni ratio ni escalación, la dimensión no se calcula.
|
||||
If there is no ratio nor escalation, the dimension is not calculated.
|
||||
"""
|
||||
if aht_ratio is None or _is_nan(aht_ratio):
|
||||
ratio: Optional[float] = None
|
||||
@@ -366,36 +366,36 @@ def score_complejidad(aht_ratio: Any,
|
||||
return {
|
||||
"score": None,
|
||||
"computed": False,
|
||||
"reason": "sin_datos",
|
||||
"reason": "no_data",
|
||||
"details": {
|
||||
"aht_p90_p50_ratio": None,
|
||||
"escalation_rate_pct": None,
|
||||
},
|
||||
}
|
||||
|
||||
# Base por variabilidad
|
||||
# Base for variability
|
||||
if ratio is None:
|
||||
base = 5.0 # fallback neutro
|
||||
base_reason = "sin_ratio_usamos_valor_neutro"
|
||||
base = 5.0 # neutral fallback
|
||||
base_reason = "no_ratio_using_neutral_value"
|
||||
else:
|
||||
base_raw = (3.0 - ratio) / (3.0 - 1.0) * 10.0
|
||||
base = _clamp(base_raw)
|
||||
base_reason = "calculado_desde_ratio"
|
||||
base_reason = "calculated_from_ratio"
|
||||
|
||||
# Ajuste por escalación
|
||||
# Escalation adjustment
|
||||
if esc is None:
|
||||
adj = 0.0
|
||||
adj_reason = "sin_escalacion_sin_ajuste"
|
||||
adj_reason = "no_escalation_no_adjustment"
|
||||
else:
|
||||
adj = - (esc / 5.0) # cada 5 puntos de escalación resta 1
|
||||
adj_reason = "ajuste_por_escalacion"
|
||||
adj = - (esc / 5.0) # every 5 escalation points subtract 1
|
||||
adj_reason = "escalation_adjustment"
|
||||
|
||||
final_score = _clamp(base + adj)
|
||||
|
||||
return {
|
||||
"score": final_score,
|
||||
"computed": True,
|
||||
"reason": "complejidad_inversa",
|
||||
"reason": "inverse_complexity",
|
||||
"details": {
|
||||
"aht_p90_p50_ratio": ratio,
|
||||
"escalation_rate_pct": esc,
|
||||
@@ -409,21 +409,21 @@ def score_complejidad(aht_ratio: Any,
|
||||
|
||||
def score_estabilidad(peak_offpeak_ratio: Any) -> Dict[str, Any]:
|
||||
"""
|
||||
Estabilidad del proceso basada en relación pico/off-peak.
|
||||
Process stability based on peak/off-peak ratio.
|
||||
|
||||
Regla:
|
||||
- 10 si ratio < 3
|
||||
Rule:
|
||||
- 10 if ratio < 3
|
||||
- 7 si 3–5
|
||||
- 3 si 5–7
|
||||
- 0 si > 7
|
||||
|
||||
Si no hay dato de ratio, la dimensión no se calcula.
|
||||
If there is no data of ratio, the dimension is not calculated.
|
||||
"""
|
||||
if peak_offpeak_ratio is None or _is_nan(peak_offpeak_ratio):
|
||||
return {
|
||||
"score": None,
|
||||
"computed": False,
|
||||
"reason": "sin_datos_peak_offpeak",
|
||||
"reason": "no_peak_offpeak_data",
|
||||
"details": {
|
||||
"peak_offpeak_ratio": None,
|
||||
},
|
||||
@@ -432,16 +432,16 @@ def score_estabilidad(peak_offpeak_ratio: Any) -> Dict[str, Any]:
|
||||
r = float(peak_offpeak_ratio)
|
||||
if r < 3.0:
|
||||
score = 10.0
|
||||
reason = "muy_estable"
|
||||
reason = "very_stable"
|
||||
elif r < 5.0:
|
||||
score = 7.0
|
||||
reason = "estable_moderado"
|
||||
reason = "moderately_stable"
|
||||
elif r < 7.0:
|
||||
score = 3.0
|
||||
reason = "pico_pronunciado"
|
||||
reason = "pronounced_peak"
|
||||
else:
|
||||
score = 0.0
|
||||
reason = "muy_inestable"
|
||||
reason = "very_unstable"
|
||||
|
||||
return {
|
||||
"score": score,
|
||||
@@ -460,20 +460,20 @@ def score_estabilidad(peak_offpeak_ratio: Any) -> Dict[str, Any]:
|
||||
|
||||
def score_roi(annual_savings: Any) -> Dict[str, Any]:
|
||||
"""
|
||||
ROI potencial anual.
|
||||
Annual potential ROI.
|
||||
|
||||
Regla:
|
||||
- 10 si ahorro > 100k €/año
|
||||
- 5 si 10k–100k €/año
|
||||
- 0 si < 10k €/año
|
||||
Rule:
|
||||
- 10 if savings > 100k €/year
|
||||
- 5 si 10k–100k €/year
|
||||
- 0 si < 10k €/year
|
||||
|
||||
Si no hay dato de ahorro, la dimensión no se calcula.
|
||||
If there is no data of savings, the dimension is not calculated.
|
||||
"""
|
||||
if annual_savings is None or _is_nan(annual_savings):
|
||||
return {
|
||||
"score": None,
|
||||
"computed": False,
|
||||
"reason": "sin_datos_ahorro",
|
||||
"reason": "no_savings_data",
|
||||
"details": {
|
||||
"annual_savings_eur": None,
|
||||
},
|
||||
@@ -482,13 +482,13 @@ def score_roi(annual_savings: Any) -> Dict[str, Any]:
|
||||
savings = float(annual_savings)
|
||||
if savings > 100_000:
|
||||
score = 10.0
|
||||
reason = "roi_alto"
|
||||
reason = "high_roi"
|
||||
elif savings >= 10_000:
|
||||
score = 5.0
|
||||
reason = "roi_medio"
|
||||
reason = "medium_roi"
|
||||
else:
|
||||
score = 0.0
|
||||
reason = "roi_bajo"
|
||||
reason = "low_roi"
|
||||
|
||||
return {
|
||||
"score": score,
|
||||
@@ -506,20 +506,20 @@ def score_roi(annual_savings: Any) -> Dict[str, Any]:
|
||||
|
||||
def classify_agentic_score(score: Optional[float]) -> Dict[str, Any]:
|
||||
"""
|
||||
Clasificación final (alineada con frontend):
|
||||
- ≥6: COPILOT 🤖 (Listo para Copilot)
|
||||
Final classification (aligned with frontend):
|
||||
- ≥6: COPILOT 🤖 (Ready for Copilot)
|
||||
- 4–5.99: OPTIMIZE 🔧 (Optimizar Primero)
|
||||
- <4: HUMAN 👤 (Requiere Gestión Humana)
|
||||
|
||||
Si score es None (ninguna dimensión disponible), devuelve NO_DATA.
|
||||
If score is None (no dimension available), returns 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."
|
||||
"Could not calculate the Agentic Readiness Score because "
|
||||
"none of the dimensions had sufficient data."
|
||||
),
|
||||
}
|
||||
|
||||
@@ -527,22 +527,22 @@ def classify_agentic_score(score: Optional[float]) -> Dict[str, Any]:
|
||||
label = "COPILOT"
|
||||
emoji = "🤖"
|
||||
description = (
|
||||
"Listo para Copilot. Procesos con predictibilidad y simplicidad "
|
||||
"suficientes para asistencia IA (sugerencias en tiempo real, autocompletado)."
|
||||
"Ready for Copilot. Processes with sufficient predictability and simplicity "
|
||||
"for AI assistance (real-time suggestions, autocomplete)."
|
||||
)
|
||||
elif score >= 4.0:
|
||||
label = "OPTIMIZE"
|
||||
emoji = "🔧"
|
||||
description = (
|
||||
"Optimizar primero. Estandarizar procesos y reducir variabilidad "
|
||||
"antes de implementar asistencia IA."
|
||||
"Optimize first. Standardize processes and reduce variability "
|
||||
"before implementing AI assistance."
|
||||
)
|
||||
else:
|
||||
label = "HUMAN"
|
||||
emoji = "👤"
|
||||
description = (
|
||||
"Requiere gestión humana. Procesos complejos o variables que "
|
||||
"necesitan intervención humana antes de considerar automatización."
|
||||
"Requires human management. Complex or variable processes that "
|
||||
"need human intervention before considering automation."
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -604,22 +604,22 @@ class AgenticScorer:
|
||||
|
||||
def compute_from_data(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Calcula el Agentic Readiness Score a partir de un dict de datos.
|
||||
Calculates the Agentic Readiness Score from a data dict.
|
||||
|
||||
Tolerante a datos faltantes: renormaliza pesos usando solo
|
||||
dimensiones con `computed = True`.
|
||||
Tolerant to missing data: renormalizes weights using only
|
||||
dimensions with `computed = True`.
|
||||
|
||||
Compatibilidad con pipeline:
|
||||
- Soporta tanto el formato antiguo:
|
||||
Pipeline compatibility:
|
||||
- Supports both the old format:
|
||||
"volume_by_skill": [10, 20, 30]
|
||||
- como el nuevo:
|
||||
- and the new:
|
||||
"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
|
||||
# Normalize here the possible formats for the type checker
|
||||
volume_by_skill = _normalize_numeric_sequence(
|
||||
volumetry.get("volume_by_skill")
|
||||
)
|
||||
@@ -650,7 +650,7 @@ class AgenticScorer:
|
||||
"roi": roi,
|
||||
}
|
||||
|
||||
# --- Renormalización de pesos sólo con dimensiones disponibles ---
|
||||
# --- Weight renormalization only with available dimensions ---
|
||||
effective_weights: Dict[str, float] = {}
|
||||
for name, base_w in self.base_weights.items():
|
||||
dim = sub_scores.get(name, {})
|
||||
@@ -665,7 +665,7 @@ class AgenticScorer:
|
||||
else:
|
||||
normalized_weights = {}
|
||||
|
||||
# --- Score final ---
|
||||
# --- Final score ---
|
||||
if not normalized_weights:
|
||||
final_score: Optional[float] = None
|
||||
else:
|
||||
@@ -692,8 +692,8 @@ class AgenticScorer:
|
||||
"metadata": {
|
||||
"source_module": "agentic_score.py",
|
||||
"notes": (
|
||||
"Modelo simplificado basado en KPIs agregados. "
|
||||
"Renormaliza los pesos cuando faltan dimensiones."
|
||||
"Simplified model based on aggregated KPIs. "
|
||||
"Renormalizes weights when dimensions are missing."
|
||||
),
|
||||
},
|
||||
}
|
||||
@@ -710,11 +710,11 @@ class AgenticScorer:
|
||||
|
||||
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
|
||||
Typical pipeline entry point:
|
||||
- Reads <folder>/results.json
|
||||
- Calculates Agentic Readiness
|
||||
- Writes <folder>/agentic_readiness.json
|
||||
- Returns the dict with the result
|
||||
"""
|
||||
data = self.load_results(folder_path)
|
||||
result = self.compute_from_data(data)
|
||||
|
||||
@@ -14,25 +14,25 @@ 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."
|
||||
"You are an expert contact center consultant. "
|
||||
"You will receive analytical results from a metrics system "
|
||||
"(BeyondMetrics) in JSON format. Your task is to generate a clear, "
|
||||
"actionable, business-oriented report, highlighting the main findings, "
|
||||
"risks, and opportunities for improvement."
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReportAgentConfig:
|
||||
"""
|
||||
Configuración básica del agente de informes.
|
||||
Basic configuration for the report agent.
|
||||
|
||||
openai_api_key:
|
||||
Se puede pasar explícitamente o leer de la variable de entorno OPENAI_API_KEY.
|
||||
Can be passed explicitly or read from the OPENAI_API_KEY environment variable.
|
||||
model:
|
||||
Modelo de ChatGPT a utilizar, p.ej. 'gpt-4.1-mini' o similar.
|
||||
ChatGPT model to use, e.g. 'gpt-4.1-mini' or similar.
|
||||
system_prompt:
|
||||
Prompt de sistema para controlar el estilo del informe.
|
||||
System prompt to control the report style.
|
||||
"""
|
||||
|
||||
openai_api_key: Optional[str] = None
|
||||
@@ -42,15 +42,15 @@ class ReportAgentConfig:
|
||||
|
||||
class BeyondMetricsReportAgent:
|
||||
"""
|
||||
Agente muy sencillo que:
|
||||
Simple agent that:
|
||||
|
||||
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.
|
||||
1) Reads the JSON results from a BeyondMetrics execution.
|
||||
2) Builds a prompt with those results.
|
||||
3) Calls ChatGPT to generate a text report.
|
||||
4) Saves the report to a PDF on disk, EMBEDDING the PNG images
|
||||
generated by the pipeline as attachments.
|
||||
|
||||
MVP: centrado en texto + figuras incrustadas.
|
||||
MVP: focused on text + embedded figures.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[ReportAgentConfig] = None) -> None:
|
||||
@@ -59,16 +59,16 @@ class BeyondMetricsReportAgent:
|
||||
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."
|
||||
"Missing OpenAI API key. "
|
||||
"Pass it in ReportAgentConfig(openai_api_key=...) or "
|
||||
"define the OPENAI_API_KEY environment variable."
|
||||
)
|
||||
|
||||
# Cliente de la nueva API de OpenAI
|
||||
# New OpenAI API client
|
||||
self._client = OpenAI(api_key=api_key)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# API pública principal
|
||||
# Main public API
|
||||
# ------------------------------------------------------------------
|
||||
def generate_pdf_report(
|
||||
self,
|
||||
@@ -77,48 +77,48 @@ class BeyondMetricsReportAgent:
|
||||
extra_user_prompt: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
Genera un informe en PDF a partir de una carpeta de resultados.
|
||||
Generates a PDF report from a results folder.
|
||||
|
||||
Parámetros:
|
||||
Parameters:
|
||||
- run_base:
|
||||
Carpeta base de la ejecución. Debe contener al menos 'results.json'
|
||||
y, opcionalmente, imágenes PNG generadas por el pipeline.
|
||||
Base folder for the execution. Must contain at least 'results.json'
|
||||
and, optionally, PNG images generated by the pipeline.
|
||||
- output_pdf_path:
|
||||
Ruta completa del PDF de salida. Si es None, se crea
|
||||
'beyondmetrics_report.pdf' dentro de run_base.
|
||||
Full path for the output PDF. If None, creates
|
||||
'beyondmetrics_report.pdf' inside run_base.
|
||||
- extra_user_prompt:
|
||||
Texto adicional para afinar la petición al agente
|
||||
(p.ej. "enfatiza eficiencia y SLA", etc.)
|
||||
Additional text to refine the agent's request
|
||||
(e.g. "emphasize efficiency and SLA", etc.)
|
||||
|
||||
Devuelve:
|
||||
- La ruta del PDF generado.
|
||||
Returns:
|
||||
- The path to the generated PDF.
|
||||
"""
|
||||
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."
|
||||
f"{results_json} not found. "
|
||||
"Make sure to run the pipeline first."
|
||||
)
|
||||
|
||||
# 1) Leer JSON de resultados
|
||||
# 1) Read results JSON
|
||||
with results_json.open("r", encoding="utf-8") as f:
|
||||
results_data: Dict[str, Any] = json.load(f)
|
||||
|
||||
# 2) Buscar imágenes generadas
|
||||
# 2) Find generated images
|
||||
image_files = sorted(p for p in run_dir.glob("*.png"))
|
||||
|
||||
# 3) Construir prompt de usuario
|
||||
# 3) Build user prompt
|
||||
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
|
||||
# 4) Call ChatGPT to get the report text
|
||||
report_text = self._call_chatgpt(user_prompt)
|
||||
|
||||
# 5) Crear PDF con texto + imágenes embebidas
|
||||
# 5) Create PDF with text + embedded images
|
||||
if output_pdf_path is None:
|
||||
output_pdf_path = str(run_dir / "beyondmetrics_report.pdf")
|
||||
|
||||
@@ -127,7 +127,7 @@ class BeyondMetricsReportAgent:
|
||||
return output_pdf_path
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Construcción del prompt
|
||||
# Prompt construction
|
||||
# ------------------------------------------------------------------
|
||||
def _build_user_prompt(
|
||||
self,
|
||||
@@ -136,34 +136,34 @@ class BeyondMetricsReportAgent:
|
||||
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.
|
||||
Builds the user message to be sent to the model.
|
||||
For an MVP, we serialize the entire results JSON.
|
||||
Later, this can be summarized if the JSON grows too large.
|
||||
"""
|
||||
results_str = json.dumps(results, indent=2, ensure_ascii=False)
|
||||
|
||||
images_section = (
|
||||
"Imágenes generadas en la ejecución:\n"
|
||||
"Images generated in the execution:\n"
|
||||
+ "\n".join(f"- {name}" for name in image_files)
|
||||
if image_files
|
||||
else "No se han generado imágenes en esta ejecución."
|
||||
else "No images were generated in this execution."
|
||||
)
|
||||
|
||||
extra = (
|
||||
f"\n\nInstrucciones adicionales del usuario:\n{extra_user_prompt}"
|
||||
f"\n\nAdditional user instructions:\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"
|
||||
"Below I provide you with the results of a BeyondMetrics execution "
|
||||
"in JSON format. You must produce an EXECUTIVE REPORT for a contact "
|
||||
"center client. The report should include:\n"
|
||||
"- Executive summary in business language.\n"
|
||||
"- Main findings by dimension.\n"
|
||||
"- Detected risks or issues.\n"
|
||||
"- Actionable recommendations.\n\n"
|
||||
"Results (JSON):\n"
|
||||
f"{results_str}\n\n"
|
||||
f"{images_section}"
|
||||
f"{extra}"
|
||||
@@ -172,12 +172,12 @@ class BeyondMetricsReportAgent:
|
||||
return prompt
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Llamada a ChatGPT (nueva API)
|
||||
# ChatGPT call (new 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.
|
||||
Calls the ChatGPT model and returns the content of the response message.
|
||||
Implemented with the new OpenAI API.
|
||||
"""
|
||||
resp = self._client.chat.completions.create(
|
||||
model=self.config.model,
|
||||
@@ -190,11 +190,11 @@ class BeyondMetricsReportAgent:
|
||||
|
||||
content = resp.choices[0].message.content
|
||||
if not isinstance(content, str):
|
||||
raise RuntimeError("La respuesta del modelo no contiene texto.")
|
||||
raise RuntimeError("The model response does not contain text.")
|
||||
return content
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Escritura de PDF (texto + imágenes)
|
||||
# PDF writing (text + images)
|
||||
# ------------------------------------------------------------------
|
||||
def _write_pdf(
|
||||
self,
|
||||
@@ -203,11 +203,11 @@ class BeyondMetricsReportAgent:
|
||||
image_paths: Sequence[Path],
|
||||
) -> None:
|
||||
"""
|
||||
Crea un PDF A4 con:
|
||||
Creates an A4 PDF with:
|
||||
|
||||
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.
|
||||
1) Report text (initial pages).
|
||||
2) An appendix section where the PNG images generated by the
|
||||
pipeline are embedded, scaled to fit the page.
|
||||
"""
|
||||
output_path = str(output_path)
|
||||
c = canvas.Canvas(output_path, pagesize=A4)
|
||||
@@ -220,7 +220,7 @@ class BeyondMetricsReportAgent:
|
||||
|
||||
c.setFont("Helvetica", 11)
|
||||
|
||||
# --- Escribir texto principal ---
|
||||
# --- Write main text ---
|
||||
def _wrap_line(line: str, max_chars: int = 100) -> list[str]:
|
||||
parts: list[str] = []
|
||||
current: list[str] = []
|
||||
@@ -248,37 +248,37 @@ class BeyondMetricsReportAgent:
|
||||
c.drawString(margin_x, y, line)
|
||||
y -= line_height
|
||||
|
||||
# --- Anexar imágenes como figuras ---
|
||||
# --- Append images as figures ---
|
||||
if image_paths:
|
||||
# Nueva página para las figuras
|
||||
# New page for figures
|
||||
c.showPage()
|
||||
c.setFont("Helvetica-Bold", 14)
|
||||
c.drawString(margin_x, height - margin_y, "Anexo: Figuras")
|
||||
c.drawString(margin_x, height - margin_y, "Appendix: Figures")
|
||||
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
|
||||
# If the image doesn't fit on the page, move to the next one
|
||||
available_height = current_y - margin_y
|
||||
if available_height < 100: # espacio mínimo
|
||||
if available_height < 100: # minimum space
|
||||
c.showPage()
|
||||
c.setFont("Helvetica-Bold", 14)
|
||||
c.drawString(margin_x, height - margin_y, "Anexo: Figuras (cont.)")
|
||||
c.drawString(margin_x, height - margin_y, "Appendix: Figures (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}"
|
||||
# Figure title
|
||||
title = f"Figure: {img_path.name}"
|
||||
c.drawString(margin_x, current_y, title)
|
||||
current_y -= line_height
|
||||
|
||||
# Cargar imagen y escalarla
|
||||
# Load and scale image
|
||||
try:
|
||||
img = ImageReader(str(img_path))
|
||||
iw, ih = img.getSize()
|
||||
# Escala para encajar en ancho y alto disponibles
|
||||
# Scale to fit available width and height
|
||||
max_img_height = available_height - 2 * line_height
|
||||
scale = min(max_width / iw, max_img_height / ih)
|
||||
if scale <= 0:
|
||||
@@ -302,8 +302,8 @@ class BeyondMetricsReportAgent:
|
||||
|
||||
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}"
|
||||
# If loading fails, indicate it in the PDF
|
||||
err_msg = f"Could not load image {img_path.name}: {e}"
|
||||
c.drawString(margin_x, current_y, err_msg)
|
||||
current_y -= 2 * line_height
|
||||
|
||||
|
||||
@@ -23,17 +23,16 @@ REQUIRED_COLUMNS_ECON: List[str] = [
|
||||
@dataclass
|
||||
class EconomyConfig:
|
||||
"""
|
||||
Parámetros manuales para la dimensión de Economía y Costes.
|
||||
Manual parameters for the Economy and Cost dimension.
|
||||
|
||||
- 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).
|
||||
- labor_cost_per_hour: total cost/hour of an agent (fully loaded).
|
||||
- overhead_rate: % variable overhead (e.g. 0.1 = 10% over labor).
|
||||
- tech_costs_annual: annual technology cost (licenses, infrastructure, ...).
|
||||
- automation_cpi: cost per automated interaction (e.g. 0.15€).
|
||||
- automation_volume_share: % of automatable volume (0-1).
|
||||
- automation_success_rate: % automation success (0-1).
|
||||
|
||||
- customer_segments: mapping opcional skill -> segmento ("high"/"medium"/"low")
|
||||
para futuros insights de ROI por segmento.
|
||||
- customer_segments: optional mapping skill -> segment ("high"/"medium"/"low") for future ROI insights by segment.
|
||||
"""
|
||||
|
||||
labor_cost_per_hour: float
|
||||
@@ -48,20 +47,20 @@ class EconomyConfig:
|
||||
@dataclass
|
||||
class EconomyCostMetrics:
|
||||
"""
|
||||
DIMENSIÓN 4: ECONOMÍA y COSTES
|
||||
DIMENSION 4: ECONOMY and COSTS
|
||||
|
||||
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.
|
||||
Purpose:
|
||||
- Quantify the current COST (CPI, annual cost).
|
||||
- Estimate the impact of overhead and technology.
|
||||
- Calculate an initial estimate of "inefficiency cost" and potential savings.
|
||||
|
||||
Requiere:
|
||||
- Columnas del dataset transaccional (ver REQUIRED_COLUMNS_ECON).
|
||||
Requires:
|
||||
- Columns from the transactional dataset (see REQUIRED_COLUMNS_ECON).
|
||||
|
||||
Inputs opcionales vía EconomyConfig:
|
||||
- labor_cost_per_hour (obligatorio para cualquier cálculo de €).
|
||||
Optional inputs via EconomyConfig:
|
||||
- labor_cost_per_hour (required for any € calculation).
|
||||
- overhead_rate, tech_costs_annual, automation_*.
|
||||
- customer_segments (para insights de ROI por segmento).
|
||||
- customer_segments (for ROI insights by segment).
|
||||
"""
|
||||
|
||||
df: pd.DataFrame
|
||||
@@ -72,13 +71,13 @@ class EconomyCostMetrics:
|
||||
self._prepare_data()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Helpers internos
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------ #
|
||||
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}"
|
||||
f"Missing required columns for EconomyCostMetrics: {missing}"
|
||||
)
|
||||
|
||||
def _prepare_data(self) -> None:
|
||||
@@ -97,15 +96,15 @@ class EconomyCostMetrics:
|
||||
df["duration_talk"].fillna(0)
|
||||
+ df["hold_time"].fillna(0)
|
||||
+ df["wrap_up_time"].fillna(0)
|
||||
) # segundos
|
||||
) # seconds
|
||||
|
||||
# Filtrar por record_status para cálculos de AHT/CPI
|
||||
# Solo incluir registros VALID (excluir NOISE, ZOMBIE, ABANDON)
|
||||
# Filter by record_status for AHT/CPI calculations
|
||||
# Only include VALID records (exclude NOISE, ZOMBIE, ABANDON)
|
||||
if "record_status" in df.columns:
|
||||
df["record_status"] = df["record_status"].astype(str).str.strip().str.upper()
|
||||
df["_is_valid_for_cost"] = df["record_status"] == "VALID"
|
||||
else:
|
||||
# Legacy data sin record_status: incluir todo
|
||||
# Legacy data without record_status: include all
|
||||
df["_is_valid_for_cost"] = True
|
||||
|
||||
self.df = df
|
||||
@@ -118,11 +117,11 @@ class EconomyCostMetrics:
|
||||
return self.config is not None and self.config.labor_cost_per_hour is not None
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# KPI 1: CPI por canal/skill
|
||||
# KPI 1: CPI by channel/skill
|
||||
# ------------------------------------------------------------------ #
|
||||
def cpi_by_skill_channel(self) -> pd.DataFrame:
|
||||
"""
|
||||
CPI (Coste Por Interacción) por skill/canal.
|
||||
CPI (Cost Per Interaction) by skill/channel.
|
||||
|
||||
CPI = (Labor_cost_per_interaction + Overhead_variable) / EFFECTIVE_PRODUCTIVITY
|
||||
|
||||
@@ -130,19 +129,17 @@ class EconomyCostMetrics:
|
||||
- Overhead_variable = overhead_rate * Labor_cost_per_interaction
|
||||
- EFFECTIVE_PRODUCTIVITY = 0.70 (70% - accounts for non-productive time)
|
||||
|
||||
Excluye registros abandonados del cálculo de costes para consistencia
|
||||
con el path del frontend (fresh CSV).
|
||||
Excludes abandoned records from cost calculation for consistency with the frontend path (fresh CSV).
|
||||
|
||||
Si no hay config de costes -> devuelve DataFrame vacío.
|
||||
If there is no cost config -> returns empty DataFrame.
|
||||
|
||||
Incluye queue_skill y channel como columnas (no solo índice) para que
|
||||
el frontend pueda hacer lookup por nombre de skill.
|
||||
Includes queue_skill and channel as columns (not just index) so that the frontend can lookup by skill name.
|
||||
"""
|
||||
if not self._has_cost_config():
|
||||
return pd.DataFrame()
|
||||
|
||||
cfg = self.config
|
||||
assert cfg is not None # para el type checker
|
||||
assert cfg is not None # for the type checker
|
||||
|
||||
df = self.df.copy()
|
||||
if df.empty:
|
||||
@@ -154,15 +151,15 @@ class EconomyCostMetrics:
|
||||
else:
|
||||
df_cost = df
|
||||
|
||||
# Filtrar por record_status: solo VALID para cálculo de AHT
|
||||
# Excluye NOISE, ZOMBIE, ABANDON
|
||||
# Filter by record_status: only VALID for AHT calculation
|
||||
# Excludes NOISE, ZOMBIE, ABANDON
|
||||
if "_is_valid_for_cost" in df_cost.columns:
|
||||
df_cost = df_cost[df_cost["_is_valid_for_cost"] == True]
|
||||
|
||||
if df_cost.empty:
|
||||
return pd.DataFrame()
|
||||
|
||||
# AHT por skill/canal (en segundos) - solo registros VALID
|
||||
# AHT by skill/channel (in seconds) - only VALID records
|
||||
grouped = df_cost.groupby(["queue_skill", "channel"])["handle_time"].mean()
|
||||
|
||||
if grouped.empty:
|
||||
@@ -193,17 +190,16 @@ class EconomyCostMetrics:
|
||||
return out.sort_index().reset_index()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# KPI 2: coste anual por skill/canal
|
||||
# KPI 2: annual cost by skill/channel
|
||||
# ------------------------------------------------------------------ #
|
||||
def annual_cost_by_skill_channel(self) -> pd.DataFrame:
|
||||
"""
|
||||
Coste anual por skill/canal.
|
||||
Annual cost by skill/channel.
|
||||
|
||||
cost_annual = CPI * volumen (cantidad de interacciones de la muestra).
|
||||
cost_annual = CPI * volume (number of interactions in the sample).
|
||||
|
||||
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.
|
||||
Note: for simplicity we assume the dataset reflects an annual period.
|
||||
If in the future you want to annualize (e.g. dataset = 1 month) you can add a scaling factor in EconomyConfig.
|
||||
"""
|
||||
cpi_table = self.cpi_by_skill_channel()
|
||||
if cpi_table.empty:
|
||||
@@ -224,18 +220,18 @@ class EconomyCostMetrics:
|
||||
return joined
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# KPI 3: desglose de costes (labor / tech / overhead)
|
||||
# KPI 3: cost breakdown (labor / tech / overhead)
|
||||
# ------------------------------------------------------------------ #
|
||||
def cost_breakdown(self) -> Dict[str, float]:
|
||||
"""
|
||||
Desglose % de costes: labor, overhead, tech.
|
||||
Cost breakdown %: 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)
|
||||
tech_total = tech_costs_annual (if provided)
|
||||
|
||||
Devuelve porcentajes sobre el total.
|
||||
Si falta configuración de coste -> devuelve {}.
|
||||
Returns percentages of the total.
|
||||
If cost configuration is missing -> returns {}.
|
||||
"""
|
||||
if not self._has_cost_config():
|
||||
return {}
|
||||
@@ -258,7 +254,7 @@ class EconomyCostMetrics:
|
||||
cpi_indexed = cpi_table.set_index(["queue_skill", "channel"])
|
||||
joined = cpi_indexed.join(volume, how="left").fillna({"volume": 0})
|
||||
|
||||
# Costes anuales de labor y overhead
|
||||
# Annual labor and overhead costs
|
||||
annual_labor = (joined["labor_cost"] * joined["volume"]).sum()
|
||||
annual_overhead = (joined["overhead_cost"] * joined["volume"]).sum()
|
||||
annual_tech = cfg.tech_costs_annual
|
||||
@@ -278,21 +274,21 @@ class EconomyCostMetrics:
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# KPI 4: coste de ineficiencia (€ por variabilidad/escalación)
|
||||
# KPI 4: inefficiency cost (€ by variability/escalation)
|
||||
# ------------------------------------------------------------------ #
|
||||
def inefficiency_cost_by_skill_channel(self) -> pd.DataFrame:
|
||||
"""
|
||||
Estimación muy simplificada de coste de ineficiencia:
|
||||
Very simplified estimate of inefficiency cost:
|
||||
|
||||
Para cada skill/canal:
|
||||
For each skill/channel:
|
||||
|
||||
- AHT_p50, AHT_p90 (segundos).
|
||||
- AHT_p50, AHT_p90 (seconds).
|
||||
- Delta = max(0, AHT_p90 - AHT_p50).
|
||||
- Se asume que ~40% de las interacciones están por encima de la mediana.
|
||||
- Assumes that ~40% of interactions are above the median.
|
||||
- Ineff_seconds = Delta * volume * 0.4
|
||||
- Ineff_cost = LaborCPI_per_second * Ineff_seconds
|
||||
|
||||
NOTA: Es un modelo aproximado para cuantificar "orden de magnitud".
|
||||
NOTE: This is an approximate model to quantify "order of magnitude".
|
||||
"""
|
||||
if not self._has_cost_config():
|
||||
return pd.DataFrame()
|
||||
@@ -302,8 +298,8 @@ class EconomyCostMetrics:
|
||||
|
||||
df = self.df.copy()
|
||||
|
||||
# Filtrar por record_status: solo VALID para cálculo de AHT
|
||||
# Excluye NOISE, ZOMBIE, ABANDON
|
||||
# Filter by record_status: only VALID for AHT calculation
|
||||
# Excludes NOISE, ZOMBIE, ABANDON
|
||||
if "_is_valid_for_cost" in df.columns:
|
||||
df = df[df["_is_valid_for_cost"] == True]
|
||||
|
||||
@@ -318,7 +314,7 @@ class EconomyCostMetrics:
|
||||
if stats.empty:
|
||||
return pd.DataFrame()
|
||||
|
||||
# CPI para obtener coste/segundo de labor
|
||||
# CPI to get cost/second of labor
|
||||
# cpi_by_skill_channel now returns with reset_index, so we need to set index for join
|
||||
cpi_table_raw = self.cpi_by_skill_channel()
|
||||
if cpi_table_raw.empty:
|
||||
@@ -331,11 +327,11 @@ class EconomyCostMetrics:
|
||||
merged = merged.fillna(0.0)
|
||||
|
||||
delta = (merged["aht_p90"] - merged["aht_p50"]).clip(lower=0.0)
|
||||
affected_fraction = 0.4 # aproximación
|
||||
affected_fraction = 0.4 # approximation
|
||||
ineff_seconds = delta * merged["volume"] * affected_fraction
|
||||
|
||||
# labor_cost = coste por interacción con AHT medio;
|
||||
# aproximamos coste/segundo como labor_cost / AHT_medio
|
||||
# labor_cost = cost per interaction with average AHT;
|
||||
# approximate cost/second as labor_cost / average_AHT
|
||||
aht_mean = grouped["handle_time"].mean()
|
||||
merged["aht_mean"] = aht_mean
|
||||
|
||||
@@ -351,21 +347,21 @@ class EconomyCostMetrics:
|
||||
return merged[["aht_p50", "aht_p90", "volume", "ineff_seconds", "ineff_cost"]].reset_index()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# KPI 5: ahorro potencial anual por automatización
|
||||
# KPI 5: potential annual savings from automation
|
||||
# ------------------------------------------------------------------ #
|
||||
def potential_savings(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Ahorro potencial anual basado en:
|
||||
Potential annual savings based on:
|
||||
|
||||
Ahorro = (CPI_humano - CPI_automatizado) * Volumen_automatizable * Tasa_éxito
|
||||
Savings = (Human_CPI - Automated_CPI) * Automatable_volume * Success_rate
|
||||
|
||||
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
|
||||
Where:
|
||||
- Human_CPI = weighted average of cpi_total.
|
||||
- Automated_CPI = config.automation_cpi
|
||||
- Automatable_volume = volume_total * automation_volume_share
|
||||
- Success_rate = automation_success_rate
|
||||
|
||||
Si faltan parámetros en config -> devuelve {}.
|
||||
If config parameters are missing -> returns {}.
|
||||
"""
|
||||
if not self._has_cost_config():
|
||||
return {}
|
||||
@@ -384,7 +380,7 @@ class EconomyCostMetrics:
|
||||
if total_volume <= 0:
|
||||
return {}
|
||||
|
||||
# CPI humano medio ponderado
|
||||
# Weighted average human CPI
|
||||
weighted_cpi = (
|
||||
(cpi_table["cpi_total"] * cpi_table["volume"]).sum() / total_volume
|
||||
)
|
||||
@@ -409,12 +405,12 @@ class EconomyCostMetrics:
|
||||
# ------------------------------------------------------------------ #
|
||||
def plot_cost_waterfall(self) -> Axes:
|
||||
"""
|
||||
Waterfall de costes anuales (labor + tech + overhead).
|
||||
Waterfall of annual costs (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.text(0.5, 0.5, "No cost configuration", ha="center", va="center")
|
||||
ax.set_axis_off()
|
||||
return ax
|
||||
|
||||
@@ -436,14 +432,14 @@ class EconomyCostMetrics:
|
||||
bottoms.append(running)
|
||||
running += v
|
||||
|
||||
# barras estilo waterfall
|
||||
# waterfall style bars
|
||||
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")
|
||||
ax.set_ylabel("€ annual")
|
||||
ax.set_title("Annual cost breakdown")
|
||||
|
||||
for idx, v in enumerate(values):
|
||||
ax.text(idx, v, f"{v:,.0f}", ha="center", va="bottom")
|
||||
@@ -454,12 +450,12 @@ class EconomyCostMetrics:
|
||||
|
||||
def plot_cpi_by_channel(self) -> Axes:
|
||||
"""
|
||||
Gráfico de barras de CPI medio por canal.
|
||||
Bar chart of average CPI by channel.
|
||||
"""
|
||||
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.text(0.5, 0.5, "No cost configuration", ha="center", va="center")
|
||||
ax.set_axis_off()
|
||||
return ax
|
||||
|
||||
@@ -474,7 +470,7 @@ class EconomyCostMetrics:
|
||||
cpi_indexed = cpi_table.set_index(["queue_skill", "channel"])
|
||||
joined = cpi_indexed.join(volume, how="left").fillna({"volume": 0})
|
||||
|
||||
# CPI medio ponderado por canal
|
||||
# Weighted average CPI by channel
|
||||
per_channel = (
|
||||
joined.reset_index()
|
||||
.groupby("channel")
|
||||
@@ -486,9 +482,9 @@ class EconomyCostMetrics:
|
||||
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.set_xlabel("Channel")
|
||||
ax.set_ylabel("Average CPI (€)")
|
||||
ax.set_title("Cost per interaction (CPI) by channel")
|
||||
ax.grid(axis="y", alpha=0.3)
|
||||
|
||||
return ax
|
||||
|
||||
@@ -25,32 +25,31 @@ REQUIRED_COLUMNS_OP: List[str] = [
|
||||
@dataclass
|
||||
class OperationalPerformanceMetrics:
|
||||
"""
|
||||
Dimensión: RENDIMIENTO OPERACIONAL Y DE SERVICIO
|
||||
Dimension: OPERATIONAL PERFORMANCE AND SERVICE
|
||||
|
||||
Propósito: medir el balance entre rapidez (eficiencia) y calidad de resolución,
|
||||
más la variabilidad del servicio.
|
||||
Purpose: measure the balance between speed (efficiency) and resolution quality, plus service variability.
|
||||
|
||||
Requiere como mínimo:
|
||||
Requires at minimum:
|
||||
- interaction_id
|
||||
- datetime_start
|
||||
- queue_skill
|
||||
- channel
|
||||
- duration_talk (segundos)
|
||||
- hold_time (segundos)
|
||||
- wrap_up_time (segundos)
|
||||
- duration_talk (seconds)
|
||||
- hold_time (seconds)
|
||||
- wrap_up_time (seconds)
|
||||
- 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
|
||||
Optional columns:
|
||||
- is_resolved (bool/int) -> for FCR
|
||||
- abandoned_flag (bool/int) -> for abandonment rate
|
||||
- customer_id / caller_id -> for recurrence and channel repetition
|
||||
- logged_time (seconds) -> for occupancy_rate
|
||||
"""
|
||||
|
||||
df: pd.DataFrame
|
||||
|
||||
# Benchmarks / parámetros de normalización (puedes ajustarlos)
|
||||
# Benchmarks / normalization parameters (you can adjust them)
|
||||
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
|
||||
@@ -61,19 +60,19 @@ class OperationalPerformanceMetrics:
|
||||
self._prepare_data()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Helpers internos
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------ #
|
||||
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}"
|
||||
f"Missing required columns for OperationalPerformanceMetrics: {missing}"
|
||||
)
|
||||
|
||||
def _prepare_data(self) -> None:
|
||||
df = self.df.copy()
|
||||
|
||||
# Tipos
|
||||
# Types
|
||||
df["datetime_start"] = pd.to_datetime(df["datetime_start"], errors="coerce")
|
||||
|
||||
for col in ["duration_talk", "hold_time", "wrap_up_time"]:
|
||||
@@ -86,13 +85,13 @@ class OperationalPerformanceMetrics:
|
||||
+ df["wrap_up_time"].fillna(0)
|
||||
)
|
||||
|
||||
# v3.0: Filtrar NOISE y ZOMBIE para cálculos de variabilidad
|
||||
# v3.0: Filter NOISE and ZOMBIE for variability calculations
|
||||
# record_status: 'VALID', 'NOISE', 'ZOMBIE', 'ABANDON'
|
||||
# Para AHT/CV solo usamos 'VALID' (excluye noise, zombie, abandon)
|
||||
# For AHT/CV we only use 'VALID' (excludes noise, zombie, abandon)
|
||||
if "record_status" in df.columns:
|
||||
df["record_status"] = df["record_status"].astype(str).str.strip().str.upper()
|
||||
# Crear máscara para registros válidos: SOLO "VALID"
|
||||
# Excluye explícitamente NOISE, ZOMBIE, ABANDON y cualquier otro valor
|
||||
# Create mask for valid records: ONLY "VALID"
|
||||
# Explicitly excludes NOISE, ZOMBIE, ABANDON and any other value
|
||||
df["_is_valid_for_cv"] = df["record_status"] == "VALID"
|
||||
|
||||
# Log record_status breakdown for debugging
|
||||
@@ -104,21 +103,21 @@ class OperationalPerformanceMetrics:
|
||||
print(f" - {status}: {count}")
|
||||
print(f" VALID rows for AHT calculation: {valid_count}")
|
||||
else:
|
||||
# Legacy data sin record_status: incluir todo
|
||||
# Legacy data without record_status: include all
|
||||
df["_is_valid_for_cv"] = True
|
||||
print(f"[OperationalPerformance] No record_status column - using all {len(df)} rows")
|
||||
|
||||
# Normalización básica
|
||||
# Basic normalization
|
||||
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
|
||||
# Optional flags converted to bool when they exist
|
||||
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
|
||||
# customer_id: we use customer_id if it exists, otherwise caller_id
|
||||
if "customer_id" in df.columns:
|
||||
df["customer_id"] = df["customer_id"].astype(str)
|
||||
elif "caller_id" in df.columns:
|
||||
@@ -126,8 +125,8 @@ class OperationalPerformanceMetrics:
|
||||
else:
|
||||
df["customer_id"] = None
|
||||
|
||||
# logged_time opcional
|
||||
# Normalizamos logged_time: siempre será una serie float con NaN si no existe
|
||||
# logged_time optional
|
||||
# Normalize logged_time: will always be a float series with NaN if it does not exist
|
||||
df["logged_time"] = pd.to_numeric(df.get("logged_time", np.nan), errors="coerce")
|
||||
|
||||
|
||||
@@ -138,16 +137,16 @@ class OperationalPerformanceMetrics:
|
||||
return self.df.empty
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# AHT y variabilidad
|
||||
# AHT and variability
|
||||
# ------------------------------------------------------------------ #
|
||||
def aht_distribution(self) -> Dict[str, float]:
|
||||
"""
|
||||
Devuelve P10, P50, P90 del AHT y el ratio P90/P50 como medida de variabilidad.
|
||||
Returns P10, P50, P90 of AHT and the P90/P50 ratio as a measure of variability.
|
||||
|
||||
v3.0: Filtra NOISE y ZOMBIE para el cálculo de variabilidad.
|
||||
Solo usa registros con record_status='valid' o sin status (legacy).
|
||||
v3.0: Filters NOISE and ZOMBIE for variability calculation.
|
||||
Only uses records with record_status='valid' or without status (legacy).
|
||||
"""
|
||||
# Filtrar solo registros válidos para cálculo de variabilidad
|
||||
# Filter only valid records for variability calculation
|
||||
df_valid = self.df[self.df["_is_valid_for_cv"] == True]
|
||||
ht = df_valid["handle_time"].dropna().astype(float)
|
||||
if ht.empty:
|
||||
@@ -167,10 +166,9 @@ class OperationalPerformanceMetrics:
|
||||
|
||||
def talk_hold_acw_p50_by_skill(self) -> pd.DataFrame:
|
||||
"""
|
||||
P50 de talk_time, hold_time y wrap_up_time por skill.
|
||||
P50 of talk_time, hold_time and wrap_up_time by skill.
|
||||
|
||||
Incluye queue_skill como columna (no solo índice) para que
|
||||
el frontend pueda hacer lookup por nombre de skill.
|
||||
Includes queue_skill as a column (not just index) so that the frontend can lookup by skill name.
|
||||
"""
|
||||
df = self.df
|
||||
|
||||
@@ -192,24 +190,24 @@ class OperationalPerformanceMetrics:
|
||||
return result.round(2).sort_index().reset_index()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# FCR, escalación, abandono, reincidencia, repetición canal
|
||||
# FCR, escalation, abandonment, recurrence, channel repetition
|
||||
# ------------------------------------------------------------------ #
|
||||
def fcr_rate(self) -> float:
|
||||
"""
|
||||
FCR (First Contact Resolution).
|
||||
|
||||
Prioridad 1: Usar fcr_real_flag del CSV si existe
|
||||
Prioridad 2: Calcular como 100 - escalation_rate
|
||||
Priority 1: Use fcr_real_flag from CSV if it exists
|
||||
Priority 2: Calculate as 100 - escalation_rate
|
||||
"""
|
||||
df = self.df
|
||||
total = len(df)
|
||||
if total == 0:
|
||||
return float("nan")
|
||||
|
||||
# Prioridad 1: Usar fcr_real_flag si existe
|
||||
# Priority 1: Use fcr_real_flag if it exists
|
||||
if "fcr_real_flag" in df.columns:
|
||||
col = df["fcr_real_flag"]
|
||||
# Normalizar a booleano
|
||||
# Normalize to boolean
|
||||
if col.dtype == "O":
|
||||
fcr_mask = (
|
||||
col.astype(str)
|
||||
@@ -224,7 +222,7 @@ class OperationalPerformanceMetrics:
|
||||
fcr = (fcr_count / total) * 100.0
|
||||
return float(max(0.0, min(100.0, round(fcr, 2))))
|
||||
|
||||
# Prioridad 2: Fallback a 100 - escalation_rate
|
||||
# Priority 2: Fallback to 100 - escalation_rate
|
||||
try:
|
||||
esc = self.escalation_rate()
|
||||
except Exception:
|
||||
@@ -239,7 +237,7 @@ class OperationalPerformanceMetrics:
|
||||
|
||||
def escalation_rate(self) -> float:
|
||||
"""
|
||||
% de interacciones que requieren escalación (transfer_flag == True).
|
||||
% of interactions that require escalation (transfer_flag == True).
|
||||
"""
|
||||
df = self.df
|
||||
total = len(df)
|
||||
@@ -251,17 +249,17 @@ class OperationalPerformanceMetrics:
|
||||
|
||||
def abandonment_rate(self) -> float:
|
||||
"""
|
||||
% de interacciones abandonadas.
|
||||
% of abandoned interactions.
|
||||
|
||||
Busca en orden: is_abandoned, abandoned_flag, abandoned
|
||||
Si ninguna columna existe, devuelve NaN.
|
||||
Searches in order: is_abandoned, abandoned_flag, abandoned
|
||||
If no column exists, returns NaN.
|
||||
"""
|
||||
df = self.df
|
||||
total = len(df)
|
||||
if total == 0:
|
||||
return float("nan")
|
||||
|
||||
# Buscar columna de abandono en orden de prioridad
|
||||
# Search for abandonment column in priority order
|
||||
abandon_col = None
|
||||
for col_name in ["is_abandoned", "abandoned_flag", "abandoned"]:
|
||||
if col_name in df.columns:
|
||||
@@ -273,7 +271,7 @@ class OperationalPerformanceMetrics:
|
||||
|
||||
col = df[abandon_col]
|
||||
|
||||
# Normalizar a booleano
|
||||
# Normalize to boolean
|
||||
if col.dtype == "O":
|
||||
abandon_mask = (
|
||||
col.astype(str)
|
||||
@@ -289,10 +287,9 @@ class OperationalPerformanceMetrics:
|
||||
|
||||
def high_hold_time_rate(self, threshold_seconds: float = 60.0) -> float:
|
||||
"""
|
||||
% de interacciones con hold_time > threshold (por defecto 60s).
|
||||
% of interactions with hold_time > threshold (default 60s).
|
||||
|
||||
Proxy de complejidad: si el agente tuvo que poner en espera al cliente
|
||||
más de 60 segundos, probablemente tuvo que consultar/investigar.
|
||||
Complexity proxy: if the agent had to put the customer on hold for more than 60 seconds, they probably had to consult/investigate.
|
||||
"""
|
||||
df = self.df
|
||||
total = len(df)
|
||||
@@ -306,44 +303,43 @@ class OperationalPerformanceMetrics:
|
||||
|
||||
def recurrence_rate_7d(self) -> float:
|
||||
"""
|
||||
% de clientes que vuelven a contactar en < 7 días para el MISMO skill.
|
||||
% of customers who contact again in < 7 days for the SAME skill.
|
||||
|
||||
Se basa en customer_id (o caller_id si no hay customer_id) + queue_skill.
|
||||
Calcula:
|
||||
- Para cada combinación cliente + skill, ordena por datetime_start
|
||||
- Si hay dos contactos consecutivos separados < 7 días (mismo cliente, mismo skill),
|
||||
cuenta como "recurrente"
|
||||
- Tasa = nº clientes recurrentes / nº total de clientes
|
||||
Based on customer_id (or caller_id if no customer_id) + queue_skill.
|
||||
Calculates:
|
||||
- For each client + skill combination, sorts by datetime_start
|
||||
- If there are two consecutive contacts separated by < 7 days (same client, same skill), counts as "recurrent"
|
||||
- Rate = number of recurrent clients / total number of clients
|
||||
|
||||
NOTA: Solo cuenta como recurrencia si el cliente llama por el MISMO skill.
|
||||
Un cliente que llama a "Ventas" y luego a "Soporte" NO es recurrente.
|
||||
NOTE: Only counts as recurrence if the client calls for the SAME skill.
|
||||
A client who calls "Sales" and then "Support" is NOT recurrent.
|
||||
"""
|
||||
|
||||
df = self.df.dropna(subset=["datetime_start"]).copy()
|
||||
|
||||
# Normalizar identificador de cliente
|
||||
# Normalize client identifier
|
||||
if "customer_id" not in df.columns:
|
||||
if "caller_id" in df.columns:
|
||||
df["customer_id"] = df["caller_id"]
|
||||
else:
|
||||
# No hay identificador de cliente -> no se puede calcular
|
||||
# No client identifier -> cannot calculate
|
||||
return float("nan")
|
||||
|
||||
df = df.dropna(subset=["customer_id"])
|
||||
if df.empty:
|
||||
return float("nan")
|
||||
|
||||
# Ordenar por cliente + skill + fecha
|
||||
# Sort by client + skill + date
|
||||
df = df.sort_values(["customer_id", "queue_skill", "datetime_start"])
|
||||
|
||||
# Diferencia de tiempo entre contactos consecutivos por cliente Y skill
|
||||
# Esto asegura que solo contamos recontactos del mismo cliente para el mismo skill
|
||||
# Time difference between consecutive contacts by client AND skill
|
||||
# This ensures we only count re-contacts from the same client for the same skill
|
||||
df["delta"] = df.groupby(["customer_id", "queue_skill"])["datetime_start"].diff()
|
||||
|
||||
# Marcamos los contactos que ocurren a menos de 7 días del anterior (mismo skill)
|
||||
# Mark contacts that occur less than 7 days from the previous one (same skill)
|
||||
recurrence_mask = df["delta"] < pd.Timedelta(days=7)
|
||||
|
||||
# Nº de clientes que tienen al menos un contacto recurrente (para cualquier skill)
|
||||
# Number of clients who have at least one recurrent contact (for any skill)
|
||||
recurrent_customers = df.loc[recurrence_mask, "customer_id"].nunique()
|
||||
total_customers = df["customer_id"].nunique()
|
||||
|
||||
@@ -356,9 +352,9 @@ class OperationalPerformanceMetrics:
|
||||
|
||||
def repeat_channel_rate(self) -> float:
|
||||
"""
|
||||
% de reincidencias (<7 días) en las que el cliente usa el MISMO canal.
|
||||
% of recurrences (<7 days) in which the client uses the SAME channel.
|
||||
|
||||
Si no hay customer_id/caller_id o solo un contacto por cliente, devuelve NaN.
|
||||
If there is no customer_id/caller_id or only one contact per client, returns NaN.
|
||||
"""
|
||||
df = self.df.dropna(subset=["datetime_start"]).copy()
|
||||
if df["customer_id"].isna().all():
|
||||
@@ -387,11 +383,11 @@ class OperationalPerformanceMetrics:
|
||||
# ------------------------------------------------------------------ #
|
||||
def occupancy_rate(self) -> float:
|
||||
"""
|
||||
Tasa de ocupación:
|
||||
Occupancy rate:
|
||||
|
||||
occupancy = sum(handle_time) / sum(logged_time) * 100.
|
||||
|
||||
Requiere columna 'logged_time'. Si no existe o es todo 0, devuelve NaN.
|
||||
Requires 'logged_time' column. If it does not exist or is all 0, returns NaN.
|
||||
"""
|
||||
df = self.df
|
||||
if "logged_time" not in df.columns:
|
||||
@@ -408,23 +404,23 @@ class OperationalPerformanceMetrics:
|
||||
return float(round(occ * 100, 2))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Score de rendimiento 0-10
|
||||
# Performance score 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)
|
||||
Calculates a 0-10 score combining:
|
||||
- AHT (lower is better)
|
||||
- FCR (higher is better)
|
||||
- Variability (P90/P50, lower is better)
|
||||
- Other factors (occupancy / escalation)
|
||||
|
||||
Fórmula:
|
||||
Formula:
|
||||
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.
|
||||
Where *_norm are values on a 0-10 scale.
|
||||
"""
|
||||
dist = self.aht_distribution()
|
||||
if not dist:
|
||||
@@ -433,15 +429,15 @@ class OperationalPerformanceMetrics:
|
||||
p50 = dist["p50"]
|
||||
ratio = dist["p90_p50_ratio"]
|
||||
|
||||
# AHT_normalized: 0 (mejor) a 10 (peor)
|
||||
# AHT_normalized: 0 (better) to 10 (worse)
|
||||
aht_norm = self._scale_to_0_10(p50, self.AHT_GOOD, self.AHT_BAD)
|
||||
# FCR_normalized: 0-10 directamente desde % (0-100)
|
||||
# FCR_normalized: 0-10 directly from % (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)
|
||||
# Variability_normalized: 0 (good ratio) to 10 (bad ratio)
|
||||
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)
|
||||
# Other factors: combine occupancy (ideal ~80%) and escalation (ideal low)
|
||||
occ = self.occupancy_rate()
|
||||
esc = self.escalation_rate()
|
||||
|
||||
@@ -467,26 +463,26 @@ class OperationalPerformanceMetrics:
|
||||
|
||||
def _scale_to_0_10(self, value: float, good: float, bad: float) -> float:
|
||||
"""
|
||||
Escala linealmente un valor:
|
||||
Linearly scales a value:
|
||||
- good -> 0
|
||||
- bad -> 10
|
||||
Con saturación fuera de rango.
|
||||
With saturation outside range.
|
||||
"""
|
||||
if np.isnan(value):
|
||||
return 5.0 # neutro
|
||||
return 5.0 # neutral
|
||||
|
||||
if good == bad:
|
||||
return 5.0
|
||||
|
||||
if good < bad:
|
||||
# Menor es mejor
|
||||
# Lower is better
|
||||
if value <= good:
|
||||
return 0.0
|
||||
if value >= bad:
|
||||
return 10.0
|
||||
return 10.0 * (value - good) / (bad - good)
|
||||
else:
|
||||
# Mayor es mejor
|
||||
# Higher is better
|
||||
if value >= good:
|
||||
return 0.0
|
||||
if value <= bad:
|
||||
@@ -495,19 +491,19 @@ class OperationalPerformanceMetrics:
|
||||
|
||||
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%)
|
||||
Other factors (0-10) based on:
|
||||
- ideal occupancy around 80%
|
||||
- ideal escalation rate low (<10%)
|
||||
"""
|
||||
# Ocupación: 0 penalización si está entre 75-85, se penaliza fuera
|
||||
# Occupancy: 0 penalty if between 75-85, penalized outside
|
||||
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_penalty = min(10.0, deviation / 5.0 * 2.0) # each 5 points add 2, max 10
|
||||
occ_score = max(0.0, 10.0 - occ_penalty)
|
||||
|
||||
# Escalación: 0-10 donde 0% -> 10 puntos, >=40% -> 0
|
||||
# Escalation: 0-10 where 0% -> 10 points, >=40% -> 0
|
||||
if np.isnan(esc_pct):
|
||||
esc_score = 5.0
|
||||
else:
|
||||
@@ -518,7 +514,7 @@ class OperationalPerformanceMetrics:
|
||||
else:
|
||||
esc_score = 10.0 * (1.0 - esc_pct / 40.0)
|
||||
|
||||
# Media simple de ambos
|
||||
# Simple average of both
|
||||
return (occ_score + esc_score) / 2.0
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -526,29 +522,29 @@ class OperationalPerformanceMetrics:
|
||||
# ------------------------------------------------------------------ #
|
||||
def plot_aht_boxplot_by_skill(self) -> Axes:
|
||||
"""
|
||||
Boxplot del AHT por skill (P10-P50-P90 visual).
|
||||
Boxplot of AHT by 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.text(0.5, 0.5, "No AHT data", 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.text(0.5, 0.5, "AHT not available", 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")
|
||||
ax.set_xlabel("Skill / Queue")
|
||||
ax.set_ylabel("AHT (seconds)")
|
||||
ax.set_title("AHT distribution by skill")
|
||||
plt.suptitle("")
|
||||
plt.xticks(rotation=45, ha="right")
|
||||
ax.grid(axis="y", alpha=0.3)
|
||||
@@ -557,14 +553,14 @@ class OperationalPerformanceMetrics:
|
||||
|
||||
def plot_resolution_funnel_by_skill(self) -> Axes:
|
||||
"""
|
||||
Funnel / barras apiladas de Talk + Hold + ACW por skill (P50).
|
||||
Funnel / stacked bars of Talk + Hold + ACW by skill (P50).
|
||||
|
||||
Permite ver el equilibrio de tiempos por skill.
|
||||
Allows viewing the time balance by 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.text(0.5, 0.5, "No data for funnel", ha="center", va="center")
|
||||
ax.set_axis_off()
|
||||
return ax
|
||||
|
||||
@@ -583,27 +579,26 @@ class OperationalPerformanceMetrics:
|
||||
|
||||
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.set_ylabel("Seconds")
|
||||
ax.set_title("Resolution funnel (P50) by skill")
|
||||
ax.legend()
|
||||
ax.grid(axis="y", alpha=0.3)
|
||||
|
||||
return ax
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Métricas por skill (para consistencia frontend cached/fresh)
|
||||
# Metrics by skill (for frontend cached/fresh consistency)
|
||||
# ------------------------------------------------------------------ #
|
||||
def metrics_by_skill(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Calcula métricas operacionales por skill:
|
||||
- transfer_rate: % de interacciones con transfer_flag == True
|
||||
- abandonment_rate: % de interacciones abandonadas
|
||||
- fcr_tecnico: 100 - transfer_rate (sin transferencia)
|
||||
- fcr_real: % sin transferencia Y sin recontacto 7d (si hay datos)
|
||||
- volume: número de interacciones
|
||||
Calculates operational metrics by skill:
|
||||
- transfer_rate: % of interactions with transfer_flag == True
|
||||
- abandonment_rate: % of abandoned interactions
|
||||
- fcr_tecnico: 100 - transfer_rate (without transfer)
|
||||
- fcr_real: % without transfer AND without 7d re-contact (if there is data)
|
||||
- volume: number of interactions
|
||||
|
||||
Devuelve una lista de dicts, uno por skill, para que el frontend
|
||||
tenga acceso a las métricas reales por skill (no estimadas).
|
||||
Returns a list of dicts, one per skill, so that the frontend has access to real metrics by skill (not estimated).
|
||||
"""
|
||||
df = self.df
|
||||
if df.empty:
|
||||
@@ -611,14 +606,14 @@ class OperationalPerformanceMetrics:
|
||||
|
||||
results = []
|
||||
|
||||
# Detectar columna de abandono
|
||||
# Detect abandonment column
|
||||
abandon_col = None
|
||||
for col_name in ["is_abandoned", "abandoned_flag", "abandoned"]:
|
||||
if col_name in df.columns:
|
||||
abandon_col = col_name
|
||||
break
|
||||
|
||||
# Detectar columna de repeat_call_7d para FCR real
|
||||
# Detect repeat_call_7d column for real FCR
|
||||
repeat_col = None
|
||||
for col_name in ["repeat_call_7d", "repeat_7d", "is_repeat_7d"]:
|
||||
if col_name in df.columns:
|
||||
@@ -637,7 +632,7 @@ class OperationalPerformanceMetrics:
|
||||
else:
|
||||
transfer_rate = 0.0
|
||||
|
||||
# FCR Técnico = 100 - transfer_rate
|
||||
# Technical FCR = 100 - transfer_rate
|
||||
fcr_tecnico = float(round(100.0 - transfer_rate, 2))
|
||||
|
||||
# Abandonment rate
|
||||
@@ -656,7 +651,7 @@ class OperationalPerformanceMetrics:
|
||||
abandoned = int(abandon_mask.sum())
|
||||
abandonment_rate = float(round(abandoned / total * 100, 2))
|
||||
|
||||
# FCR Real (sin transferencia Y sin recontacto 7d)
|
||||
# Real FCR (without transfer AND without 7d re-contact)
|
||||
fcr_real = fcr_tecnico # default to fcr_tecnico if no repeat data
|
||||
if repeat_col and "transfer_flag" in group.columns:
|
||||
repeat_data = group[repeat_col]
|
||||
@@ -670,13 +665,13 @@ class OperationalPerformanceMetrics:
|
||||
else:
|
||||
repeat_mask = pd.to_numeric(repeat_data, errors="coerce").fillna(0) > 0
|
||||
|
||||
# FCR Real: no transfer AND no repeat
|
||||
# Real FCR: no transfer AND no repeat
|
||||
fcr_real_mask = (~group["transfer_flag"]) & (~repeat_mask)
|
||||
fcr_real_count = fcr_real_mask.sum()
|
||||
fcr_real = float(round(fcr_real_count / total * 100, 2))
|
||||
|
||||
# AHT Mean (promedio de handle_time sobre registros válidos)
|
||||
# Filtramos solo registros 'valid' (excluye noise/zombie) para consistencia
|
||||
# AHT Mean (average of handle_time over valid records)
|
||||
# Filter only 'valid' records (excludes noise/zombie) for consistency
|
||||
if "_is_valid_for_cv" in group.columns:
|
||||
valid_records = group[group["_is_valid_for_cv"]]
|
||||
else:
|
||||
@@ -687,15 +682,15 @@ class OperationalPerformanceMetrics:
|
||||
else:
|
||||
aht_mean = 0.0
|
||||
|
||||
# AHT Total (promedio de handle_time sobre TODOS los registros)
|
||||
# Incluye NOISE, ZOMBIE, ABANDON - solo para información/comparación
|
||||
# AHT Total (average of handle_time over ALL records)
|
||||
# Includes NOISE, ZOMBIE, ABANDON - for information/comparison only
|
||||
if len(group) > 0 and "handle_time" in group.columns:
|
||||
aht_total = float(round(group["handle_time"].mean(), 2))
|
||||
else:
|
||||
aht_total = 0.0
|
||||
|
||||
# Hold Time Mean (promedio de hold_time sobre registros válidos)
|
||||
# Consistente con fresh path que usa MEAN, no P50
|
||||
# Hold Time Mean (average of hold_time over valid records)
|
||||
# Consistent with fresh path that uses MEAN, not P50
|
||||
if len(valid_records) > 0 and "hold_time" in valid_records.columns:
|
||||
hold_time_mean = float(round(valid_records["hold_time"].mean(), 2))
|
||||
else:
|
||||
|
||||
@@ -24,11 +24,10 @@ REQUIRED_COLUMNS_SAT: List[str] = [
|
||||
@dataclass
|
||||
class SatisfactionExperienceMetrics:
|
||||
"""
|
||||
Dimensión 3: SATISFACCIÓN y EXPERIENCIA
|
||||
Dimension 3: SATISFACTION and EXPERIENCE
|
||||
|
||||
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.
|
||||
All satisfaction columns (csat/nps/ces/aht) are OPTIONAL.
|
||||
If they are not present, the metrics that use them return empty/NaN but never break the pipeline.
|
||||
"""
|
||||
|
||||
df: pd.DataFrame
|
||||
@@ -44,7 +43,7 @@ class SatisfactionExperienceMetrics:
|
||||
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}"
|
||||
f"Missing required columns for SatisfactionExperienceMetrics: {missing}"
|
||||
)
|
||||
|
||||
def _prepare_data(self) -> None:
|
||||
@@ -52,7 +51,7 @@ class SatisfactionExperienceMetrics:
|
||||
|
||||
df["datetime_start"] = pd.to_datetime(df["datetime_start"], errors="coerce")
|
||||
|
||||
# Duraciones base siempre existen
|
||||
# Base durations always exist
|
||||
for col in ["duration_talk", "hold_time", "wrap_up_time"]:
|
||||
df[col] = pd.to_numeric(df[col], errors="coerce")
|
||||
|
||||
@@ -63,16 +62,16 @@ class SatisfactionExperienceMetrics:
|
||||
+ df["wrap_up_time"].fillna(0)
|
||||
)
|
||||
|
||||
# csat_score opcional
|
||||
# csat_score optional
|
||||
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
|
||||
# aht optional: if explicit column exists we use it, otherwise we use 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
|
||||
# NPS / CES optional
|
||||
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")
|
||||
|
||||
@@ -90,8 +89,8 @@ class SatisfactionExperienceMetrics:
|
||||
# ------------------------------------------------------------------ #
|
||||
def csat_avg_by_skill_channel(self) -> pd.DataFrame:
|
||||
"""
|
||||
CSAT promedio por skill/canal.
|
||||
Si no hay csat_score, devuelve DataFrame vacío.
|
||||
Average CSAT by skill/channel.
|
||||
If there is no csat_score, returns empty DataFrame.
|
||||
"""
|
||||
df = self.df
|
||||
if "csat_score" not in df.columns or df["csat_score"].notna().sum() == 0:
|
||||
@@ -115,7 +114,7 @@ class SatisfactionExperienceMetrics:
|
||||
|
||||
def nps_avg_by_skill_channel(self) -> pd.DataFrame:
|
||||
"""
|
||||
NPS medio por skill/canal, si existe nps_score.
|
||||
Average NPS by skill/channel, if nps_score exists.
|
||||
"""
|
||||
df = self.df
|
||||
if "nps_score" not in df.columns or df["nps_score"].notna().sum() == 0:
|
||||
@@ -139,7 +138,7 @@ class SatisfactionExperienceMetrics:
|
||||
|
||||
def ces_avg_by_skill_channel(self) -> pd.DataFrame:
|
||||
"""
|
||||
CES medio por skill/canal, si existe ces_score.
|
||||
Average CES by skill/channel, if ces_score exists.
|
||||
"""
|
||||
df = self.df
|
||||
if "ces_score" not in df.columns or df["ces_score"].notna().sum() == 0:
|
||||
@@ -163,11 +162,11 @@ class SatisfactionExperienceMetrics:
|
||||
|
||||
def csat_global(self) -> float:
|
||||
"""
|
||||
CSAT medio global (todas las interacciones).
|
||||
Global average CSAT (all interactions).
|
||||
|
||||
Usa la columna opcional `csat_score`:
|
||||
- Si no existe, devuelve NaN.
|
||||
- Si todos los valores son NaN / vacíos, devuelve NaN.
|
||||
Uses the optional `csat_score` column:
|
||||
- If it does not exist, returns NaN.
|
||||
- If all values are NaN / empty, returns NaN.
|
||||
"""
|
||||
df = self.df
|
||||
if "csat_score" not in df.columns:
|
||||
@@ -183,8 +182,8 @@ class SatisfactionExperienceMetrics:
|
||||
|
||||
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.
|
||||
Pearson correlation CSAT vs AHT.
|
||||
If csat or aht is missing, or there is no variance, returns NaN and appropriate code.
|
||||
"""
|
||||
df = self.df
|
||||
if "csat_score" not in df.columns or df["csat_score"].notna().sum() == 0:
|
||||
@@ -216,8 +215,8 @@ class SatisfactionExperienceMetrics:
|
||||
|
||||
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.
|
||||
Summary by skill with "sweet spot" classification.
|
||||
If csat or aht is missing, returns empty DataFrame.
|
||||
"""
|
||||
df = self.df
|
||||
if df["csat_score"].notna().sum() == 0 or df["aht"].notna().sum() == 0:
|
||||
@@ -258,20 +257,20 @@ class SatisfactionExperienceMetrics:
|
||||
# ------------------------------------------------------------------ #
|
||||
def plot_csat_vs_aht_scatter(self) -> Axes:
|
||||
"""
|
||||
Scatter CSAT vs AHT por skill.
|
||||
Si no hay datos suficientes, devuelve un Axes con mensaje.
|
||||
Scatter CSAT vs AHT by skill.
|
||||
If there is insufficient data, returns an Axes with message.
|
||||
"""
|
||||
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.text(0.5, 0.5, "No CSAT/AHT data", 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.text(0.5, 0.5, "No CSAT/AHT data", ha="center", va="center")
|
||||
ax.set_axis_off()
|
||||
return ax
|
||||
|
||||
@@ -280,9 +279,9 @@ class SatisfactionExperienceMetrics:
|
||||
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_xlabel("AHT (seconds)")
|
||||
ax.set_ylabel("CSAT")
|
||||
ax.set_title("CSAT vs AHT por skill")
|
||||
ax.set_title("CSAT vs AHT by skill")
|
||||
ax.grid(alpha=0.3)
|
||||
ax.legend(title="Skill", bbox_to_anchor=(1.05, 1), loc="upper left")
|
||||
|
||||
@@ -291,28 +290,28 @@ class SatisfactionExperienceMetrics:
|
||||
|
||||
def plot_csat_distribution(self) -> Axes:
|
||||
"""
|
||||
Histograma de CSAT.
|
||||
Si no hay csat_score, devuelve un Axes con mensaje.
|
||||
CSAT histogram.
|
||||
If there is no csat_score, returns an Axes with message.
|
||||
"""
|
||||
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.text(0.5, 0.5, "No CSAT data", 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.text(0.5, 0.5, "No CSAT data", 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.set_ylabel("Frequency")
|
||||
ax.set_title("CSAT distribution")
|
||||
ax.grid(axis="y", alpha=0.3)
|
||||
|
||||
return ax
|
||||
|
||||
@@ -20,15 +20,15 @@ REQUIRED_COLUMNS_VOLUMETRIA: List[str] = [
|
||||
@dataclass
|
||||
class VolumetriaMetrics:
|
||||
"""
|
||||
Métricas de volumetría basadas en el nuevo esquema de datos.
|
||||
Volumetry metrics based on the new data schema.
|
||||
|
||||
Columnas mínimas requeridas:
|
||||
Minimum required columns:
|
||||
- interaction_id
|
||||
- datetime_start
|
||||
- queue_skill
|
||||
- channel
|
||||
|
||||
Otras columnas pueden existir pero no son necesarias para estas métricas.
|
||||
Other columns may exist but are not required for these metrics.
|
||||
"""
|
||||
|
||||
df: pd.DataFrame
|
||||
@@ -38,41 +38,41 @@ class VolumetriaMetrics:
|
||||
self._prepare_data()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Helpers internos
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------ #
|
||||
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}"
|
||||
f"Missing required columns for VolumetriaMetrics: {missing}"
|
||||
)
|
||||
|
||||
def _prepare_data(self) -> None:
|
||||
df = self.df.copy()
|
||||
|
||||
# Asegurar tipo datetime
|
||||
# Ensure datetime type
|
||||
df["datetime_start"] = pd.to_datetime(df["datetime_start"], errors="coerce")
|
||||
|
||||
# Normalizar strings
|
||||
# Normalize strings
|
||||
df["queue_skill"] = df["queue_skill"].astype(str).str.strip()
|
||||
df["channel"] = df["channel"].astype(str).str.strip()
|
||||
|
||||
# Guardamos el df preparado
|
||||
# Store the prepared dataframe
|
||||
self.df = df
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Propiedades útiles
|
||||
# Useful properties
|
||||
# ------------------------------------------------------------------ #
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
return self.df.empty
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Métricas numéricas / tabulares
|
||||
# Numeric / tabular metrics
|
||||
# ------------------------------------------------------------------ #
|
||||
def volume_by_channel(self) -> pd.Series:
|
||||
"""
|
||||
Nº de interacciones por canal.
|
||||
Number of interactions by channel.
|
||||
"""
|
||||
return self.df.groupby("channel")["interaction_id"].nunique().sort_values(
|
||||
ascending=False
|
||||
@@ -80,7 +80,7 @@ class VolumetriaMetrics:
|
||||
|
||||
def volume_by_skill(self) -> pd.Series:
|
||||
"""
|
||||
Nº de interacciones por skill / cola.
|
||||
Number of interactions by skill / queue.
|
||||
"""
|
||||
return self.df.groupby("queue_skill")["interaction_id"].nunique().sort_values(
|
||||
ascending=False
|
||||
@@ -88,7 +88,7 @@ class VolumetriaMetrics:
|
||||
|
||||
def channel_distribution_pct(self) -> pd.Series:
|
||||
"""
|
||||
Distribución porcentual del volumen por canal.
|
||||
Percentage distribution of volume by channel.
|
||||
"""
|
||||
counts = self.volume_by_channel()
|
||||
total = counts.sum()
|
||||
@@ -98,7 +98,7 @@ class VolumetriaMetrics:
|
||||
|
||||
def skill_distribution_pct(self) -> pd.Series:
|
||||
"""
|
||||
Distribución porcentual del volumen por skill.
|
||||
Percentage distribution of volume by skill.
|
||||
"""
|
||||
counts = self.volume_by_skill()
|
||||
total = counts.sum()
|
||||
@@ -108,12 +108,12 @@ class VolumetriaMetrics:
|
||||
|
||||
def heatmap_24x7(self) -> pd.DataFrame:
|
||||
"""
|
||||
Matriz [día_semana x hora] con nº de interacciones.
|
||||
dayofweek: 0=Lunes ... 6=Domingo
|
||||
Matrix [day_of_week x hour] with number of interactions.
|
||||
dayofweek: 0=Monday ... 6=Sunday
|
||||
"""
|
||||
df = self.df.dropna(subset=["datetime_start"]).copy()
|
||||
if df.empty:
|
||||
# Devolvemos un df vacío pero con índice/columnas esperadas
|
||||
# Return an empty dataframe with expected index/columns
|
||||
idx = range(7)
|
||||
cols = range(24)
|
||||
return pd.DataFrame(0, index=idx, columns=cols)
|
||||
@@ -137,8 +137,8 @@ class VolumetriaMetrics:
|
||||
|
||||
def monthly_seasonality_cv(self) -> float:
|
||||
"""
|
||||
Coeficiente de variación del volumen mensual.
|
||||
CV = std / mean (en %).
|
||||
Coefficient of variation of monthly volume.
|
||||
CV = std / mean (in %).
|
||||
"""
|
||||
df = self.df.dropna(subset=["datetime_start"]).copy()
|
||||
if df.empty:
|
||||
@@ -161,9 +161,9 @@ class VolumetriaMetrics:
|
||||
|
||||
def peak_offpeak_ratio(self) -> float:
|
||||
"""
|
||||
Ratio de volumen entre horas pico y valle.
|
||||
Volume ratio between peak and off-peak hours.
|
||||
|
||||
Definimos pico como horas 10:00–19:59, resto valle.
|
||||
We define peak as hours 10:00–19:59, rest as off-peak.
|
||||
"""
|
||||
df = self.df.dropna(subset=["datetime_start"]).copy()
|
||||
if df.empty:
|
||||
@@ -184,7 +184,7 @@ class VolumetriaMetrics:
|
||||
|
||||
def concentration_top20_skills_pct(self) -> float:
|
||||
"""
|
||||
% del volumen concentrado en el top 20% de skills (por nº de interacciones).
|
||||
% of volume concentrated in the top 20% of skills (by number of interactions).
|
||||
"""
|
||||
counts = (
|
||||
self.df.groupby("queue_skill")["interaction_id"].nunique().sort_values(
|
||||
@@ -210,8 +210,8 @@ class VolumetriaMetrics:
|
||||
# ------------------------------------------------------------------ #
|
||||
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.
|
||||
Heatmap of volume by day of week (0-6) and hour (0-23).
|
||||
Returns Axes so the pipeline can save the figure.
|
||||
"""
|
||||
data = self.heatmap_24x7()
|
||||
|
||||
@@ -222,45 +222,45 @@ class VolumetriaMetrics:
|
||||
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_yticklabels(["M", "T", "W", "T", "F", "S", "S"])
|
||||
|
||||
|
||||
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")
|
||||
ax.set_xlabel("Hour of day")
|
||||
ax.set_ylabel("Day of week")
|
||||
ax.set_title("Volume by day of week and hour")
|
||||
|
||||
plt.colorbar(im, ax=ax, label="Nº interacciones")
|
||||
plt.colorbar(im, ax=ax, label="# interactions")
|
||||
|
||||
return ax
|
||||
|
||||
def plot_channel_distribution(self) -> Axes:
|
||||
"""
|
||||
Distribución de volumen por canal.
|
||||
Volume distribution by channel.
|
||||
"""
|
||||
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.set_xlabel("Channel")
|
||||
ax.set_ylabel("# interactions")
|
||||
ax.set_title("Volume by channel")
|
||||
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).
|
||||
Simple Pareto chart of volume by skill (volume bars only).
|
||||
"""
|
||||
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.set_xlabel("Skill / Queue")
|
||||
ax.set_ylabel("# interactions")
|
||||
ax.set_title("Pareto chart of volume by skill")
|
||||
ax.grid(axis="y", alpha=0.3)
|
||||
|
||||
plt.xticks(rotation=45, ha="right")
|
||||
|
||||
@@ -23,7 +23,7 @@ LOGGER = logging.getLogger(__name__)
|
||||
|
||||
def setup_basic_logging(level: str = "INFO") -> None:
|
||||
"""
|
||||
Configuración básica de logging, por si se necesita desde scripts.
|
||||
Basic logging configuration, if needed from scripts.
|
||||
"""
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, level.upper(), logging.INFO),
|
||||
@@ -33,10 +33,10 @@ def setup_basic_logging(level: str = "INFO") -> None:
|
||||
|
||||
def _import_class(path: str) -> type:
|
||||
"""
|
||||
Import dinámico de una clase a partir de un string tipo:
|
||||
Dynamic import of a class from a string like:
|
||||
"beyond_metrics.dimensions.VolumetriaMetrics"
|
||||
"""
|
||||
LOGGER.debug("Importando clase %s", path)
|
||||
LOGGER.debug("Importing class %s", path)
|
||||
module_name, class_name = path.rsplit(".", 1)
|
||||
module = import_module(module_name)
|
||||
cls = getattr(module, class_name)
|
||||
@@ -45,7 +45,7 @@ def _import_class(path: str) -> type:
|
||||
|
||||
def _serialize_for_json(obj: Any) -> Any:
|
||||
"""
|
||||
Convierte objetos típicos de numpy/pandas en tipos JSON-friendly.
|
||||
Converts typical numpy/pandas objects to JSON-friendly types.
|
||||
"""
|
||||
if obj is None or isinstance(obj, (str, int, float, bool)):
|
||||
return obj
|
||||
@@ -73,12 +73,12 @@ PostRunCallback = Callable[[Dict[str, Any], str, ResultsSink], None]
|
||||
@dataclass
|
||||
class BeyondMetricsPipeline:
|
||||
"""
|
||||
Pipeline principal de BeyondMetrics.
|
||||
Main BeyondMetrics pipeline.
|
||||
|
||||
- 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_'.
|
||||
- Reads a CSV from a DataSource (local, S3, Google Drive, etc.).
|
||||
- Executes dimensions configured in a config dict.
|
||||
- Serializes numeric/tabular results to JSON.
|
||||
- Saves images from methods starting with 'plot_'.
|
||||
"""
|
||||
|
||||
datasource: DataSource
|
||||
@@ -95,39 +95,39 @@ class BeyondMetricsPipeline:
|
||||
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)
|
||||
LOGGER.info("Starting BeyondMetricsPipeline execution")
|
||||
LOGGER.info("Reading input CSV: %s", input_path)
|
||||
|
||||
# 1) Leer datos
|
||||
# 1) Read data
|
||||
df = self.datasource.read_csv(input_path)
|
||||
LOGGER.info("CSV leído con %d filas y %d columnas", df.shape[0], df.shape[1])
|
||||
LOGGER.info("CSV read with %d rows and %d columns", df.shape[0], df.shape[1])
|
||||
|
||||
# 2) Determinar carpeta/base de salida para esta ejecución
|
||||
# 2) Determine output folder/base for this execution
|
||||
run_base = run_dir.rstrip("/")
|
||||
LOGGER.info("Ruta base de esta ejecución: %s", run_base)
|
||||
LOGGER.info("Base path for this execution: %s", run_base)
|
||||
|
||||
# 3) Ejecutar dimensiones
|
||||
# 3) Execute dimensions
|
||||
dimensions_cfg = self.dimensions_config
|
||||
if not isinstance(dimensions_cfg, dict):
|
||||
raise ValueError("El bloque 'dimensions' debe ser un dict.")
|
||||
raise ValueError("The 'dimensions' block must be a 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).")
|
||||
raise ValueError(f"Invalid config for dimension '{dim_name}' (must be dict).")
|
||||
|
||||
if not dim_cfg.get("enabled", True):
|
||||
LOGGER.info("Dimensión '%s' desactivada; se omite.", dim_name)
|
||||
LOGGER.info("Dimension '%s' disabled; skipping.", dim_name)
|
||||
continue
|
||||
|
||||
class_path = dim_cfg.get("class")
|
||||
if not class_path:
|
||||
raise ValueError(f"Falta 'class' en la dimensión '{dim_name}'.")
|
||||
raise ValueError(f"Missing 'class' in dimension '{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)
|
||||
LOGGER.info("Dimension '%s' has no configured metrics; skipping.", dim_name)
|
||||
continue
|
||||
|
||||
cls = _import_class(class_path)
|
||||
@@ -136,35 +136,35 @@ class BeyondMetricsPipeline:
|
||||
if self.dimension_params is not None:
|
||||
extra_kwargs = self.dimension_params.get(dim_name, {}) or {}
|
||||
|
||||
# Las dimensiones reciben df en el constructor
|
||||
# Dimensions receive df in the 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)
|
||||
LOGGER.info(" - Executing metric '%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)
|
||||
# 4) Save results JSON (optional)
|
||||
if write_results_json:
|
||||
results_json_path = f"{run_base}/results.json"
|
||||
LOGGER.info("Guardando resultados en JSON: %s", results_json_path)
|
||||
LOGGER.info("Saving results to JSON: %s", results_json_path)
|
||||
self.sink.write_json(results_json_path, all_results)
|
||||
|
||||
# 5) Ejecutar callbacks post-run (scorers, agentes, etc.)
|
||||
# 5) Execute post-run callbacks (scorers, agents, etc.)
|
||||
if self.post_run:
|
||||
LOGGER.info("Ejecutando %d callbacks post-run...", len(self.post_run))
|
||||
LOGGER.info("Executing %d post-run callbacks...", len(self.post_run))
|
||||
for cb in self.post_run:
|
||||
try:
|
||||
LOGGER.info("Ejecutando post-run callback: %s", cb)
|
||||
LOGGER.info("Executing post-run callback: %s", cb)
|
||||
cb(all_results, run_base, self.sink)
|
||||
except Exception:
|
||||
LOGGER.exception("Error ejecutando post-run callback %s", cb)
|
||||
LOGGER.exception("Error executing post-run callback %s", cb)
|
||||
|
||||
LOGGER.info("Ejecución completada correctamente.")
|
||||
LOGGER.info("Execution completed successfully.")
|
||||
return all_results
|
||||
|
||||
|
||||
@@ -176,42 +176,42 @@ class BeyondMetricsPipeline:
|
||||
dim_name: str,
|
||||
) -> Any:
|
||||
"""
|
||||
Ejecuta una métrica:
|
||||
Executes a metric:
|
||||
|
||||
- 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.
|
||||
- If it starts with 'plot_' -> assumed to return Axes:
|
||||
- the figure is saved as PNG
|
||||
- returns {"type": "image", "path": "..."}
|
||||
- Otherwise, the value is serialized to 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.
|
||||
Additionally, for categorical metrics (by skill/channel) from the
|
||||
'volumetry' dimension, we explicitly return labels and values so
|
||||
the frontend can know what each number belongs to.
|
||||
"""
|
||||
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__}"
|
||||
f"Metric '{metric_name}' does not exist in {type(instance).__name__}"
|
||||
)
|
||||
|
||||
# Caso plots
|
||||
# Plot case
|
||||
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"
|
||||
f"Metric '{metric_name}' of '{type(instance).__name__}' "
|
||||
f"should return a matplotlib.axes.Axes"
|
||||
)
|
||||
fig = ax.get_figure()
|
||||
if fig is None:
|
||||
raise RuntimeError(
|
||||
"Axes.get_figure() devolvió None, lo cual no debería pasar."
|
||||
"Axes.get_figure() returned None, which should not happen."
|
||||
)
|
||||
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)
|
||||
LOGGER.debug("Saving figure to %s", img_path)
|
||||
self.sink.write_figure(img_path, fig)
|
||||
plt.close(fig)
|
||||
|
||||
@@ -220,12 +220,12 @@ class BeyondMetricsPipeline:
|
||||
"path": img_path,
|
||||
}
|
||||
|
||||
# Caso numérico/tabular
|
||||
# Numeric/tabular case
|
||||
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.
|
||||
# Special case: categorical series from volumetry (by skill / channel)
|
||||
# Return {"labels": [...], "values": [...]} to maintain
|
||||
# label information in the JSON.
|
||||
if (
|
||||
dim_name == "volumetry"
|
||||
and isinstance(value, pd.Series)
|
||||
@@ -238,7 +238,7 @@ class BeyondMetricsPipeline:
|
||||
}
|
||||
):
|
||||
labels = [str(idx) for idx in value.index.tolist()]
|
||||
# Aseguramos que todos los valores sean numéricos JSON-friendly
|
||||
# Ensure all values are JSON-friendly numeric
|
||||
values = [float(v) for v in value.astype(float).tolist()]
|
||||
return {
|
||||
"labels": labels,
|
||||
@@ -251,7 +251,7 @@ class BeyondMetricsPipeline:
|
||||
|
||||
def load_dimensions_config(path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Carga un JSON de configuración que contiene solo el bloque 'dimensions'.
|
||||
Loads a JSON configuration file containing only the 'dimensions' block.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
@@ -261,7 +261,7 @@ def load_dimensions_config(path: str) -> Dict[str, Any]:
|
||||
|
||||
dimensions = cfg.get("dimensions")
|
||||
if dimensions is None:
|
||||
raise ValueError("El fichero de configuración debe contener un bloque 'dimensions'.")
|
||||
raise ValueError("The configuration file must contain a 'dimensions' block.")
|
||||
|
||||
return dimensions
|
||||
|
||||
@@ -274,12 +274,12 @@ def build_pipeline(
|
||||
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.)
|
||||
Creates a BeyondMetricsPipeline from:
|
||||
- path to JSON with dimensions/metrics
|
||||
- an already constructed DataSource (local/S3/Drive)
|
||||
- an already constructed ResultsSink (local/S3/Drive)
|
||||
- an optional list of post_run callbacks that execute at the end
|
||||
(useful for scorers, AI agents, etc.)
|
||||
"""
|
||||
dims_cfg = load_dimensions_config(dimensions_config_path)
|
||||
return BeyondMetricsPipeline(
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Opportunity, DrilldownDataPoint, AgenticTier } from '../types';
|
||||
import {
|
||||
ChevronRight,
|
||||
@@ -57,56 +58,56 @@ interface EnrichedOpportunity extends Opportunity {
|
||||
annualCost?: number;
|
||||
}
|
||||
|
||||
// Tier configuration
|
||||
// Tier configuration - labels and descriptions will be translated at usage time
|
||||
const TIER_CONFIG: Record<AgenticTier, {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
labelKey: string;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
borderColor: string;
|
||||
savingsRate: string;
|
||||
timeline: string;
|
||||
description: string;
|
||||
timelineKey: string;
|
||||
descriptionKey: string;
|
||||
}> = {
|
||||
'AUTOMATE': {
|
||||
icon: <Bot size={18} />,
|
||||
label: 'Automatizar',
|
||||
labelKey: 'opportunityPrioritizer.tierLabels.automate',
|
||||
color: 'text-emerald-700',
|
||||
bgColor: 'bg-emerald-50',
|
||||
borderColor: 'border-emerald-300',
|
||||
savingsRate: '70%',
|
||||
timeline: '3-6 meses',
|
||||
description: 'Automatización completa con agentes IA'
|
||||
timelineKey: 'opportunityPrioritizer.timelines.automate',
|
||||
descriptionKey: 'opportunityPrioritizer.tierDescriptions.automate'
|
||||
},
|
||||
'ASSIST': {
|
||||
icon: <Headphones size={18} />,
|
||||
label: 'Asistir',
|
||||
labelKey: 'opportunityPrioritizer.tierLabels.assist',
|
||||
color: 'text-blue-700',
|
||||
bgColor: 'bg-blue-50',
|
||||
borderColor: 'border-blue-300',
|
||||
savingsRate: '30%',
|
||||
timeline: '6-9 meses',
|
||||
description: 'Copilot IA para agentes humanos'
|
||||
timelineKey: 'opportunityPrioritizer.timelines.assist',
|
||||
descriptionKey: 'opportunityPrioritizer.tierDescriptions.assist'
|
||||
},
|
||||
'AUGMENT': {
|
||||
icon: <BookOpen size={18} />,
|
||||
label: 'Optimizar',
|
||||
labelKey: 'opportunityPrioritizer.tierLabels.augment',
|
||||
color: 'text-amber-700',
|
||||
bgColor: 'bg-amber-50',
|
||||
borderColor: 'border-amber-300',
|
||||
savingsRate: '15%',
|
||||
timeline: '9-12 meses',
|
||||
description: 'Estandarización y mejora de procesos'
|
||||
timelineKey: 'opportunityPrioritizer.timelines.augment',
|
||||
descriptionKey: 'opportunityPrioritizer.tierDescriptions.augment'
|
||||
},
|
||||
'HUMAN-ONLY': {
|
||||
icon: <Users size={18} />,
|
||||
label: 'Humano',
|
||||
labelKey: 'opportunityPrioritizer.tierLabels.human',
|
||||
color: 'text-slate-600',
|
||||
bgColor: 'bg-slate-50',
|
||||
borderColor: 'border-slate-300',
|
||||
savingsRate: '0%',
|
||||
timeline: 'N/A',
|
||||
description: 'Requiere intervención humana'
|
||||
timelineKey: 'N/A',
|
||||
descriptionKey: 'opportunityPrioritizer.tierDescriptions.humanOnly'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -115,6 +116,7 @@ const OpportunityPrioritizer: React.FC<OpportunityPrioritizerProps> = ({
|
||||
drilldownData,
|
||||
costPerHour = 20
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [showAllOpportunities, setShowAllOpportunities] = useState(false);
|
||||
|
||||
@@ -175,29 +177,23 @@ const OpportunityPrioritizer: React.FC<OpportunityPrioritizerProps> = ({
|
||||
// Timeline based on tier
|
||||
const timelineMonths = tier === 'AUTOMATE' ? 4 : tier === 'ASSIST' ? 7 : 10;
|
||||
|
||||
// Generate "why" explanation
|
||||
const whyPrioritized: string[] = [];
|
||||
if (opp.savings > 50000) whyPrioritized.push(`Alto ahorro potencial (€${(opp.savings / 1000).toFixed(0)}K/año)`);
|
||||
if (lookupData?.volume && lookupData.volume > 1000) whyPrioritized.push(`Alto volumen (${lookupData.volume.toLocaleString()} interacciones)`);
|
||||
if (tier === 'AUTOMATE') whyPrioritized.push('Proceso altamente predecible y repetitivo');
|
||||
if (cv < 60) whyPrioritized.push('Baja variabilidad en tiempos de gestión');
|
||||
if (transfer < 15) whyPrioritized.push('Baja tasa de transferencias');
|
||||
if (opp.feasibility >= 7) whyPrioritized.push('Alta factibilidad técnica');
|
||||
// Generate "why" explanation - store keys for translation
|
||||
const whyPrioritized: { key: string; params?: any }[] = [];
|
||||
if (opp.savings > 50000) whyPrioritized.push({ key: 'reasons.highSavingsPotential', params: { amount: (opp.savings / 1000).toFixed(0) } });
|
||||
if (lookupData?.volume && lookupData.volume > 1000) whyPrioritized.push({ key: 'reasons.highVolume', params: { volume: lookupData.volume.toLocaleString() } });
|
||||
if (tier === 'AUTOMATE') whyPrioritized.push({ key: 'reasons.highlyPredictable' });
|
||||
if (cv < 60) whyPrioritized.push({ key: 'reasons.lowVariability' });
|
||||
if (transfer < 15) whyPrioritized.push({ key: 'reasons.lowTransferRate' });
|
||||
if (opp.feasibility >= 7) whyPrioritized.push({ key: 'reasons.highFeasibility' });
|
||||
|
||||
// Generate next steps
|
||||
// Generate next steps - store keys for translation
|
||||
const nextSteps: string[] = [];
|
||||
if (tier === 'AUTOMATE') {
|
||||
nextSteps.push('Definir flujos conversacionales principales');
|
||||
nextSteps.push('Identificar integraciones necesarias (CRM, APIs)');
|
||||
nextSteps.push('Crear piloto con 10% del volumen');
|
||||
nextSteps.push('steps.automate1', 'steps.automate2', 'steps.automate3');
|
||||
} else if (tier === 'ASSIST') {
|
||||
nextSteps.push('Mapear puntos de fricción del agente');
|
||||
nextSteps.push('Diseñar sugerencias contextuales');
|
||||
nextSteps.push('Piloto con equipo seleccionado');
|
||||
nextSteps.push('steps.assist1', 'steps.assist2', 'steps.assist3');
|
||||
} else {
|
||||
nextSteps.push('Analizar causa raíz de variabilidad');
|
||||
nextSteps.push('Estandarizar procesos y scripts');
|
||||
nextSteps.push('Capacitar equipo en mejores prácticas');
|
||||
nextSteps.push('steps.augment1', 'steps.augment2', 'steps.augment3');
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -248,8 +244,8 @@ const OpportunityPrioritizer: React.FC<OpportunityPrioritizerProps> = ({
|
||||
return (
|
||||
<div className="bg-white p-8 rounded-xl border border-slate-200 text-center">
|
||||
<AlertTriangle className="mx-auto mb-4 text-amber-500" size={48} />
|
||||
<h3 className="text-lg font-semibold text-slate-700">No hay oportunidades identificadas</h3>
|
||||
<p className="text-slate-500 mt-2">Los datos actuales no muestran oportunidades de automatización viables.</p>
|
||||
<h3 className="text-lg font-semibold text-slate-700">{t('opportunityPrioritizer.noOpportunitiesTitle')}</h3>
|
||||
<p className="text-slate-500 mt-2">{t('opportunityPrioritizer.noOpportunitiesDescription')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -260,9 +256,9 @@ const OpportunityPrioritizer: React.FC<OpportunityPrioritizerProps> = ({
|
||||
<div className="p-6 border-b border-slate-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900">Oportunidades Priorizadas</h2>
|
||||
<h2 className="text-xl font-bold text-gray-900">{t('opportunityPrioritizer.title')}</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{enrichedOpportunities.length} iniciativas ordenadas por potencial de ahorro y factibilidad
|
||||
{t('opportunityPrioritizer.subtitle', { count: enrichedOpportunities.length })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -273,50 +269,50 @@ const OpportunityPrioritizer: React.FC<OpportunityPrioritizerProps> = ({
|
||||
<div className="bg-white rounded-lg p-4 border border-slate-200 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-slate-500 text-xs mb-1">
|
||||
<DollarSign size={14} />
|
||||
<span>Ahorro Total Identificado</span>
|
||||
<span>{t('opportunityPrioritizer.totalSavingsIdentified')}</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-slate-800">
|
||||
€{(summary.totalSavings / 1000).toFixed(0)}K
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">anuales</div>
|
||||
<div className="text-xs text-slate-500">{t('opportunityPrioritizer.annual')}</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-emerald-50 rounded-lg p-4 border border-emerald-200 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-emerald-600 text-xs mb-1">
|
||||
<Bot size={14} />
|
||||
<span>Quick Wins (AUTOMATE)</span>
|
||||
<span>{t('opportunityPrioritizer.quickWins')}</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-emerald-700">
|
||||
{summary.byTier.AUTOMATE.length}
|
||||
</div>
|
||||
<div className="text-xs text-emerald-600">
|
||||
€{(summary.byTier.AUTOMATE.reduce((s, o) => s + o.savings, 0) / 1000).toFixed(0)}K en 3-6 meses
|
||||
€{(summary.byTier.AUTOMATE.reduce((s, o) => s + o.savings, 0) / 1000).toFixed(0)}K {t('opportunityPrioritizer.inMonths', { months: '3-6' })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 rounded-lg p-4 border border-blue-200 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-blue-600 text-xs mb-1">
|
||||
<Headphones size={14} />
|
||||
<span>Asistencia (ASSIST)</span>
|
||||
<span>{t('opportunityPrioritizer.assistance')}</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-blue-700">
|
||||
{summary.byTier.ASSIST.length}
|
||||
</div>
|
||||
<div className="text-xs text-blue-600">
|
||||
€{(summary.byTier.ASSIST.reduce((s, o) => s + o.savings, 0) / 1000).toFixed(0)}K en 6-9 meses
|
||||
€{(summary.byTier.ASSIST.reduce((s, o) => s + o.savings, 0) / 1000).toFixed(0)}K {t('opportunityPrioritizer.inMonths', { months: '6-9' })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 rounded-lg p-4 border border-amber-200 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-amber-600 text-xs mb-1">
|
||||
<BookOpen size={14} />
|
||||
<span>Optimización (AUGMENT)</span>
|
||||
<span>{t('opportunityPrioritizer.optimization')}</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-amber-700">
|
||||
{summary.byTier.AUGMENT.length}
|
||||
</div>
|
||||
<div className="text-xs text-amber-600">
|
||||
€{(summary.byTier.AUGMENT.reduce((s, o) => s + o.savings, 0) / 1000).toFixed(0)}K en 9-12 meses
|
||||
€{(summary.byTier.AUGMENT.reduce((s, o) => s + o.savings, 0) / 1000).toFixed(0)}K {t('opportunityPrioritizer.inMonths', { months: '9-12' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -326,8 +322,8 @@ const OpportunityPrioritizer: React.FC<OpportunityPrioritizerProps> = ({
|
||||
<div className="p-6 bg-gradient-to-r from-emerald-50 to-green-50 border-b-2 border-emerald-200">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Sparkles className="text-emerald-600" size={20} />
|
||||
<span className="text-emerald-800 font-bold text-lg">EMPIEZA AQUÍ</span>
|
||||
<span className="bg-emerald-600 text-white text-xs px-2 py-0.5 rounded-full">Prioridad #1</span>
|
||||
<span className="text-emerald-800 font-bold text-lg">{t('opportunityPrioritizer.startHere')}</span>
|
||||
<span className="bg-emerald-600 text-white text-xs px-2 py-0.5 rounded-full">{t('opportunityPrioritizer.priority1')}</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border-2 border-emerald-300 p-6 shadow-lg">
|
||||
@@ -343,7 +339,7 @@ const OpportunityPrioritizer: React.FC<OpportunityPrioritizerProps> = ({
|
||||
{topOpportunity.name.replace(/^[^\w\s]+\s*/, '')}
|
||||
</h3>
|
||||
<span className={`text-sm font-medium ${TIER_CONFIG[topOpportunity.tier].color}`}>
|
||||
{TIER_CONFIG[topOpportunity.tier].label} • {TIER_CONFIG[topOpportunity.tier].description}
|
||||
{t(TIER_CONFIG[topOpportunity.tier].labelKey)} • {t(TIER_CONFIG[topOpportunity.tier].descriptionKey)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -351,25 +347,25 @@ const OpportunityPrioritizer: React.FC<OpportunityPrioritizerProps> = ({
|
||||
{/* Key metrics */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
<div className="bg-green-50 rounded-lg p-3">
|
||||
<div className="text-xs text-green-600 mb-1">Ahorro Anual</div>
|
||||
<div className="text-xs text-green-600 mb-1">{t('opportunityPrioritizer.annualSavings')}</div>
|
||||
<div className="text-xl font-bold text-green-700">
|
||||
€{(topOpportunity.savings / 1000).toFixed(0)}K
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-3">
|
||||
<div className="text-xs text-slate-500 mb-1">Volumen</div>
|
||||
<div className="text-xs text-slate-500 mb-1">{t('opportunityPrioritizer.volume')}</div>
|
||||
<div className="text-xl font-bold text-slate-700">
|
||||
{topOpportunity.volume.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-3">
|
||||
<div className="text-xs text-slate-500 mb-1">Timeline</div>
|
||||
<div className="text-xs text-slate-500 mb-1">{t('opportunityPrioritizer.timeline')}</div>
|
||||
<div className="text-xl font-bold text-slate-700">
|
||||
{topOpportunity.timelineMonths} meses
|
||||
{topOpportunity.timelineMonths} {t('opportunityPrioritizer.months')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-3">
|
||||
<div className="text-xs text-slate-500 mb-1">Agentic Score</div>
|
||||
<div className="text-xs text-slate-500 mb-1">{t('opportunityPrioritizer.agenticScore')}</div>
|
||||
<div className="text-xl font-bold text-slate-700">
|
||||
{topOpportunity.agenticScore.toFixed(1)}/10
|
||||
</div>
|
||||
@@ -380,13 +376,13 @@ const OpportunityPrioritizer: React.FC<OpportunityPrioritizerProps> = ({
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-semibold text-slate-700 mb-2 flex items-center gap-2">
|
||||
<Info size={14} />
|
||||
¿Por qué es la prioridad #1?
|
||||
{t('opportunityPrioritizer.whyPriority1')}
|
||||
</h4>
|
||||
<ul className="space-y-1">
|
||||
{topOpportunity.whyPrioritized.slice(0, 4).map((reason, i) => (
|
||||
<li key={i} className="flex items-center gap-2 text-sm text-slate-600">
|
||||
<CheckCircle2 size={14} className="text-emerald-500 flex-shrink-0" />
|
||||
{reason}
|
||||
{t(`opportunityPrioritizer.${reason.key}`, reason.params)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -397,7 +393,7 @@ const OpportunityPrioritizer: React.FC<OpportunityPrioritizerProps> = ({
|
||||
<div className="lg:w-80 bg-emerald-50 rounded-lg p-4 border border-emerald-200">
|
||||
<h4 className="text-sm font-semibold text-emerald-800 mb-3 flex items-center gap-2">
|
||||
<ArrowRight size={14} />
|
||||
Próximos Pasos
|
||||
{t('opportunityPrioritizer.nextSteps')}
|
||||
</h4>
|
||||
<ol className="space-y-2">
|
||||
{topOpportunity.nextSteps.map((step, i) => (
|
||||
@@ -405,12 +401,12 @@ const OpportunityPrioritizer: React.FC<OpportunityPrioritizerProps> = ({
|
||||
<span className="bg-emerald-600 text-white w-5 h-5 rounded-full flex items-center justify-center text-xs flex-shrink-0 mt-0.5">
|
||||
{i + 1}
|
||||
</span>
|
||||
{step}
|
||||
{t(`opportunityPrioritizer.${step}`)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<button className="mt-4 w-full bg-emerald-600 hover:bg-emerald-700 text-white font-medium py-2 px-4 rounded-lg transition-colors flex items-center justify-center gap-2">
|
||||
Ver Detalle Completo
|
||||
{t('opportunityPrioritizer.viewCompleteDetail')}
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -423,7 +419,7 @@ const OpportunityPrioritizer: React.FC<OpportunityPrioritizerProps> = ({
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-bold text-slate-800 mb-4 flex items-center gap-2">
|
||||
<BarChart3 size={20} />
|
||||
Todas las Oportunidades Priorizadas
|
||||
{t('opportunityPrioritizer.allOpportunities')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
@@ -460,18 +456,18 @@ const OpportunityPrioritizer: React.FC<OpportunityPrioritizerProps> = ({
|
||||
{opp.name.replace(/^[^\w\s]+\s*/, '')}
|
||||
</h4>
|
||||
<span className={`text-xs ${TIER_CONFIG[opp.tier].color}`}>
|
||||
{TIER_CONFIG[opp.tier].label} • {TIER_CONFIG[opp.tier].timeline}
|
||||
{t(TIER_CONFIG[opp.tier].labelKey)} • {t(TIER_CONFIG[opp.tier].timelineKey)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Quick stats */}
|
||||
<div className="hidden md:flex items-center gap-6">
|
||||
<div className="text-right">
|
||||
<div className="text-xs text-slate-500">Ahorro</div>
|
||||
<div className="text-xs text-slate-500">{t('opportunityPrioritizer.savings')}</div>
|
||||
<div className="font-bold text-green-600">€{(opp.savings / 1000).toFixed(0)}K</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xs text-slate-500">Volumen</div>
|
||||
<div className="text-xs text-slate-500">{t('opportunityPrioritizer.volume')}</div>
|
||||
<div className="font-semibold text-slate-700">{opp.volume.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
@@ -482,7 +478,7 @@ const OpportunityPrioritizer: React.FC<OpportunityPrioritizerProps> = ({
|
||||
|
||||
{/* Visual bar: Value vs Effort */}
|
||||
<div className="hidden lg:block w-32">
|
||||
<div className="text-xs text-slate-500 mb-1">Valor / Esfuerzo</div>
|
||||
<div className="text-xs text-slate-500 mb-1">{t('opportunityPrioritizer.valueEffort')}</div>
|
||||
<div className="flex h-2 rounded-full overflow-hidden bg-slate-100">
|
||||
<div
|
||||
className="bg-emerald-500 transition-all"
|
||||
@@ -494,8 +490,8 @@ const OpportunityPrioritizer: React.FC<OpportunityPrioritizerProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] text-slate-400 mt-0.5">
|
||||
<span>Valor</span>
|
||||
<span>Esfuerzo</span>
|
||||
<span>{t('opportunityPrioritizer.value')}</span>
|
||||
<span>{t('opportunityPrioritizer.effort')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -523,12 +519,12 @@ const OpportunityPrioritizer: React.FC<OpportunityPrioritizerProps> = ({
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Why prioritized */}
|
||||
<div>
|
||||
<h5 className="text-sm font-semibold text-slate-700 mb-2">¿Por qué esta posición?</h5>
|
||||
<h5 className="text-sm font-semibold text-slate-700 mb-2">{t('opportunityPrioritizer.whyThisPosition')}</h5>
|
||||
<ul className="space-y-1">
|
||||
{opp.whyPrioritized.map((reason, i) => (
|
||||
<li key={i} className="flex items-center gap-2 text-sm text-slate-600">
|
||||
<CheckCircle2 size={12} className="text-emerald-500 flex-shrink-0" />
|
||||
{reason}
|
||||
{t(`opportunityPrioritizer.${reason.key}`, reason.params)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -536,7 +532,7 @@ const OpportunityPrioritizer: React.FC<OpportunityPrioritizerProps> = ({
|
||||
|
||||
{/* Metrics */}
|
||||
<div>
|
||||
<h5 className="text-sm font-semibold text-slate-700 mb-2">Métricas Clave</h5>
|
||||
<h5 className="text-sm font-semibold text-slate-700 mb-2">{t('opportunityPrioritizer.keyMetrics')}</h5>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="bg-white rounded p-2 border border-slate-200">
|
||||
<div className="text-xs text-slate-500">CV AHT</div>
|
||||
@@ -551,12 +547,12 @@ const OpportunityPrioritizer: React.FC<OpportunityPrioritizerProps> = ({
|
||||
<div className="font-semibold text-slate-700">{opp.fcr_rate.toFixed(1)}%</div>
|
||||
</div>
|
||||
<div className="bg-white rounded p-2 border border-slate-200">
|
||||
<div className="text-xs text-slate-500">Riesgo</div>
|
||||
<div className="text-xs text-slate-500">{t('roadmap.risk')}</div>
|
||||
<div className={`font-semibold ${
|
||||
opp.riskLevel === 'low' ? 'text-emerald-600' :
|
||||
opp.riskLevel === 'medium' ? 'text-amber-600' : 'text-red-600'
|
||||
}`}>
|
||||
{opp.riskLevel === 'low' ? 'Bajo' : opp.riskLevel === 'medium' ? 'Medio' : 'Alto'}
|
||||
{t(`roadmap.risk${opp.riskLevel.charAt(0).toUpperCase() + opp.riskLevel.slice(1)}`)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -565,11 +561,11 @@ const OpportunityPrioritizer: React.FC<OpportunityPrioritizerProps> = ({
|
||||
|
||||
{/* Next steps */}
|
||||
<div className="mt-4 pt-4 border-t border-slate-200">
|
||||
<h5 className="text-sm font-semibold text-slate-700 mb-2">Próximos Pasos</h5>
|
||||
<h5 className="text-sm font-semibold text-slate-700 mb-2">{t('opportunityPrioritizer.nextSteps')}</h5>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{opp.nextSteps.map((step, i) => (
|
||||
<span key={i} className="bg-white border border-slate-200 rounded-full px-3 py-1 text-xs text-slate-600">
|
||||
{i + 1}. {step}
|
||||
{i + 1}. {t(`opportunityPrioritizer.${step}`)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -591,12 +587,12 @@ const OpportunityPrioritizer: React.FC<OpportunityPrioritizerProps> = ({
|
||||
{showAllOpportunities ? (
|
||||
<>
|
||||
<ChevronDown size={16} className="rotate-180" />
|
||||
Mostrar menos
|
||||
{t('opportunityPrioritizer.showLess')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown size={16} />
|
||||
Ver {enrichedOpportunities.length - 5} oportunidades más
|
||||
{t('opportunityPrioritizer.viewMore', { count: enrichedOpportunities.length - 5 })}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -609,9 +605,7 @@ const OpportunityPrioritizer: React.FC<OpportunityPrioritizerProps> = ({
|
||||
<div className="flex items-start gap-2">
|
||||
<Info size={14} className="flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<strong>Metodología de priorización:</strong> Las oportunidades se ordenan por potencial de ahorro TCO (volumen × tasa de contención × diferencial CPI).
|
||||
La clasificación de tier (AUTOMATE/ASSIST/AUGMENT) se basa en el Agentic Readiness Score considerando predictibilidad (CV AHT),
|
||||
resolutividad (FCR + Transfer), volumen, calidad de datos y simplicidad del proceso.
|
||||
<strong>{t('opportunityPrioritizer.methodology')}</strong> {t('opportunityPrioritizer.methodologyDescription')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1209,7 +1209,7 @@ function Law10SummaryRoadmap({
|
||||
<div className="p-2 bg-slate-100 rounded-lg">
|
||||
<FileText className="w-5 h-5 text-slate-600" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 text-lg">Resumen de Cumplimiento - Todos los Requisitos</h3>
|
||||
<h3 className="font-semibold text-gray-900 text-lg">{t('law10.summary.title')}</h3>
|
||||
</div>
|
||||
|
||||
{/* Scorecard con todos los requisitos */}
|
||||
@@ -1217,11 +1217,11 @@ function Law10SummaryRoadmap({
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 bg-gray-50">
|
||||
<th className="text-left py-3 px-3 font-medium text-gray-600">Requisito</th>
|
||||
<th className="text-left py-3 px-3 font-medium text-gray-600">Descripcion</th>
|
||||
<th className="text-center py-3 px-3 font-medium text-gray-600">Estado</th>
|
||||
<th className="text-center py-3 px-3 font-medium text-gray-600">Score</th>
|
||||
<th className="text-left py-3 px-3 font-medium text-gray-600">Gap</th>
|
||||
<th className="text-left py-3 px-3 font-medium text-gray-600">{t('law10.summaryTable.requirement')}</th>
|
||||
<th className="text-left py-3 px-3 font-medium text-gray-600">{t('law10.summaryTable.description')}</th>
|
||||
<th className="text-center py-3 px-3 font-medium text-gray-600">{t('law10.summaryTable.status')}</th>
|
||||
<th className="text-center py-3 px-3 font-medium text-gray-600">{t('law10.summaryTable.score')}</th>
|
||||
<th className="text-left py-3 px-3 font-medium text-gray-600">{t('law10.summaryTable.gap')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -1241,7 +1241,7 @@ function Law10SummaryRoadmap({
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<StatusIcon status={req.result.status} />
|
||||
<Badge
|
||||
label={getStatusLabel(req.result.status)}
|
||||
label={getStatusLabel(req.result.status, t)}
|
||||
variant={getStatusBadgeVariant(req.result.status)}
|
||||
size="sm"
|
||||
/>
|
||||
@@ -1271,40 +1271,40 @@ function Law10SummaryRoadmap({
|
||||
<div className="flex flex-wrap gap-4 mb-6 p-3 bg-gray-50 rounded-lg text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-emerald-500" />
|
||||
<span className="text-gray-600">Cumple: Requisito satisfecho</span>
|
||||
<span className="text-gray-600">{t('law10.summaryTable.legend.complies')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-500" />
|
||||
<span className="text-gray-600">Parcial: Requiere mejoras</span>
|
||||
<span className="text-gray-600">{t('law10.summaryTable.legend.partial')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<XCircle className="w-4 h-4 text-red-500" />
|
||||
<span className="text-gray-600">No Cumple: Accion urgente</span>
|
||||
<span className="text-gray-600">{t('law10.summaryTable.legend.notComply')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<HelpCircle className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-gray-600">Sin Datos: Campos no disponibles en CSV</span>
|
||||
<span className="text-gray-600">{t('law10.summaryTable.legend.noData')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inversion Estimada */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 p-4 bg-gray-50 rounded-lg">
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500 mb-1">Coste de no cumplimiento</p>
|
||||
<p className="text-xl font-bold text-red-600">Hasta 100K</p>
|
||||
<p className="text-xs text-gray-400">Multas potenciales/infraccion</p>
|
||||
<p className="text-xs text-gray-500 mb-1">{t('law10.summaryTable.investment.nonComplianceCost')}</p>
|
||||
<p className="text-xl font-bold text-red-600">{t('law10.summaryTable.investment.upTo100k')}</p>
|
||||
<p className="text-xs text-gray-400">{t('law10.summaryTable.investment.potentialFines')}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500 mb-1">Inversion recomendada</p>
|
||||
<p className="text-xs text-gray-500 mb-1">{t('law10.summaryTable.investment.recommendedInvestment')}</p>
|
||||
<p className="text-xl font-bold text-blue-600">{formatCurrency(estimatedInvestment())}</p>
|
||||
<p className="text-xs text-gray-400">Basada en tu operacion</p>
|
||||
<p className="text-xs text-gray-400">{t('law10.summaryTable.investment.basedOnOperation')}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500 mb-1">ROI de cumplimiento</p>
|
||||
<p className="text-xs text-gray-500 mb-1">{t('law10.summaryTable.investment.complianceRoi')}</p>
|
||||
<p className="text-xl font-bold text-emerald-600">
|
||||
{data.economicModel?.roi3yr ? `${Math.round(data.economicModel.roi3yr / 2)}%` : 'Alto'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">Evitar sanciones + mejora CX</p>
|
||||
<p className="text-xs text-gray-400">{t('law10.summaryTable.investment.avoidSanctions')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -1313,29 +1313,31 @@ function Law10SummaryRoadmap({
|
||||
|
||||
// Seccion: Resumen de Madurez de Datos
|
||||
function DataMaturitySummary({ data }: { data: AnalysisData }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Usar datos economicos reales cuando esten disponibles
|
||||
const currentAnnualCost = data.economicModel?.currentAnnualCost || 0;
|
||||
const annualSavings = data.economicModel?.annualSavings || 0;
|
||||
// Datos disponibles
|
||||
const availableData = [
|
||||
{ name: 'Cobertura temporal 24/7', article: 'Art. 14' },
|
||||
{ name: 'Distribucion geografica', article: 'Art. 15 parcial' },
|
||||
{ name: 'Calidad resolucion proxy', article: 'Art. 17 indirecto' },
|
||||
{ name: t('law10.dataMaturity.items.coverage247'), article: t('law10.dataMaturity.article', { number: '14' }) },
|
||||
{ name: t('law10.dataMaturity.items.geoDistribution'), article: t('law10.dataMaturity.articlePartial', { number: '15' }) },
|
||||
{ name: t('law10.dataMaturity.items.resolutionQuality'), article: t('law10.dataMaturity.articleIndirect', { number: '17' }) },
|
||||
];
|
||||
|
||||
// Datos estimables
|
||||
const estimableData = [
|
||||
{ name: 'ASA <3min via proxy abandono', article: 'Art. 8.2', error: '±10%' },
|
||||
{ name: 'Lenguas cooficiales via pais', article: 'Art. 15', error: 'sin detalle' },
|
||||
{ name: t('law10.dataMaturity.items.asa3min'), article: t('law10.dataMaturity.article', { number: '8.2' }), error: t('law10.dataMaturity.errorMargin', { margin: '10' }) },
|
||||
{ name: t('law10.dataMaturity.items.officialLanguages'), article: t('law10.dataMaturity.article', { number: '15' }), error: t('law10.dataMaturity.noDetail') },
|
||||
];
|
||||
|
||||
// Datos no disponibles
|
||||
const missingData = [
|
||||
{ name: 'Tiempo resolucion casos', article: 'Art. 17' },
|
||||
{ name: 'Cobros indebidos <5 dias', article: 'Art. 17' },
|
||||
{ name: 'Transfer a supervisor', article: 'Art. 8' },
|
||||
{ name: 'Info incidencias <2h', article: 'Art. 17' },
|
||||
{ name: 'Auditoria ENAC', article: 'Art. 22', note: 'requiere contratacion externa' },
|
||||
{ name: t('law10.dataMaturity.items.caseResolutionTime'), article: t('law10.dataMaturity.article', { number: '17' }) },
|
||||
{ name: t('law10.dataMaturity.items.undueBilling'), article: t('law10.dataMaturity.article', { number: '17' }) },
|
||||
{ name: t('law10.dataMaturity.items.supervisorTransfer'), article: t('law10.dataMaturity.article', { number: '8' }) },
|
||||
{ name: t('law10.dataMaturity.items.incidentInfo'), article: t('law10.dataMaturity.article', { number: '17' }) },
|
||||
{ name: t('law10.dataMaturity.items.enacAudit'), article: t('law10.dataMaturity.article', { number: '22' }), note: t('law10.dataMaturity.items.externalContractRequired') },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -1344,17 +1346,17 @@ function DataMaturitySummary({ data }: { data: AnalysisData }) {
|
||||
<div className="p-2 bg-indigo-100 rounded-lg">
|
||||
<TrendingUp className="w-5 h-5 text-indigo-600" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 text-lg">Resumen: Madurez de Datos para Compliance</h3>
|
||||
<h3 className="font-semibold text-gray-900 text-lg">{t('law10.dataMaturity.title')}</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 mb-4">Tu nivel actual de instrumentacion:</p>
|
||||
<p className="text-sm text-gray-600 mb-4">{t('law10.dataMaturity.currentLevel')}</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
{/* Datos disponibles */}
|
||||
<div className="p-4 bg-emerald-50 border border-emerald-200 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<CheckCircle className="w-5 h-5 text-emerald-600" />
|
||||
<p className="font-semibold text-emerald-800">DATOS DISPONIBLES (3/10)</p>
|
||||
<p className="font-semibold text-emerald-800">{t('law10.dataMaturity.availableData')}</p>
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{availableData.map((item, idx) => (
|
||||
@@ -1370,7 +1372,7 @@ function DataMaturitySummary({ data }: { data: AnalysisData }) {
|
||||
<div className="p-4 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-600" />
|
||||
<p className="font-semibold text-amber-800">DATOS ESTIMABLES (2/10)</p>
|
||||
<p className="font-semibold text-amber-800">{t('law10.dataMaturity.estimableData')}</p>
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{estimableData.map((item, idx) => (
|
||||
@@ -1386,7 +1388,7 @@ function DataMaturitySummary({ data }: { data: AnalysisData }) {
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<XCircle className="w-5 h-5 text-red-600" />
|
||||
<p className="font-semibold text-red-800">NO DISPONIBLES (5/10)</p>
|
||||
<p className="font-semibold text-red-800">{t('law10.dataMaturity.unavailableData')}</p>
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{missingData.map((item, idx) => (
|
||||
@@ -1406,28 +1408,28 @@ function DataMaturitySummary({ data }: { data: AnalysisData }) {
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Lightbulb className="w-5 h-5 text-amber-500" />
|
||||
<p className="font-semibold text-gray-800">INVERSION SUGERIDA PARA COMPLIANCE COMPLETO</p>
|
||||
<p className="font-semibold text-gray-800">{t('law10.dataMaturity.investment.title')}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
{/* Fase 1 */}
|
||||
<div className="p-3 bg-white rounded border border-gray-200">
|
||||
<p className="font-medium text-gray-800 mb-2">Fase 1 - Instrumentacion (Q1 2026)</p>
|
||||
<p className="font-medium text-gray-800 mb-2">{t('law10.dataMaturity.investment.phase1.title')}</p>
|
||||
<ul className="space-y-1 text-sm text-gray-600">
|
||||
<li className="flex justify-between">
|
||||
<span>• Tracking ASA real</span>
|
||||
<span>{t('law10.dataMaturity.investment.phase1.realAsaTracking')}</span>
|
||||
<span className="font-semibold">5-8K</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span>• Sistema ticketing/casos</span>
|
||||
<span>{t('law10.dataMaturity.investment.phase1.ticketingSystem')}</span>
|
||||
<span className="font-semibold">15-25K</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span>• Enriquecimiento lenguas</span>
|
||||
<span>{t('law10.dataMaturity.investment.phase1.languageEnrichment')}</span>
|
||||
<span className="font-semibold">2K</span>
|
||||
</li>
|
||||
<li className="flex justify-between border-t border-gray-100 pt-1 mt-1">
|
||||
<span className="font-medium">Subtotal:</span>
|
||||
<span className="font-medium">{t('law10.dataMaturity.investment.phase1.subtotal')}</span>
|
||||
<span className="font-bold text-blue-600">22-35K</span>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -1435,22 +1437,22 @@ function DataMaturitySummary({ data }: { data: AnalysisData }) {
|
||||
|
||||
{/* Fase 2 */}
|
||||
<div className="p-3 bg-white rounded border border-gray-200">
|
||||
<p className="font-medium text-gray-800 mb-2">Fase 2 - Operaciones (Q2-Q3 2026)</p>
|
||||
<p className="font-medium text-gray-800 mb-2">{t('law10.dataMaturity.investment.phase2.title')}</p>
|
||||
<ul className="space-y-1 text-sm text-gray-600">
|
||||
<li className="flex justify-between">
|
||||
<span>• Cobertura 24/7 (chatbot + on-call)</span>
|
||||
<span className="font-semibold">65K/año</span>
|
||||
<span>{t('law10.dataMaturity.investment.phase2.coverage247')}</span>
|
||||
<span className="font-semibold">65K/yr</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span>• Copilot IA (reducir AHT)</span>
|
||||
<span className="font-semibold">35K + 8K/mes</span>
|
||||
<span>{t('law10.dataMaturity.investment.phase2.aiCopilot')}</span>
|
||||
<span className="font-semibold">35K + 8K/mo</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span>• Auditor ENAC</span>
|
||||
<span className="font-semibold">12-18K/año</span>
|
||||
<span>{t('law10.dataMaturity.investment.phase2.enacAuditor')}</span>
|
||||
<span className="font-semibold">12-18K/yr</span>
|
||||
</li>
|
||||
<li className="flex justify-between border-t border-gray-100 pt-1 mt-1">
|
||||
<span className="font-medium">Subtotal año 1:</span>
|
||||
<span className="font-medium">{t('law10.dataMaturity.investment.phase2.subtotalYear1')}</span>
|
||||
<span className="font-bold text-blue-600">112-118K</span>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -1460,21 +1462,21 @@ function DataMaturitySummary({ data }: { data: AnalysisData }) {
|
||||
{/* Totales - usar datos reales cuando disponibles */}
|
||||
<div className="grid grid-cols-3 gap-4 pt-4 border-t border-gray-200">
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500 mb-1">Inversion Total</p>
|
||||
<p className="text-xs text-gray-500 mb-1">{t('law10.dataMaturity.investment.totals.totalInvestment')}</p>
|
||||
<p className="text-xl font-bold text-blue-600">
|
||||
{currentAnnualCost > 0 ? formatCurrency(Math.round(currentAnnualCost * 0.05)) : '134-153K'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">~5% coste anual</p>
|
||||
<p className="text-xs text-gray-400">{t('law10.dataMaturity.investment.totals.percentAnnualCost')}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500 mb-1">Riesgo Evitado</p>
|
||||
<p className="text-xs text-gray-500 mb-1">{t('law10.dataMaturity.investment.totals.riskAvoided')}</p>
|
||||
<p className="text-xl font-bold text-red-600">
|
||||
{currentAnnualCost > 0 ? formatCurrency(Math.min(1000000, currentAnnualCost * 0.3)) : '750K-1M'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">sanciones potenciales</p>
|
||||
<p className="text-xs text-gray-400">{t('law10.dataMaturity.investment.totals.potentialSanctions')}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500 mb-1">ROI Compliance</p>
|
||||
<p className="text-xs text-gray-500 mb-1">{t('law10.dataMaturity.investment.totals.complianceRoi')}</p>
|
||||
<p className="text-xl font-bold text-emerald-600">
|
||||
{data.economicModel?.roi3yr ? `${data.economicModel.roi3yr}%` : '490-650%'}
|
||||
</p>
|
||||
|
||||
@@ -187,7 +187,8 @@ const calcularPaybackCompleto = (
|
||||
ahorroAnual: number,
|
||||
waves: string[],
|
||||
esHabilitador: boolean,
|
||||
incluyeQuickWin: boolean
|
||||
incluyeQuickWin: boolean,
|
||||
t: any
|
||||
): PaybackInfo => {
|
||||
// 1. Caso especial: escenario habilitador con poco ahorro directo
|
||||
if (esHabilitador || ahorroAnual < inversion * 0.1) {
|
||||
@@ -195,11 +196,10 @@ const calcularPaybackCompleto = (
|
||||
meses: -1,
|
||||
mesesImplementacion: calcularMesesImplementacion(waves, incluyeQuickWin),
|
||||
mesesRecuperacion: -1,
|
||||
texto: 'Ver Wave 3-4',
|
||||
texto: t('roadmap.payback.seeWave34'),
|
||||
clase: 'text-blue-600',
|
||||
esRecuperable: false,
|
||||
tooltip: 'Esta inversión se recupera con las waves de automatización (W3-W4). ' +
|
||||
'El payback se calcula sobre el roadmap completo, no sobre waves habilitadoras aisladas.'
|
||||
tooltip: t('roadmap.payback.recoversWithAutomation')
|
||||
};
|
||||
}
|
||||
|
||||
@@ -212,11 +212,10 @@ const calcularPaybackCompleto = (
|
||||
meses: -1,
|
||||
mesesImplementacion: 0,
|
||||
mesesRecuperacion: -1,
|
||||
texto: 'No recuperable',
|
||||
texto: t('roadmap.payback.notRecoverable'),
|
||||
clase: 'text-red-600',
|
||||
esRecuperable: false,
|
||||
tooltip: 'El ahorro anual no supera los costes recurrentes. ' +
|
||||
`Margen neto: ${formatCurrency(margenAnual)}/año`
|
||||
tooltip: t('roadmap.payback.savingsDoNotCoverRecurringWithMargin', { margin: formatCurrency(margenAnual) })
|
||||
};
|
||||
}
|
||||
|
||||
@@ -230,7 +229,7 @@ const calcularPaybackCompleto = (
|
||||
const paybackTotal = mesesImplementacion + mesesRecuperacion;
|
||||
|
||||
// 7. Formatear resultado según duración
|
||||
return formatearPaybackResult(paybackTotal, mesesImplementacion, mesesRecuperacion, margenMensual, inversion);
|
||||
return formatearPaybackResult(paybackTotal, mesesImplementacion, mesesRecuperacion, margenMensual, inversion, t);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -241,17 +240,21 @@ const formatearPaybackResult = (
|
||||
mesesImpl: number,
|
||||
mesesRec: number,
|
||||
margenMensual: number,
|
||||
inversion: number
|
||||
inversion: number,
|
||||
t: any
|
||||
): PaybackInfo => {
|
||||
const tooltipBase = `Implementación: ${mesesImpl} meses → Recuperación: ${mesesRec} meses. ` +
|
||||
`Margen: ${formatCurrency(margenMensual * 12)}/año.`;
|
||||
const tooltipBase = t('roadmap.payback.implementationRecoveryMargin', {
|
||||
impl: mesesImpl,
|
||||
rec: mesesRec,
|
||||
margin: formatCurrency(margenMensual * 12)
|
||||
});
|
||||
|
||||
if (meses <= 0) {
|
||||
return {
|
||||
meses: 0,
|
||||
mesesImplementacion: mesesImpl,
|
||||
mesesRecuperacion: mesesRec,
|
||||
texto: 'Inmediato',
|
||||
texto: t('roadmap.payback.immediate'),
|
||||
clase: 'text-emerald-600',
|
||||
esRecuperable: true,
|
||||
tooltip: tooltipBase
|
||||
@@ -290,7 +293,7 @@ const formatearPaybackResult = (
|
||||
texto: `${meses} meses`,
|
||||
clase: 'text-amber-600',
|
||||
esRecuperable: true,
|
||||
tooltip: tooltipBase + ' ⚠️ Periodo de recuperación moderado.'
|
||||
tooltip: tooltipBase + ' ⚠️ ' + t('roadmap.payback.moderateRecoveryPeriod')
|
||||
};
|
||||
}
|
||||
|
||||
@@ -303,7 +306,7 @@ const formatearPaybackResult = (
|
||||
texto: `${anos} años`,
|
||||
clase: 'text-orange-600',
|
||||
esRecuperable: true,
|
||||
tooltip: tooltipBase + ' ⚠️ Periodo de recuperación largo. Considerar escenario menos ambicioso.'
|
||||
tooltip: tooltipBase + ' ⚠️ ' + t('roadmap.payback.longRecoveryPeriod')
|
||||
};
|
||||
};
|
||||
|
||||
@@ -393,11 +396,12 @@ interface BubbleDataPoint {
|
||||
}
|
||||
|
||||
// v3.5: Colores por Tier
|
||||
// Note: labels are now set dynamically using t() in the component
|
||||
const TIER_COLORS: Record<AgenticTier, { fill: string; stroke: string; label: string }> = {
|
||||
'AUTOMATE': { fill: '#059669', stroke: '#047857', label: 'Automatizar' },
|
||||
'ASSIST': { fill: '#3B82F6', stroke: '#2563EB', label: 'Asistir' },
|
||||
'AUGMENT': { fill: '#F59E0B', stroke: '#D97706', label: 'Optimizar' },
|
||||
'HUMAN-ONLY': { fill: '#EF4444', stroke: '#DC2626', label: 'Humano' }
|
||||
'AUTOMATE': { fill: '#059669', stroke: '#047857', label: '' },
|
||||
'ASSIST': { fill: '#3B82F6', stroke: '#2563EB', label: '' },
|
||||
'AUGMENT': { fill: '#F59E0B', stroke: '#D97706', label: '' },
|
||||
'HUMAN-ONLY': { fill: '#EF4444', stroke: '#DC2626', label: '' }
|
||||
};
|
||||
|
||||
// v3.6: Constantes CPI para cálculo de ahorro TCO
|
||||
@@ -1013,14 +1017,14 @@ function WaveCard({
|
||||
{entryCriteria && (
|
||||
<div className="p-2.5 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<p className="text-[10px] text-blue-700 font-bold mb-1.5 flex items-center gap-1">
|
||||
<ArrowRight className="w-3 h-3" /> ENTRADA
|
||||
<ArrowRight className="w-3 h-3" /> {t('roadmap.table.entry')}
|
||||
</p>
|
||||
<div className="space-y-1 text-[10px]">
|
||||
<p className="text-blue-600">
|
||||
<span className="font-medium">Tier:</span> {entryCriteria.tierFrom.join(', ')}
|
||||
<span className="font-medium">{t('roadmap.table.tierLabel')}</span> {entryCriteria.tierFrom.join(', ')}
|
||||
</p>
|
||||
<p className="text-blue-600">
|
||||
<span className="font-medium">Score:</span> {entryCriteria.scoreRange}
|
||||
<span className="font-medium">{t('roadmap.table.scoreLabel')}</span> {entryCriteria.scoreRange}
|
||||
</p>
|
||||
<div className="pt-1 border-t border-blue-200 mt-1">
|
||||
{entryCriteria.requiredMetrics.map((m, i) => (
|
||||
@@ -1035,14 +1039,14 @@ function WaveCard({
|
||||
{exitCriteria && (
|
||||
<div className="p-2.5 bg-emerald-50 rounded-lg border border-emerald-200">
|
||||
<p className="text-[10px] text-emerald-700 font-bold mb-1.5 flex items-center gap-1">
|
||||
<CheckCircle className="w-3 h-3" /> SALIDA
|
||||
<CheckCircle className="w-3 h-3" /> {t('roadmap.table.exit')}
|
||||
</p>
|
||||
<div className="space-y-1 text-[10px]">
|
||||
<p className="text-emerald-600">
|
||||
<span className="font-medium">Tier:</span> {exitCriteria.tierTo}
|
||||
<span className="font-medium">{t('roadmap.table.tierLabel')}</span> {exitCriteria.tierTo}
|
||||
</p>
|
||||
<p className="text-emerald-600">
|
||||
<span className="font-medium">Score:</span> {exitCriteria.scoreTarget}
|
||||
<span className="font-medium">{t('roadmap.table.scoreLabel')}</span> {exitCriteria.scoreTarget}
|
||||
</p>
|
||||
<div className="pt-1 border-t border-emerald-200 mt-1">
|
||||
{exitCriteria.kpiTargets.map((k, i) => (
|
||||
@@ -1067,19 +1071,19 @@ function WaveCard({
|
||||
<div className="bg-gray-100 px-3 py-2 border-b border-gray-200">
|
||||
<p className="text-xs font-semibold text-gray-700 flex items-center gap-1">
|
||||
<Target className="w-3.5 h-3.5 text-blue-500" />
|
||||
Top Colas por Volumen × Impacto
|
||||
{t('roadmap.table.topQueuesByVolumeImpact')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[10px]">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="text-left py-1.5 px-2 font-medium text-gray-500">Cola</th>
|
||||
<th className="text-right py-1.5 px-2 font-medium text-gray-500">Vol/mes</th>
|
||||
<th className="text-right py-1.5 px-2 font-medium text-gray-500">Score</th>
|
||||
<th className="text-center py-1.5 px-2 font-medium text-gray-500">Tier</th>
|
||||
<th className="text-left py-1.5 px-2 font-medium text-gray-500">Red Flags</th>
|
||||
<th className="text-right py-1.5 px-2 font-medium text-gray-500">Potencial</th>
|
||||
<th className="text-left py-1.5 px-2 font-medium text-gray-500">{t('roadmap.table.queue')}</th>
|
||||
<th className="text-right py-1.5 px-2 font-medium text-gray-500">{t('roadmap.table.volPerMonth')}</th>
|
||||
<th className="text-right py-1.5 px-2 font-medium text-gray-500">{t('roadmap.table.score')}</th>
|
||||
<th className="text-center py-1.5 px-2 font-medium text-gray-500">{t('roadmap.table.tier')}</th>
|
||||
<th className="text-left py-1.5 px-2 font-medium text-gray-500">{t('roadmap.table.redFlags')}</th>
|
||||
<th className="text-right py-1.5 px-2 font-medium text-gray-500">{t('roadmap.table.potential')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -1128,14 +1132,14 @@ function WaveCard({
|
||||
</div>
|
||||
{/* v3.7: Nota explicativa de Red Flags */}
|
||||
<div className="px-3 py-1.5 bg-gray-50 border-t border-gray-200 text-[9px] text-gray-500">
|
||||
<span className="font-medium">Red Flags:</span> CV >120% (alta variabilidad) · Transfer >50% (proceso fragmentado) · Vol <50 (muestra pequeña) · Valid <30% (datos ruidosos)
|
||||
{t('roadmap.table.redFlagsNote')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skills afectados */}
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 font-medium mb-2">Skills ({wave.skills.length}):</p>
|
||||
<p className="text-xs text-gray-500 font-medium mb-2">{t('roadmap.table.skills')} ({wave.skills.length}):</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{wave.skills.map((skill, idx) => (
|
||||
<span key={idx} className="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded">
|
||||
@@ -1152,15 +1156,15 @@ function WaveCard({
|
||||
<p className="text-sm font-bold text-red-700">{formatCurrency(wave.inversionSetup)}</p>
|
||||
</div>
|
||||
<div className="p-2 bg-amber-50 rounded border border-amber-100">
|
||||
<p className="text-[10px] text-amber-600 font-medium">Recurrente/año</p>
|
||||
<p className="text-[10px] text-amber-600 font-medium">{t('roadmap.table.recurringPerYear')}</p>
|
||||
<p className="text-sm font-bold text-amber-700">{formatCurrency(wave.costoRecurrenteAnual)}</p>
|
||||
</div>
|
||||
<div className="p-2 bg-emerald-50 rounded border border-emerald-100">
|
||||
<p className="text-[10px] text-emerald-600 font-medium">Ahorro/año</p>
|
||||
<p className="text-[10px] text-emerald-600 font-medium">{t('roadmap.comparison.savingsPerYear')}</p>
|
||||
<p className="text-sm font-bold text-emerald-700">{formatCurrency(wave.ahorroAnual)}</p>
|
||||
</div>
|
||||
<div className="p-2 bg-blue-50 rounded border border-blue-100">
|
||||
<p className="text-[10px] text-blue-600 font-medium">Margen/año</p>
|
||||
<p className="text-[10px] text-blue-600 font-medium">{t('roadmap.comparison.marginPerYear')}</p>
|
||||
<p className="text-sm font-bold text-blue-700">{formatCurrency(margenAnual)}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1178,7 +1182,7 @@ function WaveCard({
|
||||
<div className="space-y-4 pt-2">
|
||||
{/* Iniciativas */}
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 font-medium mb-2">Iniciativas:</p>
|
||||
<p className="text-xs text-gray-500 font-medium mb-2">{t('roadmap.table.initiativesLabel')}</p>
|
||||
<div className="space-y-2">
|
||||
{wave.iniciativas.map((init, idx) => (
|
||||
<div key={idx} className="flex items-start gap-2 p-2 bg-gray-50 rounded text-xs">
|
||||
@@ -1188,9 +1192,9 @@ function WaveCard({
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-700">{init.nombre}</p>
|
||||
<p className="text-gray-500">
|
||||
Setup: {formatCurrency(init.setup)} | Rec: {formatCurrency(init.recurrente)}/mes
|
||||
{t('roadmap.table.setup')} {formatCurrency(init.setup)} | {t('roadmap.table.rec')} {formatCurrency(init.recurrente)}{t('agenticReadiness.table.perMonth')}
|
||||
</p>
|
||||
<p className="text-blue-600 mt-1">KPI: {init.kpi}</p>
|
||||
<p className="text-blue-600 mt-1">{t('roadmap.table.kpi')} {init.kpi}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -1199,7 +1203,7 @@ function WaveCard({
|
||||
|
||||
{/* Criterios de éxito */}
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 font-medium mb-2">✅ Criterios de éxito:</p>
|
||||
<p className="text-xs text-gray-500 font-medium mb-2">{t('roadmap.table.successCriteriaLabel')}</p>
|
||||
<ul className="space-y-1">
|
||||
{wave.criteriosExito.map((criterio, idx) => (
|
||||
<li key={idx} className="text-xs text-gray-600 flex items-start gap-2">
|
||||
@@ -1214,14 +1218,14 @@ function WaveCard({
|
||||
{wave.esCondicional && wave.condicion && (
|
||||
<div className="p-2 bg-amber-50 rounded border border-amber-200">
|
||||
<p className="text-xs text-amber-700">
|
||||
<strong>⚠️ Condición:</strong> {wave.condicion}
|
||||
<strong>{t('roadmap.table.condition')}</strong> {wave.condicion}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Proveedor */}
|
||||
<div className="text-xs text-gray-500">
|
||||
<strong>Proveedor:</strong> {wave.proveedor}
|
||||
<strong>{t('roadmap.table.provider')}</strong> {wave.proveedor}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1244,11 +1248,11 @@ function ScenarioComparison({ escenarios }: { escenarios: EscenarioData[] }) {
|
||||
<div className="p-4 border-b border-gray-200 bg-gray-50">
|
||||
<h3 className="font-semibold text-gray-800 flex items-center gap-2">
|
||||
<Target className="w-5 h-5 text-blue-500" />
|
||||
Escenarios de Inversión
|
||||
{t('roadmap.comparison.title')}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Comparación de opciones según nivel de compromiso
|
||||
<span className="ml-2 text-gray-400" title="ROI basado en benchmarks de industria. El ROI ajustado considera factores de riesgo de implementación.">
|
||||
{t('roadmap.comparison.subtitle')}
|
||||
<span className="ml-2 text-gray-400" title={t('roadmap.comparison.tooltip')}>
|
||||
ℹ️
|
||||
</span>
|
||||
</p>
|
||||
@@ -1258,20 +1262,20 @@ function ScenarioComparison({ escenarios }: { escenarios: EscenarioData[] }) {
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Escenario</th>
|
||||
<th className="text-right py-3 px-4 font-medium text-gray-600">Inversión</th>
|
||||
<th className="text-right py-3 px-4 font-medium text-gray-600">Recurrente</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">{t('roadmap.comparison.scenario')}</th>
|
||||
<th className="text-right py-3 px-4 font-medium text-gray-600">{t('roadmap.comparison.investment')}</th>
|
||||
<th className="text-right py-3 px-4 font-medium text-gray-600">{t('roadmap.comparison.recurring')}</th>
|
||||
<th className="text-right py-3 px-4 font-medium text-gray-600">
|
||||
Ahorro
|
||||
<span className="block text-[10px] text-gray-400 font-normal">(ajustado)</span>
|
||||
{t('roadmap.comparison.savings')}
|
||||
<span className="block text-[10px] text-gray-400 font-normal">({t('roadmap.comparison.adjusted')})</span>
|
||||
</th>
|
||||
<th className="text-right py-3 px-4 font-medium text-gray-600">Margen</th>
|
||||
<th className="text-right py-3 px-4 font-medium text-gray-600">Payback</th>
|
||||
<th className="text-right py-3 px-4 font-medium text-gray-600">{t('roadmap.comparison.margin')}</th>
|
||||
<th className="text-right py-3 px-4 font-medium text-gray-600">{t('roadmap.comparison.payback')}</th>
|
||||
<th className="text-right py-3 px-4 font-medium text-gray-600">
|
||||
ROI 3a
|
||||
<span className="block text-[10px] text-gray-400 font-normal">(ajustado)</span>
|
||||
{t('roadmap.comparison.roi3y')}
|
||||
<span className="block text-[10px] text-gray-400 font-normal">({t('roadmap.comparison.adjusted')})</span>
|
||||
</th>
|
||||
<th className="text-center py-3 px-4 font-medium text-gray-600">Riesgo</th>
|
||||
<th className="text-center py-3 px-4 font-medium text-gray-600">{t('roadmap.comparison.risk')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -1292,10 +1296,10 @@ function ScenarioComparison({ escenarios }: { escenarios: EscenarioData[] }) {
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{esc.esHabilitador && (
|
||||
<span className="text-blue-500" title="Wave habilitadora - su valor está en desbloquear waves posteriores">💡</span>
|
||||
<span className="text-blue-500" title={t('roadmap.comparison.enablerWaveTooltip')}>💡</span>
|
||||
)}
|
||||
{!esc.esRentable && !esc.esHabilitador && (
|
||||
<span className="text-red-500" title="Margen anual negativo">❌</span>
|
||||
<span className="text-red-500" title={t('roadmap.comparison.negativeMarginTooltip')}>❌</span>
|
||||
)}
|
||||
<span className={`font-medium ${
|
||||
esc.esHabilitador ? 'text-blue-700' :
|
||||
@@ -1306,12 +1310,12 @@ function ScenarioComparison({ escenarios }: { escenarios: EscenarioData[] }) {
|
||||
</span>
|
||||
{esc.esHabilitador && (
|
||||
<span className="text-[10px] bg-blue-500 text-white px-2 py-0.5 rounded-full">
|
||||
Habilitador
|
||||
{t('roadmap.comparison.enabler')}
|
||||
</span>
|
||||
)}
|
||||
{esc.esRecomendado && !esc.esHabilitador && esc.esRentable && (
|
||||
<span className="text-[10px] bg-emerald-500 text-white px-2 py-0.5 rounded-full">
|
||||
Recomendado
|
||||
{t('roadmap.comparison.recommended')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -1321,29 +1325,29 @@ function ScenarioComparison({ escenarios }: { escenarios: EscenarioData[] }) {
|
||||
{formatCurrency(esc.inversionTotal)}
|
||||
</td>
|
||||
<td className="text-right py-3 px-4 text-amber-600">
|
||||
{formatCurrency(esc.costoRecurrenteAnual)}/año
|
||||
{formatCurrency(esc.costoRecurrenteAnual)}{t('agenticReadiness.table.perYear')}
|
||||
</td>
|
||||
<td className="text-right py-3 px-4">
|
||||
<div className="text-emerald-600">{formatCurrency(esc.ahorroAnual)}/año</div>
|
||||
<div className="text-emerald-600">{formatCurrency(esc.ahorroAnual)}{t('agenticReadiness.table.perYear')}</div>
|
||||
{esc.esHabilitador && esc.potencialHabilitado > 0 && (
|
||||
<div className="text-[10px] text-blue-600" title={`Desbloquea ${esc.wavesHabilitadas.join(', ')}`}>
|
||||
(habilita {formatCurrency(esc.potencialHabilitado)})
|
||||
<div className="text-[10px] text-blue-600" title={t('roadmap.scenarios.unlocks', { waves: esc.wavesHabilitadas.join(', ') })}>
|
||||
({t('roadmap.scenarios.enablesAmount', { amount: formatCurrency(esc.potencialHabilitado) })})
|
||||
</div>
|
||||
)}
|
||||
{!esc.esHabilitador && esc.ahorroAjustado !== esc.ahorroAnual && (
|
||||
<div className="text-[10px] text-gray-500">
|
||||
({formatCurrency(esc.ahorroAjustado)} ajust.)
|
||||
({formatCurrency(esc.ahorroAjustado)} {t('roadmap.comparison.adjusted')})
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-right py-3 px-4">
|
||||
{esc.esHabilitador ? (
|
||||
<span className="text-xs px-2 py-1 bg-blue-100 text-blue-700 rounded-full font-medium">
|
||||
Prerrequisito
|
||||
{t('roadmap.comparison.prerequisite')}
|
||||
</span>
|
||||
) : (
|
||||
<span className={`font-bold ${esc.margenAnual <= 0 ? 'text-red-600' : 'text-blue-600'}`}>
|
||||
{esc.margenAnual <= 0 ? '-' : ''}{formatCurrency(Math.abs(esc.margenAnual))}/año
|
||||
{esc.margenAnual <= 0 ? '-' : ''}{formatCurrency(Math.abs(esc.margenAnual))}{t('agenticReadiness.table.perYear')}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
@@ -1368,8 +1372,8 @@ function ScenarioComparison({ escenarios }: { escenarios: EscenarioData[] }) {
|
||||
<td className="text-right py-3 px-4">
|
||||
{esc.esHabilitador ? (
|
||||
<span className="text-xs px-2 py-1 bg-blue-100 text-blue-700 rounded-full font-medium"
|
||||
title="El ROI se calcula sobre el roadmap completo">
|
||||
Prerrequisito
|
||||
title={t('roadmap.comparison.roiCalculatedOn')}>
|
||||
{t('roadmap.comparison.prerequisite')}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col items-end">
|
||||
@@ -1379,12 +1383,12 @@ function ScenarioComparison({ escenarios }: { escenarios: EscenarioData[] }) {
|
||||
}`}>
|
||||
{roiInfo.text}
|
||||
{roiInfo.isHighWarning && (
|
||||
<span className="ml-1" title="ROI proyectado. Validar con piloto.">⚠️</span>
|
||||
<span className="ml-1" title={t('roadmap.comparison.projectedRoiTooltip')}>⚠️</span>
|
||||
)}
|
||||
</span>
|
||||
{roiInfo.showAjustado && esc.roi3AnosAjustado > 0 && (
|
||||
<span className="text-[10px] text-gray-500" title="ROI ajustado por riesgo de implementación">
|
||||
({esc.roi3AnosAjustado.toFixed(1)}% ajust.)
|
||||
<span className="text-[10px] text-gray-500" title={t('roadmap.comparison.adjustedRoiTooltip')}>
|
||||
({esc.roi3AnosAjustado.toFixed(1)}% {t('roadmap.comparison.adjusted').slice(0, 5)}.)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -1392,7 +1396,7 @@ function ScenarioComparison({ escenarios }: { escenarios: EscenarioData[] }) {
|
||||
</td>
|
||||
<td className="text-center py-3 px-4">
|
||||
<span className={`text-xs px-2 py-1 rounded-full ${riesgoColors[esc.riesgo]}`}>
|
||||
{esc.riesgo.charAt(0).toUpperCase() + esc.riesgo.slice(1)}
|
||||
{t(`roadmap.comparison.risk${esc.riesgo.charAt(0).toUpperCase() + esc.riesgo.slice(1)}`)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1404,13 +1408,11 @@ function ScenarioComparison({ escenarios }: { escenarios: EscenarioData[] }) {
|
||||
|
||||
{/* Nota sobre cálculos */}
|
||||
<div className="px-4 py-2 bg-gray-50 border-t border-gray-200 text-[10px] text-gray-500">
|
||||
<strong>Payback:</strong> Tiempo implementación + tiempo recuperación.
|
||||
Wave 1: 6m, W2: 3m, W3: 3m, W4: 6m. Ahorro comienza al 50% de última wave.
|
||||
{t('roadmap.comparison.paybackNote')}
|
||||
<br />
|
||||
<strong>ROI:</strong> (Ahorro 3a - Coste Total 3a) / Coste Total 3a × 100.
|
||||
Ajustado aplica riesgo: W1-2: 75-90%, W3: 60%, W4: 50%.
|
||||
{t('roadmap.comparison.roiNote')}
|
||||
<br />
|
||||
<strong>💡 Habilitador:</strong> Waves que desbloquean ROI de waves posteriores. Su payback se evalúa con el roadmap completo.
|
||||
<strong>💡 {t('roadmap.comparison.enabler')}:</strong> {t('roadmap.comparison.enablerNote')}
|
||||
</div>
|
||||
|
||||
{/* Recomendación destacada */}
|
||||
@@ -1437,16 +1439,18 @@ function ScenarioComparison({ escenarios }: { escenarios: EscenarioData[] }) {
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p className={`text-sm font-medium ${textColor}`}>
|
||||
{isEnabling ? 'Recomendación (Habilitador)' : 'Recomendación'}
|
||||
{isEnabling ? t('roadmap.comparison.recommendationEnabler') : t('roadmap.comparison.recommendation')}
|
||||
</p>
|
||||
<p className={`text-xs mt-1 ${subTextColor}`}>
|
||||
{recomendado?.recomendacion || 'Iniciar con escenario conservador para validar modelo antes de escalar.'}
|
||||
{recomendado?.recomendacion || t('roadmap.scenarios.startConservative')}
|
||||
</p>
|
||||
{isEnabling && recomendado?.potencialHabilitado > 0 && (
|
||||
<div className="mt-2 p-2 bg-white/60 rounded border border-blue-200">
|
||||
<p className="text-xs text-blue-800">
|
||||
<strong>💡 Valor real de esta inversión:</strong> Desbloquea {formatCurrency(recomendado.potencialHabilitado)}/año
|
||||
en {recomendado.wavesHabilitadas.join(' y ')}. Sin esta base, las waves posteriores no son viables.
|
||||
<strong>{t('roadmap.scenarios.enablerValue')}</strong> {t('roadmap.scenarios.enablerUnlocks', {
|
||||
amount: formatCurrency(recomendado.potencialHabilitado),
|
||||
waves: recomendado.wavesHabilitadas.join(' y ')
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -1472,34 +1476,38 @@ interface DecisionGate {
|
||||
}
|
||||
|
||||
// v3.6: Decision Gates alineados con nueva nomenclatura y criterios de Tier
|
||||
const DECISION_GATES: DecisionGate[] = [
|
||||
// Note: Decision gates are rendered using translation keys dynamically
|
||||
const getDecisionGates = (t: any): DecisionGate[] => [
|
||||
{
|
||||
id: 'gate1',
|
||||
afterWave: 'wave1',
|
||||
question: '¿CV ≤75% en 3+ colas?',
|
||||
criteria: 'Red flags eliminados, Tier 4→3',
|
||||
goAction: 'Iniciar AUGMENT',
|
||||
noGoAction: 'Extender FOUNDATION'
|
||||
question: t('roadmap.decisionGates.gate1Question'),
|
||||
criteria: t('roadmap.decisionGates.gate1Criteria'),
|
||||
goAction: t('roadmap.decisionGates.gate1GoAction'),
|
||||
noGoAction: t('roadmap.decisionGates.gate1NoGoAction')
|
||||
},
|
||||
{
|
||||
id: 'gate2',
|
||||
afterWave: 'wave2',
|
||||
question: '¿Score ≥5.5 en target?',
|
||||
criteria: 'CV ≤90%, Transfer ≤30%',
|
||||
goAction: 'Iniciar ASSIST',
|
||||
noGoAction: 'Consolidar AUGMENT'
|
||||
question: t('roadmap.decisionGates.gate2Question'),
|
||||
criteria: t('roadmap.decisionGates.gate2Criteria'),
|
||||
goAction: t('roadmap.decisionGates.gate2GoAction'),
|
||||
noGoAction: t('roadmap.decisionGates.gate2NoGoAction')
|
||||
},
|
||||
{
|
||||
id: 'gate3',
|
||||
afterWave: 'wave3',
|
||||
question: '¿Score ≥7.5 en 2+ colas?',
|
||||
criteria: 'CV ≤75%, FCR ≥50%',
|
||||
goAction: 'Lanzar AUTOMATE',
|
||||
noGoAction: 'Expandir ASSIST'
|
||||
question: t('roadmap.decisionGates.gate3Question'),
|
||||
criteria: t('roadmap.decisionGates.gate3Criteria'),
|
||||
goAction: t('roadmap.decisionGates.gate3GoAction'),
|
||||
noGoAction: t('roadmap.decisionGates.gate3NoGoAction')
|
||||
}
|
||||
];
|
||||
|
||||
function RoadmapTimeline({ waves }: { waves: WaveData[] }) {
|
||||
const { t } = useTranslation();
|
||||
const DECISION_GATES = getDecisionGates(t);
|
||||
|
||||
const waveColors: Record<string, { bg: string; border: string; connector: string }> = {
|
||||
wave1: { bg: 'bg-blue-100', border: 'border-blue-400', connector: 'bg-blue-400' },
|
||||
wave2: { bg: 'bg-emerald-100', border: 'border-emerald-400', connector: 'bg-emerald-400' },
|
||||
@@ -1509,8 +1517,8 @@ function RoadmapTimeline({ waves }: { waves: WaveData[] }) {
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="font-semibold text-gray-800 mb-2">Roadmap de Transformación 2026-2027</h3>
|
||||
<p className="text-xs text-gray-500 mb-6">Cada wave depende del éxito de la anterior. Los puntos de decisión permiten ajustar según resultados reales.</p>
|
||||
<h3 className="font-semibold text-gray-800 mb-2">{t('roadmap.timeline.title')}</h3>
|
||||
<p className="text-xs text-gray-500 mb-6">{t('roadmap.timeline.subtitle')}</p>
|
||||
|
||||
{/* Timeline horizontal con waves y gates */}
|
||||
<div className="relative">
|
||||
@@ -1552,11 +1560,11 @@ function RoadmapTimeline({ waves }: { waves: WaveData[] }) {
|
||||
{/* Wave metrics */}
|
||||
<div className="grid grid-cols-2 gap-1 text-[10px]">
|
||||
<div className="bg-white/60 rounded px-1.5 py-1">
|
||||
<span className="text-gray-500">Setup:</span>
|
||||
<span className="text-gray-500">{t('roadmap.timeline.setup')}</span>
|
||||
<span className="font-semibold text-gray-700 ml-1">{formatCurrency(wave.inversionSetup)}</span>
|
||||
</div>
|
||||
<div className="bg-white/60 rounded px-1.5 py-1">
|
||||
<span className="text-gray-500">Ahorro:</span>
|
||||
<span className="text-gray-500">{t('roadmap.comparison.savingsLabel')}</span>
|
||||
<span className="font-semibold text-emerald-600 ml-1">{formatCurrency(wave.ahorroAnual)}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1564,7 +1572,7 @@ function RoadmapTimeline({ waves }: { waves: WaveData[] }) {
|
||||
{/* Conditional badge */}
|
||||
{wave.esCondicional && (
|
||||
<div className="absolute -top-2 -right-2 bg-amber-500 text-white text-[8px] px-1.5 py-0.5 rounded-full font-medium">
|
||||
Condicional
|
||||
{t('roadmap.comparison.conditional')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1574,7 +1582,7 @@ function RoadmapTimeline({ waves }: { waves: WaveData[] }) {
|
||||
wave.riesgo === 'medio' ? 'bg-amber-500 text-white' :
|
||||
'bg-red-500 text-white'
|
||||
}`}>
|
||||
{wave.riesgo === 'bajo' ? '● Bajo' : wave.riesgo === 'medio' ? '● Medio' : '● Alto'}
|
||||
● {t(`roadmap.comparison.risk${wave.riesgo.charAt(0).toUpperCase() + wave.riesgo.slice(1)}`)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -1710,8 +1718,15 @@ export function RoadmapTab({ data }: RoadmapTabProps) {
|
||||
|
||||
// Generar texto dinámico para Wave 2
|
||||
const wave2Description = skillsListos > 0
|
||||
? `${bestSkill?.skill || 'Skill principal'} es el skill con mejor Score (${bestSkillScore.toFixed(1)}/10, categoría "Copilot"). Volumen ${bestSkillVolume.toLocaleString()}/año = mayor impacto económico.`
|
||||
: `Ningún skill alcanza actualmente Score ≥6. El mejor candidato es ${bestSkill?.skill || 'N/A'} con Score ${bestSkillScore.toFixed(1)}/10. Requiere optimización previa en Wave 1.`;
|
||||
? t('roadmap.wave2Description.ready', {
|
||||
skill: bestSkill?.skill || 'Skill principal',
|
||||
score: bestSkillScore.toFixed(1),
|
||||
volume: bestSkillVolume.toLocaleString()
|
||||
})
|
||||
: t('roadmap.wave2Description.notReady', {
|
||||
skill: bestSkill?.skill || 'N/A',
|
||||
score: bestSkillScore.toFixed(1)
|
||||
});
|
||||
|
||||
const wave2Skills = skillsListos > 0
|
||||
? skillsCopilot.map(s => s.skill)
|
||||
@@ -1761,9 +1776,9 @@ export function RoadmapTab({ data }: RoadmapTabProps) {
|
||||
const waves: WaveData[] = [
|
||||
{
|
||||
id: 'wave1',
|
||||
nombre: 'Wave 1',
|
||||
titulo: 'FOUNDATION',
|
||||
trimestre: 'Q1-Q2 2026',
|
||||
nombre: t('roadmap.waves.wave1Name'),
|
||||
titulo: t('roadmap.waves.wave1Title'),
|
||||
trimestre: t('roadmap.waves.wave1Quarter'),
|
||||
tipo: 'consulting',
|
||||
icon: <Settings className="w-5 h-5" />,
|
||||
color: 'text-gray-600',
|
||||
@@ -1773,30 +1788,34 @@ export function RoadmapTab({ data }: RoadmapTabProps) {
|
||||
costoRecurrenteAnual: 0,
|
||||
ahorroAnual: 0, // Wave habilitadora
|
||||
esCondicional: false,
|
||||
porQueNecesario: `${tierCounts['HUMAN-ONLY'].length + tierCounts.AUGMENT.length} de ${allQueues.length} colas están en Tier 3-4 (${Math.round((wave1Volume / totalVolume) * 100)}% del volumen). Red flags: CV >75%, Transfer >20%. Automatizar sin estandarizar = fracaso garantizado.`,
|
||||
porQueNecesario: t('roadmap.porQueNecesarioTemplates.wave1', {
|
||||
count: tierCounts['HUMAN-ONLY'].length + tierCounts.AUGMENT.length,
|
||||
total: allQueues.length,
|
||||
pct: Math.round((wave1Volume / totalVolume) * 100)
|
||||
}),
|
||||
skills: wave1Queues.length > 0
|
||||
? [...new Set(drilldownData.filter(s => s.originalQueues.some(q => q.tier === 'HUMAN-ONLY' || q.tier === 'AUGMENT')).map(s => s.skill))].slice(0, 5)
|
||||
: skillsNeedStandardization.map(s => s.skill).slice(0, 5),
|
||||
iniciativas: [
|
||||
{ nombre: 'Análisis de variabilidad y red flags', setup: 15000, recurrente: 0, kpi: 'Mapear causas de CV >75% y Transfer >20%' },
|
||||
{ nombre: 'Rediseño y documentación de procesos', setup: 20000, recurrente: 0, kpi: 'Scripts estandarizados para 80% casuística' },
|
||||
{ nombre: 'Training y certificación de agentes', setup: 12000, recurrente: 0, kpi: 'Certificación 90% agentes, adherencia >85%' }
|
||||
{ nombre: t('roadmap.initiatives.wave1Init1'), setup: 15000, recurrente: 0, kpi: t('roadmap.initiatives.wave1Init1Kpi') },
|
||||
{ nombre: t('roadmap.initiatives.wave1Init2'), setup: 20000, recurrente: 0, kpi: t('roadmap.initiatives.wave1Init2Kpi') },
|
||||
{ nombre: t('roadmap.initiatives.wave1Init3'), setup: 12000, recurrente: 0, kpi: t('roadmap.initiatives.wave1Init3Kpi') }
|
||||
],
|
||||
criteriosExito: [
|
||||
`CV AHT ≤75% en al menos ${Math.max(3, Math.ceil(wave1Queues.length * 0.3))} colas de alto volumen`,
|
||||
'Transfer ≤20% global',
|
||||
'Red flags eliminados en colas prioritarias',
|
||||
`Al menos ${Math.ceil(wave1Queues.length * 0.2)} colas migran de Tier 4 → Tier 3`
|
||||
t('roadmap.successCriteriaTemplates.wave1Criterion1', { count: Math.max(3, Math.ceil(wave1Queues.length * 0.3)) }),
|
||||
t('roadmap.successCriteriaTemplates.wave1Criterion2'),
|
||||
t('roadmap.successCriteriaTemplates.wave1Criterion3'),
|
||||
t('roadmap.successCriteriaTemplates.wave1Criterion4', { count: Math.ceil(wave1Queues.length * 0.2) })
|
||||
],
|
||||
riesgo: 'bajo',
|
||||
riesgoDescripcion: 'Consultoría con entregables tangibles. No requiere tecnología.',
|
||||
proveedor: 'Beyond Consulting o tercero especializado'
|
||||
riesgoDescripcion: t('roadmap.waves.wave1RiskDescription'),
|
||||
proveedor: t('roadmap.waves.wave1Provider')
|
||||
},
|
||||
{
|
||||
id: 'wave2',
|
||||
nombre: 'Wave 2',
|
||||
titulo: 'AUGMENT',
|
||||
trimestre: 'Q3 2026',
|
||||
nombre: t('roadmap.waves.wave2Name'),
|
||||
titulo: t('roadmap.waves.wave2Title'),
|
||||
trimestre: t('roadmap.waves.wave2Quarter'),
|
||||
tipo: 'beyond_consulting',
|
||||
icon: <TrendingUp className="w-5 h-5" />,
|
||||
color: 'text-amber-600',
|
||||
@@ -1806,30 +1825,33 @@ export function RoadmapTab({ data }: RoadmapTabProps) {
|
||||
costoRecurrenteAnual: 40000,
|
||||
ahorroAnual: potentialSavings.AUGMENT, // 15% efficiency - calculado desde datos reales
|
||||
esCondicional: true,
|
||||
condicion: 'Requiere CV ≤75% post-Wave 1 en colas target',
|
||||
porQueNecesario: `Implementar herramientas de soporte para colas Tier 3 (Score 3.5-5.5). Objetivo: elevar score a ≥5.5 para habilitar Wave 3. Foco en ${tierCounts.AUGMENT.length} colas con ${tierVolumes.AUGMENT.toLocaleString()} int/mes.`,
|
||||
condicion: t('roadmap.waves.wave2Condition'),
|
||||
porQueNecesario: t('roadmap.porQueNecesarioTemplates.wave2', {
|
||||
count: tierCounts.AUGMENT.length,
|
||||
volume: tierVolumes.AUGMENT.toLocaleString()
|
||||
}),
|
||||
skills: tierCounts.AUGMENT.length > 0
|
||||
? [...new Set(drilldownData.filter(s => s.originalQueues.some(q => q.tier === 'AUGMENT')).map(s => s.skill))].slice(0, 4)
|
||||
: ['Colas que alcancen Score 3.5-5.5 post Wave 1'],
|
||||
: [t('roadmap.fallbackSkills.wave1')],
|
||||
iniciativas: [
|
||||
{ nombre: 'Knowledge Base contextual', setup: 20000, recurrente: 2000, kpi: 'Hold time -25%, uso KB +40%' },
|
||||
{ nombre: 'Scripts dinámicos con IA', setup: 15000, recurrente: 1500, kpi: 'Adherencia scripts +30%' }
|
||||
{ nombre: t('roadmap.initiatives.wave2Init1'), setup: 20000, recurrente: 2000, kpi: t('roadmap.initiatives.wave2Init1Kpi') },
|
||||
{ nombre: t('roadmap.initiatives.wave2Init2'), setup: 15000, recurrente: 1500, kpi: t('roadmap.initiatives.wave2Init2Kpi') }
|
||||
],
|
||||
criteriosExito: [
|
||||
'Score promedio sube de 3.5-5.5 → ≥5.5',
|
||||
'AHT -15% vs baseline',
|
||||
'CV ≤90% en colas target',
|
||||
`${Math.ceil(tierCounts.AUGMENT.length * 0.5)} colas migran de Tier 3 → Tier 2`
|
||||
t('roadmap.successCriteriaTemplates.wave2Criterion1'),
|
||||
t('roadmap.successCriteriaTemplates.wave2Criterion2'),
|
||||
t('roadmap.successCriteriaTemplates.wave2Criterion3'),
|
||||
t('roadmap.successCriteriaTemplates.wave2Criterion4', { count: Math.ceil(tierCounts.AUGMENT.length * 0.5) })
|
||||
],
|
||||
riesgo: 'bajo',
|
||||
riesgoDescripcion: 'Herramientas de soporte, bajo riesgo de integración.',
|
||||
proveedor: 'BEYOND (KB + Scripts IA)'
|
||||
riesgoDescripcion: t('roadmap.waves.wave2RiskDescription'),
|
||||
proveedor: t('roadmap.waves.wave2Provider')
|
||||
},
|
||||
{
|
||||
id: 'wave3',
|
||||
nombre: 'Wave 3',
|
||||
titulo: 'ASSIST',
|
||||
trimestre: 'Q4 2026',
|
||||
nombre: t('roadmap.waves.wave3Name'),
|
||||
titulo: t('roadmap.waves.wave3Title'),
|
||||
trimestre: t('roadmap.waves.wave3Quarter'),
|
||||
tipo: 'beyond',
|
||||
icon: <Bot className="w-5 h-5" />,
|
||||
color: 'text-blue-600',
|
||||
@@ -1839,31 +1861,34 @@ export function RoadmapTab({ data }: RoadmapTabProps) {
|
||||
costoRecurrenteAnual: 78000,
|
||||
ahorroAnual: potentialSavings.ASSIST, // 30% efficiency - calculado desde datos reales
|
||||
esCondicional: true,
|
||||
condicion: 'Requiere Score ≥5.5 Y CV ≤90% Y Transfer ≤30%',
|
||||
porQueNecesario: `Copilot IA para agentes en colas Tier 2. Sugerencias en tiempo real, autocompletado, next-best-action. Objetivo: elevar score a ≥7.5 para Wave 4. Target: ${tierCounts.ASSIST.length} colas con ${tierVolumes.ASSIST.toLocaleString()} int/mes.`,
|
||||
condicion: t('roadmap.waves.wave3Condition'),
|
||||
porQueNecesario: t('roadmap.porQueNecesarioTemplates.wave3', {
|
||||
count: tierCounts.ASSIST.length,
|
||||
volume: tierVolumes.ASSIST.toLocaleString()
|
||||
}),
|
||||
skills: tierCounts.ASSIST.length > 0
|
||||
? [...new Set(drilldownData.filter(s => s.originalQueues.some(q => q.tier === 'ASSIST')).map(s => s.skill))].slice(0, 4)
|
||||
: ['Colas que alcancen Score ≥5.5 post Wave 2'],
|
||||
: [t('roadmap.fallbackSkills.wave2')],
|
||||
iniciativas: [
|
||||
{ nombre: 'Agent Assist / Copilot IA', setup: 45000, recurrente: 4500, kpi: 'AHT -30%, sugerencias aceptadas >60%' },
|
||||
{ nombre: 'Automatización parcial (FAQs, routing)', setup: 25000, recurrente: 3000, kpi: 'Deflection rate 15%' }
|
||||
{ nombre: t('roadmap.initiatives.wave3Init1'), setup: 45000, recurrente: 4500, kpi: t('roadmap.initiatives.wave3Init1Kpi') },
|
||||
{ nombre: t('roadmap.initiatives.wave3Init2'), setup: 25000, recurrente: 3000, kpi: t('roadmap.initiatives.wave3Init2Kpi') }
|
||||
],
|
||||
criteriosExito: [
|
||||
'Score promedio sube de 5.5-7.5 → ≥7.5',
|
||||
'AHT -30% vs baseline Wave 2',
|
||||
'CV ≤75% en colas target',
|
||||
'Transfer ≤20%',
|
||||
`${Math.ceil(tierCounts.ASSIST.length * 0.4)} colas migran de Tier 2 → Tier 1`
|
||||
t('roadmap.successCriteriaTemplates.wave3Criterion1'),
|
||||
t('roadmap.successCriteriaTemplates.wave3Criterion2'),
|
||||
t('roadmap.successCriteriaTemplates.wave3Criterion3'),
|
||||
t('roadmap.successCriteriaTemplates.wave3Criterion4'),
|
||||
t('roadmap.successCriteriaTemplates.wave3Criterion5', { count: Math.ceil(tierCounts.ASSIST.length * 0.4) })
|
||||
],
|
||||
riesgo: 'medio',
|
||||
riesgoDescripcion: 'Integración con plataforma contact center. Piloto 4 semanas mitiga.',
|
||||
proveedor: 'BEYOND (Copilot + Routing IA)'
|
||||
riesgoDescripcion: t('roadmap.waves.wave3RiskDescription'),
|
||||
proveedor: t('roadmap.waves.wave3Provider')
|
||||
},
|
||||
{
|
||||
id: 'wave4',
|
||||
nombre: 'Wave 4',
|
||||
titulo: 'AUTOMATE',
|
||||
trimestre: 'Q1-Q2 2027',
|
||||
nombre: t('roadmap.waves.wave4Name'),
|
||||
titulo: t('roadmap.waves.wave4Title'),
|
||||
trimestre: t('roadmap.waves.wave4Quarter'),
|
||||
tipo: 'beyond',
|
||||
icon: <Rocket className="w-5 h-5" />,
|
||||
color: 'text-emerald-600',
|
||||
@@ -1873,24 +1898,27 @@ export function RoadmapTab({ data }: RoadmapTabProps) {
|
||||
costoRecurrenteAnual: 108000,
|
||||
ahorroAnual: potentialSavings.AUTOMATE, // 70% containment - calculado desde datos reales
|
||||
esCondicional: true,
|
||||
condicion: 'Requiere Score ≥7.5 Y CV ≤75% Y Transfer ≤20% Y FCR ≥50%',
|
||||
porQueNecesario: `Automatización end-to-end para colas Tier 1. Voicebot/Chatbot transaccional con 70% contención. Solo viable con procesos maduros. Target actual: ${tierCounts.AUTOMATE.length} colas con ${tierVolumes.AUTOMATE.toLocaleString()} int/mes.`,
|
||||
condicion: t('roadmap.waves.wave4Condition'),
|
||||
porQueNecesario: t('roadmap.porQueNecesarioTemplates.wave4', {
|
||||
count: tierCounts.AUTOMATE.length,
|
||||
volume: tierVolumes.AUTOMATE.toLocaleString()
|
||||
}),
|
||||
skills: tierCounts.AUTOMATE.length > 0
|
||||
? [...new Set(drilldownData.filter(s => s.originalQueues.some(q => q.tier === 'AUTOMATE')).map(s => s.skill))].slice(0, 4)
|
||||
: ['Colas que alcancen Score ≥7.5 post Wave 3'],
|
||||
: [t('roadmap.fallbackSkills.wave3')],
|
||||
iniciativas: [
|
||||
{ nombre: 'Voicebot/Chatbot transaccional', setup: 55000, recurrente: 6000, kpi: 'Contención 70%+, CSAT ≥4/5' },
|
||||
{ nombre: 'IVR inteligente con NLU', setup: 30000, recurrente: 3000, kpi: 'Pre-calificación 80%+, transferencia warm' }
|
||||
{ nombre: t('roadmap.initiatives.wave4Init1'), setup: 55000, recurrente: 6000, kpi: t('roadmap.initiatives.wave4Init1Kpi') },
|
||||
{ nombre: t('roadmap.initiatives.wave4Init2'), setup: 30000, recurrente: 3000, kpi: t('roadmap.initiatives.wave4Init2Kpi') }
|
||||
],
|
||||
criteriosExito: [
|
||||
'Contención ≥70% en colas automatizadas',
|
||||
'CSAT se mantiene o mejora (≥4/5)',
|
||||
'Escalado a humano <30%',
|
||||
'ROI acumulado >300%'
|
||||
t('roadmap.successCriteriaTemplates.wave4Criterion1'),
|
||||
t('roadmap.successCriteriaTemplates.wave4Criterion2'),
|
||||
t('roadmap.successCriteriaTemplates.wave4Criterion3'),
|
||||
t('roadmap.successCriteriaTemplates.wave4Criterion4')
|
||||
],
|
||||
riesgo: 'alto',
|
||||
riesgoDescripcion: 'Muy condicional. Requiere éxito demostrado en Waves 1-3.',
|
||||
proveedor: 'BEYOND (Voicebot + IVR + Chatbot)'
|
||||
riesgoDescripcion: t('roadmap.waves.wave4RiskDescription'),
|
||||
proveedor: t('roadmap.waves.wave4Provider')
|
||||
}
|
||||
];
|
||||
|
||||
@@ -1952,24 +1980,24 @@ export function RoadmapTab({ data }: RoadmapTabProps) {
|
||||
// v3.9: Calcular payback completo para cada escenario
|
||||
const consPaybackInfo = calcularPaybackCompleto(
|
||||
consInversion, consMargen, consSavings,
|
||||
['wave1', 'wave2'], consEsHabilitador, false
|
||||
['wave1', 'wave2'], consEsHabilitador, false, t
|
||||
);
|
||||
const modPaybackInfo = calcularPaybackCompleto(
|
||||
modInversion, modMargen, modSavings,
|
||||
['wave1', 'wave2', 'wave3'], modEsHabilitador, false
|
||||
['wave1', 'wave2', 'wave3'], modEsHabilitador, false, t
|
||||
);
|
||||
// Agresivo incluye Wave 4 (Quick Wins potenciales si hay AUTOMATE queues)
|
||||
const agrIncluyeQuickWin = tierCounts.AUTOMATE.length >= 3;
|
||||
const agrPaybackInfo = calcularPaybackCompleto(
|
||||
agrInversion, agrMargen, agrSavings,
|
||||
['wave1', 'wave2', 'wave3', 'wave4'], agrEsHabilitador, agrIncluyeQuickWin
|
||||
['wave1', 'wave2', 'wave3', 'wave4'], agrEsHabilitador, agrIncluyeQuickWin, t
|
||||
);
|
||||
|
||||
const escenarios: EscenarioData[] = [
|
||||
{
|
||||
id: 'conservador',
|
||||
nombre: 'Conservador',
|
||||
descripcion: 'FOUNDATION + AUGMENT (Wave 1-2)',
|
||||
nombre: t('roadmap.scenarios.conservativeName'),
|
||||
descripcion: t('roadmap.scenarios.conservativeDesc'),
|
||||
waves: ['wave1', 'wave2'],
|
||||
inversionTotal: consInversion,
|
||||
costoRecurrenteAnual: consRec,
|
||||
@@ -1982,7 +2010,7 @@ export function RoadmapTab({ data }: RoadmapTabProps) {
|
||||
roi3AnosAjustado: calculateROI3Years(consInversion, consRec, consSavingsAjustado),
|
||||
riesgo: 'bajo',
|
||||
recomendacion: consEsHabilitador
|
||||
? `✅ Recomendado como HABILITADOR. Desbloquea ${formatCurrency(consPotencialHabilitado)}/año en Wave 3-4. Objetivo: mover ${Math.ceil(wave1Queues.length * 0.3)} colas de Tier 4→3.`
|
||||
? `✅ Recomendado como HABILITADOR. Desbloquea ${formatCurrency(consPotencialHabilitado)}{t('agenticReadiness.table.perYear')} en Wave 3-4. Objetivo: mover ${Math.ceil(wave1Queues.length * 0.3)} colas de Tier 4→3.`
|
||||
: `✅ Recomendado. Validar modelo con riesgo bajo. Objetivo: mover ${Math.ceil(wave1Queues.length * 0.3)} colas de Tier 4→3.`,
|
||||
esRecomendado: true,
|
||||
esRentable: consMargen > 0,
|
||||
@@ -1993,8 +2021,8 @@ export function RoadmapTab({ data }: RoadmapTabProps) {
|
||||
},
|
||||
{
|
||||
id: 'moderado',
|
||||
nombre: 'Moderado',
|
||||
descripcion: 'FOUNDATION + AUGMENT + ASSIST (Wave 1-3)',
|
||||
nombre: t('roadmap.scenarios.moderateName'),
|
||||
descripcion: t('roadmap.scenarios.moderateDesc'),
|
||||
waves: ['wave1', 'wave2', 'wave3'],
|
||||
inversionTotal: modInversion,
|
||||
costoRecurrenteAnual: modRec,
|
||||
@@ -2007,7 +2035,7 @@ export function RoadmapTab({ data }: RoadmapTabProps) {
|
||||
roi3AnosAjustado: calculateROI3Years(modInversion, modRec, modSavingsAjustado),
|
||||
riesgo: 'medio',
|
||||
recomendacion: modEsHabilitador
|
||||
? `Habilitador parcial. Desbloquea ${formatCurrency(modPotencialHabilitado)}/año en Wave 4. Decidir Go/No-Go en Q3 2026.`
|
||||
? `Habilitador parcial. Desbloquea ${formatCurrency(modPotencialHabilitado)}{t('agenticReadiness.table.perYear')} en Wave 4. Decidir Go/No-Go en Q3 2026.`
|
||||
: `Decidir Go/No-Go en Q3 2026 basado en resultados Wave 1-2. Requiere Score ≥5.5 en colas target.`,
|
||||
esRecomendado: false,
|
||||
esRentable: modMargen > 0,
|
||||
@@ -2018,8 +2046,8 @@ export function RoadmapTab({ data }: RoadmapTabProps) {
|
||||
},
|
||||
{
|
||||
id: 'agresivo',
|
||||
nombre: 'Agresivo',
|
||||
descripcion: 'Roadmap completo (Wave 1-4)',
|
||||
nombre: t('roadmap.scenarios.aggressiveName'),
|
||||
descripcion: t('roadmap.scenarios.aggressiveDesc'),
|
||||
waves: ['wave1', 'wave2', 'wave3', 'wave4'],
|
||||
inversionTotal: agrInversion,
|
||||
costoRecurrenteAnual: agrRec,
|
||||
@@ -2228,27 +2256,47 @@ export function RoadmapTab({ data }: RoadmapTabProps) {
|
||||
|
||||
if (automateCount >= 3) {
|
||||
return {
|
||||
action: 'Lanzar Wave 4 (AUTOMATE) en piloto',
|
||||
rationale: `${automateCount} colas ya tienen Score ≥7.5 con volumen de ${tierVolumes.AUTOMATE.toLocaleString()} int/mes.`,
|
||||
nextStep: `Iniciar piloto de automatización en las 2-3 colas de mayor volumen con ahorro potencial de ${formatCurrency(potentialSavings.AUTOMATE)}/año.`
|
||||
action: t('roadmap.specificRecommendations.launchWave4'),
|
||||
rationale: t('roadmap.specificRecommendations.launchWave4Rationale', {
|
||||
count: automateCount,
|
||||
volume: tierVolumes.AUTOMATE.toLocaleString()
|
||||
}),
|
||||
nextStep: t('roadmap.specificRecommendations.launchWave4NextStep', {
|
||||
amount: formatCurrency(potentialSavings.AUTOMATE)
|
||||
})
|
||||
};
|
||||
} else if (assistCount >= 5 || pctHighTier >= 30) {
|
||||
return {
|
||||
action: 'Iniciar Wave 3 (ASSIST) con Copilot',
|
||||
rationale: `${assistCount} colas tienen Score 5.5-7.5, representando ${Math.round((tierVolumes.ASSIST / totalVolume) * 100)}% del volumen.`,
|
||||
nextStep: `Desplegar Copilot IA en colas Tier 2 para elevar score a ≥7.5 y habilitar Wave 4. Inversión: ${formatCurrency(wave3Setup)}.`
|
||||
action: t('roadmap.specificRecommendations.initiateWave3'),
|
||||
rationale: t('roadmap.specificRecommendations.initiateWave3Rationale', {
|
||||
count: assistCount,
|
||||
pct: Math.round((tierVolumes.ASSIST / totalVolume) * 100)
|
||||
}),
|
||||
nextStep: t('roadmap.specificRecommendations.initiateWave3NextStep', {
|
||||
amount: formatCurrency(wave3Setup)
|
||||
})
|
||||
};
|
||||
} else if (humanOnlyCount > totalQueues * 0.5) {
|
||||
return {
|
||||
action: 'Priorizar Wave 1 (FOUNDATION)',
|
||||
rationale: `${humanOnlyCount} colas (${Math.round((humanOnlyCount / totalQueues) * 100)}%) tienen Red Flags que impiden automatización.`,
|
||||
nextStep: `Estandarizar procesos antes de invertir en IA. La automatización sin fundamentos sólidos fracasa en 80%+ de casos.`
|
||||
action: t('roadmap.specificRecommendations.prioritizeWave1'),
|
||||
rationale: t('roadmap.specificRecommendations.prioritizeWave1Rationale', {
|
||||
count: humanOnlyCount,
|
||||
pct: Math.round((humanOnlyCount / totalQueues) * 100)
|
||||
}),
|
||||
nextStep: t('roadmap.specificRecommendations.prioritizeWave1NextStep')
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
action: 'Ejecutar Wave 1-2 secuencialmente',
|
||||
rationale: `Operación mixta: ${automateCount} colas Tier 1, ${assistCount} Tier 2, ${tierCounts.AUGMENT.length} Tier 3, ${humanOnlyCount} Tier 4.`,
|
||||
nextStep: `Comenzar con FOUNDATION para eliminar red flags, seguido de AUGMENT para elevar scores. Inversión inicial: ${formatCurrency(wave1Setup + wave2Setup)}.`
|
||||
action: t('roadmap.specificRecommendations.executeWave12'),
|
||||
rationale: t('roadmap.specificRecommendations.executeWave12Rationale', {
|
||||
automate: automateCount,
|
||||
assist: assistCount,
|
||||
augment: tierCounts.AUGMENT.length,
|
||||
human: humanOnlyCount
|
||||
}),
|
||||
nextStep: t('roadmap.specificRecommendations.executeWave12NextStep', {
|
||||
amount: formatCurrency(wave1Setup + wave2Setup)
|
||||
})
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -2482,7 +2530,7 @@ export function RoadmapTab({ data }: RoadmapTabProps) {
|
||||
return { display: '>500%', tooltip: `ROI calculado: ${roi}%`, showCap: true };
|
||||
}
|
||||
if (roi > 300) {
|
||||
return { display: `${roi}%`, tooltip: 'ROI alto - validar con piloto', showCap: false };
|
||||
return { display: `${roi}%`, tooltip: t('roadmap.payback.roiValidateWithPilot'), showCap: false };
|
||||
}
|
||||
return { display: `${roi}%`, tooltip: '', showCap: false };
|
||||
};
|
||||
@@ -2530,15 +2578,13 @@ export function RoadmapTab({ data }: RoadmapTabProps) {
|
||||
<div className="p-4 space-y-4">
|
||||
{/* ENFOQUE DUAL: Párrafo explicativo */}
|
||||
{recType === 'DUAL' && (
|
||||
<p className="text-sm text-gray-600 leading-relaxed">
|
||||
La Estrategia Dual consiste en ejecutar dos líneas de trabajo en paralelo:
|
||||
<strong className="text-gray-800"> Quick Win</strong> automatiza inmediatamente las {pilotQueues.length} colas
|
||||
ya preparadas (Tier AUTOMATE, {Math.round(totalVolume > 0 ? (tierVolumes.AUTOMATE / totalVolume) * 100 : 0)}% del volumen), generando retorno desde el primer mes;
|
||||
mientras que <strong className="text-gray-800">Foundation</strong> prepara el {Math.round(assistPct + augmentPct)}%
|
||||
restante del volumen (Tiers ASSIST y AUGMENT) estandarizando procesos y reduciendo variabilidad para habilitar
|
||||
automatización futura. Este enfoque maximiza el time-to-value: Quick Win financia la transformación y genera
|
||||
confianza organizacional, mientras Foundation amplía progresivamente el alcance de la automatización.
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 leading-relaxed" dangerouslySetInnerHTML={{
|
||||
__html: t('roadmap.dualStrategy.explanation', {
|
||||
count: pilotQueues.length,
|
||||
pct: Math.round(totalVolume > 0 ? (tierVolumes.AUTOMATE / totalVolume) * 100 : 0),
|
||||
remaining: Math.round(assistPct + augmentPct)
|
||||
})
|
||||
}} />
|
||||
)}
|
||||
|
||||
{/* FOUNDATION PRIMERO */}
|
||||
|
||||
@@ -11,8 +11,8 @@ i18n
|
||||
es: { translation: es },
|
||||
en: { translation: en },
|
||||
},
|
||||
lng: localStorage.getItem('language') || 'es', // Español por defecto
|
||||
fallbackLng: 'es', // Si falla una traducción, usa español
|
||||
lng: localStorage.getItem('language') || 'en', // English by default
|
||||
fallbackLng: 'en', // If translation fails, use English
|
||||
interpolation: {
|
||||
escapeValue: false, // React ya escapa por defecto
|
||||
},
|
||||
|
||||
@@ -532,7 +532,301 @@
|
||||
"hideDetail": "Hide detail",
|
||||
"viewDetail": "View detail",
|
||||
"collapseAll": "Collapse all",
|
||||
"expandAll": "Expand all"
|
||||
"expandAll": "Expand all",
|
||||
"tierLabels": {
|
||||
"automate": "Automate",
|
||||
"assist": "Assist",
|
||||
"augment": "Optimize",
|
||||
"human": "Human"
|
||||
},
|
||||
"payback": {
|
||||
"seeWave34": "See Wave 3-4",
|
||||
"notRecoverable": "Not recoverable",
|
||||
"immediate": "Immediate",
|
||||
"recoversWithAutomation": "This investment is recovered with automation waves (W3-W4). Payback is calculated on the complete roadmap, not on enabling waves in isolation.",
|
||||
"savingsDoNotCoverRecurring": "Annual savings do not cover recurring costs.",
|
||||
"savingsDoNotCoverRecurringWithMargin": "Annual savings do not cover recurring costs. Net margin: {{margin}}/year",
|
||||
"implementationRecoveryMargin": "Implementation: {{impl}} months → Recovery: {{rec}} months. Margin: {{margin}}/year.",
|
||||
"moderateRecoveryPeriod": "Moderate recovery period.",
|
||||
"longRecoveryPeriod": "Long recovery period. Consider less ambitious scenario.",
|
||||
"roiValidateWithPilot": "High ROI - validate with pilot"
|
||||
},
|
||||
"waves": {
|
||||
"wave1Name": "Wave 1",
|
||||
"wave1Title": "FOUNDATION",
|
||||
"wave1Quarter": "Q1-Q2 2026",
|
||||
"wave1Condition": "",
|
||||
"wave1Provider": "Beyond Consulting or specialized third party",
|
||||
"wave1RiskDescription": "Consulting with tangible deliverables. Does not require technology.",
|
||||
"wave2Name": "Wave 2",
|
||||
"wave2Title": "AUGMENT",
|
||||
"wave2Quarter": "Q3 2026",
|
||||
"wave2Condition": "Requires CV ≤75% post-Wave 1 in target queues",
|
||||
"wave2Provider": "BEYOND (KB + AI Scripts)",
|
||||
"wave2RiskDescription": "Support tools, low integration risk.",
|
||||
"wave3Name": "Wave 3",
|
||||
"wave3Title": "ASSIST",
|
||||
"wave3Quarter": "Q4 2026",
|
||||
"wave3Condition": "Requires Score ≥5.5 AND CV ≤90% AND Transfer ≤30%",
|
||||
"wave3Provider": "BEYOND (Copilot + AI Routing)",
|
||||
"wave3RiskDescription": "Integration with contact center platform. 4-week pilot mitigates.",
|
||||
"wave4Name": "Wave 4",
|
||||
"wave4Title": "AUTOMATE",
|
||||
"wave4Quarter": "Q1-Q2 2027",
|
||||
"wave4Condition": "Requires Score ≥7.5 AND CV ≤75% AND Transfer ≤20% AND FCR ≥50%",
|
||||
"wave4Provider": "BEYOND (Voicebot + IVR + Chatbot)",
|
||||
"wave4RiskDescription": "Very conditional. Requires demonstrated success in Waves 1-3."
|
||||
},
|
||||
"initiatives": {
|
||||
"wave1Init1": "Variability and red flags analysis",
|
||||
"wave1Init1Kpi": "Map causes of CV >75% and Transfer >20%",
|
||||
"wave1Init2": "Process redesign and documentation",
|
||||
"wave1Init2Kpi": "Standardized scripts for 80% of cases",
|
||||
"wave1Init3": "Agent training and certification",
|
||||
"wave1Init3Kpi": "90% agent certification, >85% adherence",
|
||||
"wave2Init1": "Contextual Knowledge Base",
|
||||
"wave2Init1Kpi": "Hold time -25%, KB usage +40%",
|
||||
"wave2Init2": "AI-powered dynamic scripts",
|
||||
"wave2Init2Kpi": "Script adherence +30%",
|
||||
"wave3Init1": "Agent Assist / AI Copilot",
|
||||
"wave3Init1Kpi": "AHT -30%, suggestions accepted >60%",
|
||||
"wave3Init2": "Partial automation (FAQs, routing)",
|
||||
"wave3Init2Kpi": "Deflection rate 15%",
|
||||
"wave4Init1": "Transactional Voicebot/Chatbot",
|
||||
"wave4Init1Kpi": "Containment 70%+, CSAT ≥4/5",
|
||||
"wave4Init2": "Intelligent IVR with NLU",
|
||||
"wave4Init2Kpi": "Pre-qualification 80%+, warm transfer"
|
||||
},
|
||||
"successCriteriaTemplates": {
|
||||
"wave1Criterion1": "CV AHT ≤75% in at least {{count}} high-volume queues",
|
||||
"wave1Criterion2": "Transfer ≤20% global",
|
||||
"wave1Criterion3": "Red flags eliminated in priority queues",
|
||||
"wave1Criterion4": "At least {{count}} queues migrate from Tier 4 → Tier 3",
|
||||
"wave2Criterion1": "Average score rises from 3.5-5.5 → ≥5.5",
|
||||
"wave2Criterion2": "AHT -15% vs baseline",
|
||||
"wave2Criterion3": "CV ≤90% in target queues",
|
||||
"wave2Criterion4": "{{count}} queues migrate from Tier 3 → Tier 2",
|
||||
"wave3Criterion1": "Average score rises from 5.5-7.5 → ≥7.5",
|
||||
"wave3Criterion2": "AHT -30% vs Wave 2 baseline",
|
||||
"wave3Criterion3": "CV ≤75% in target queues",
|
||||
"wave3Criterion4": "Transfer ≤20%",
|
||||
"wave3Criterion5": "{{count}} queues migrate from Tier 2 → Tier 1",
|
||||
"wave4Criterion1": "Containment ≥70% in automated queues",
|
||||
"wave4Criterion2": "CSAT maintains or improves (≥4/5)",
|
||||
"wave4Criterion3": "Escalation to human <30%",
|
||||
"wave4Criterion4": "Cumulative ROI >300%"
|
||||
},
|
||||
"scenarios": {
|
||||
"conservativeName": "Conservative",
|
||||
"conservativeDesc": "FOUNDATION + AUGMENT (Wave 1-2)",
|
||||
"moderateName": "Moderate",
|
||||
"moderateDesc": "FOUNDATION + AUGMENT + ASSIST (Wave 1-3)",
|
||||
"aggressiveName": "Aggressive",
|
||||
"aggressiveDesc": "Complete roadmap (Wave 1-4)",
|
||||
"recommended": "Recommended",
|
||||
"enablerRecommendation": "Recommended as ENABLER",
|
||||
"partialEnabler": "Partial enabler",
|
||||
"aspirational": "Aspirational",
|
||||
"notProfitable": "Not profitable with current volume",
|
||||
"scenariosTitle": "Investment Scenarios",
|
||||
"scenariosSubtitle": "Comparison of options according to commitment level",
|
||||
"scenariosTooltip": "ROI based on industry benchmarks. Adjusted ROI considers implementation risk factors.",
|
||||
"scenario": "Scenario",
|
||||
"investment": "Investment",
|
||||
"recurring": "Recurring",
|
||||
"savings": "Savings",
|
||||
"adjusted": "adjusted",
|
||||
"margin": "Margin",
|
||||
"payback": "Payback",
|
||||
"roi3y": "3y ROI",
|
||||
"risk": "Risk",
|
||||
"enabler": "Enabler",
|
||||
"prerequisite": "Prerequisite",
|
||||
"roiCalculatedOn": "ROI calculated on",
|
||||
"enablerLongDesc": "Enabling waves whose value lies in unlocking subsequent waves. Their payback is evaluated on the complete roadmap.",
|
||||
"paybackNote": "Payback: Implementation time + recovery time. Wave 1: 6m, W2: 3m, W3: 3m, W4: 6m. Savings begin at 50% of last wave.",
|
||||
"roiNote": "ROI: (3y Savings - 3y Total Cost) / 3y Total Cost × 100. Adjusted applies risk: W1-2: 75-90%, W3: 60%, W4: 50%.",
|
||||
"enablerNote": "Enabler: Waves that unlock ROI of subsequent waves. Their payback is evaluated with the complete roadmap.",
|
||||
"enablerValue": "Real value of this investment:",
|
||||
"enablerUnlocks": "Unlocks {{amount}}/year in {{waves}}. Without this foundation, subsequent waves are not viable.",
|
||||
"unlocks": "Unlocks {{waves}}",
|
||||
"enablesAmount": "enables {{amount}}",
|
||||
"startConservative": "Start with conservative scenario to validate model before scaling."
|
||||
},
|
||||
"decisionGates": {
|
||||
"gate1Question": "CV ≤75% in 3+ queues?",
|
||||
"gate1Criteria": "Red flags eliminated, Tier 4→3",
|
||||
"gate1GoAction": "Start AUGMENT",
|
||||
"gate1NoGoAction": "Extend FOUNDATION",
|
||||
"gate2Question": "Score ≥5.5 in target?",
|
||||
"gate2Criteria": "CV ≤90%, Transfer ≤30%",
|
||||
"gate2GoAction": "Start ASSIST",
|
||||
"gate2NoGoAction": "Consolidate AUGMENT",
|
||||
"gate3Question": "Score ≥7.5 in 2+ queues?",
|
||||
"gate3Criteria": "CV ≤75%, FCR ≥50%",
|
||||
"gate3GoAction": "Launch AUTOMATE",
|
||||
"gate3NoGoAction": "Expand ASSIST",
|
||||
"goNoGo": "Go/No-Go",
|
||||
"criteria": "Criteria:",
|
||||
"go": "Go:",
|
||||
"no": "No:"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Transformation Roadmap 2026-2027",
|
||||
"subtitle": "Each wave depends on the success of the previous one. Decision points allow adjustment based on actual results.",
|
||||
"setup": "Setup:",
|
||||
"savings": "Savings:",
|
||||
"conditional": "Conditional",
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High",
|
||||
"legendConfirmed": "Confirmed wave",
|
||||
"legendConditional": "Conditional wave",
|
||||
"legendDecisionPoint": "Go/No-Go decision point",
|
||||
"legendRisk": "= Risk"
|
||||
},
|
||||
"comparison": {
|
||||
"title": "Investment Scenarios",
|
||||
"subtitle": "Comparison of options by commitment level",
|
||||
"tooltip": "ROI based on industry benchmarks. Adjusted ROI considers implementation risk factors.",
|
||||
"investment": "Investment",
|
||||
"recurring": "Recurring",
|
||||
"savings": "Savings",
|
||||
"adjusted": "adjusted",
|
||||
"margin": "Margin",
|
||||
"payback": "Payback",
|
||||
"roi3y": "3y ROI",
|
||||
"risk": "Risk",
|
||||
"scenario": "Scenario",
|
||||
"recommendation": "Recommendation",
|
||||
"recommendationEnabler": "Recommendation (Enabler)",
|
||||
"enabler": "Enabler",
|
||||
"recommended": "Recommended",
|
||||
"savingsPerYear": "Savings/year",
|
||||
"marginPerYear": "Margin/year",
|
||||
"savingsLabel": "Savings:",
|
||||
"conditional": "Conditional",
|
||||
"riskLow": "Low",
|
||||
"riskMedium": "Medium",
|
||||
"riskHigh": "High",
|
||||
"enablerWaveTooltip": "Enabler wave - its value is in unlocking subsequent waves",
|
||||
"negativeMarginTooltip": "Negative annual margin",
|
||||
"projectedRoiTooltip": "Projected ROI. Validate with pilot.",
|
||||
"adjustedRoiTooltip": "ROI adjusted for implementation risk"
|
||||
},
|
||||
"entryCriteria": {
|
||||
"wave1TierFrom": "HUMAN-ONLY (4), AUGMENT (3)",
|
||||
"wave1ScoreRange": "<5.5",
|
||||
"wave1Metric1": "CV >75% or Transfer >20%",
|
||||
"wave1Metric2": "Active Red Flags",
|
||||
"wave1Metric3": "Undocumented processes",
|
||||
"wave2TierFrom": "AUGMENT (3)",
|
||||
"wave2ScoreRange": "3.5-5.5",
|
||||
"wave2Metric1": "CV ≤75%",
|
||||
"wave2Metric2": "Transfer ≤20%",
|
||||
"wave2Metric3": "No Red Flags",
|
||||
"wave3TierFrom": "ASSIST (2)",
|
||||
"wave3ScoreRange": "5.5-7.5",
|
||||
"wave3Metric1": "CV ≤90%",
|
||||
"wave3Metric2": "Transfer ≤30%",
|
||||
"wave3Metric3": "Stable AHT",
|
||||
"wave4TierFrom": "AUTOMATE (1)",
|
||||
"wave4ScoreRange": "≥7.5",
|
||||
"wave4Metric1": "CV ≤75%",
|
||||
"wave4Metric2": "Transfer ≤20%",
|
||||
"wave4Metric3": "FCR ≥50%",
|
||||
"wave4Metric4": "No Red Flags"
|
||||
},
|
||||
"exitCriteria": {
|
||||
"wave1TierTo": "AUGMENT (3) minimum",
|
||||
"wave1ScoreTarget": "≥3.5",
|
||||
"wave1Kpi1": "CV ≤75%",
|
||||
"wave1Kpi2": "Transfer ≤20%",
|
||||
"wave1Kpi3": "Red flags eliminated",
|
||||
"wave2TierTo": "ASSIST (2)",
|
||||
"wave2ScoreTarget": "≥5.5",
|
||||
"wave2Kpi1": "CV ≤90%",
|
||||
"wave2Kpi2": "Transfer ≤30%",
|
||||
"wave2Kpi3": "AHT -15%",
|
||||
"wave3TierTo": "AUTOMATE (1)",
|
||||
"wave3ScoreTarget": "≥7.5",
|
||||
"wave3Kpi1": "CV ≤75%",
|
||||
"wave3Kpi2": "Transfer ≤20%",
|
||||
"wave3Kpi3": "FCR ≥50%",
|
||||
"wave3Kpi4": "AHT -30%",
|
||||
"wave4TierTo": "AUTOMATED",
|
||||
"wave4ScoreTarget": "Containment ≥70%",
|
||||
"wave4Kpi1": "Bot resolution ≥70%",
|
||||
"wave4Kpi2": "CSAT ≥4/5",
|
||||
"wave4Kpi3": "Escalation <30%"
|
||||
},
|
||||
"recommendations": {
|
||||
"conservativeEnabler": "✅ Recommended as ENABLER. Unlocks {{amount}}/year in Wave 3-4. Objective: move {{count}} queues from Tier 4→3.",
|
||||
"conservativeNormal": "✅ Recommended. Validate model with low risk. Objective: move {{count}} queues from Tier 4→3.",
|
||||
"moderateEnabler": "Partial enabler. Unlocks {{amount}}/year in Wave 4. Decide Go/No-Go in Q3 2026.",
|
||||
"moderateNormal": "Decide Go/No-Go in Q3 2026 based on Wave 1-2 results. Requires Score ≥5.5 in target queues.",
|
||||
"aggressivePositive": "⚠️ Aspirational. Only if Waves 1-3 successful and queues with Score ≥7.5 exist. Decision in Q1 2027.",
|
||||
"aggressiveNegative": "❌ Not profitable with current volume. Requires significantly greater scale."
|
||||
},
|
||||
"table": {
|
||||
"topQueuesByVolumeImpact": "Top Queues by Volume × Impact",
|
||||
"queue": "Queue",
|
||||
"volPerMonth": "Vol/month",
|
||||
"score": "Score",
|
||||
"tier": "Tier",
|
||||
"redFlags": "Red Flags",
|
||||
"potential": "Potential",
|
||||
"redFlagsNote": "Red Flags: CV >120% (high variability) · Transfer >50% (fragmented process) · Vol <50 (small sample) · Valid <30% (noisy data)",
|
||||
"skills": "Skills",
|
||||
"entry": "ENTRY",
|
||||
"exit": "EXIT",
|
||||
"tierLabel": "Tier:",
|
||||
"scoreLabel": "Score:",
|
||||
"financialMetrics": "Financial Metrics",
|
||||
"setupLabel": "Setup",
|
||||
"recurringPerYear": "Recurring/year",
|
||||
"savingsPerYear": "Savings/year",
|
||||
"marginPerYear": "Margin/year",
|
||||
"initiativesLabel": "Initiatives:",
|
||||
"setup": "Setup:",
|
||||
"rec": "Rec:",
|
||||
"kpi": "KPI:",
|
||||
"successCriteriaLabel": "✅ Success criteria:",
|
||||
"condition": "⚠️ Condition:",
|
||||
"provider": "Provider:"
|
||||
},
|
||||
"porQueNecesarioTemplates": {
|
||||
"wave1": "{{count}} of {{total}} queues are in Tier 3-4 ({{pct}}% of volume). Red flags: CV >75%, Transfer >20%. Automating without standardizing = guaranteed failure.",
|
||||
"wave2": "Implement support tools for Tier 3 queues (Score 3.5-5.5). Objective: raise score to ≥5.5 to enable Wave 3. Focus on {{count}} queues with {{volume}} int/month.",
|
||||
"wave3": "AI Copilot for agents in Tier 2 queues. Real-time suggestions, autocomplete, next-best-action. Objective: raise score to ≥7.5 for Wave 4. Target: {{count}} queues with {{volume}} int/month.",
|
||||
"wave4": "End-to-end automation for Tier 1 queues. Transactional Voicebot/Chatbot with 70% containment. Only viable with mature processes. Current target: {{count}} queues with {{volume}} int/month."
|
||||
},
|
||||
"fallbackSkills": {
|
||||
"wave1": "Queues that reach Score 3.5-5.5 post Wave 1",
|
||||
"wave2": "Queues that reach Score ≥5.5 post Wave 2",
|
||||
"wave3": "Queues that reach Score ≥7.5 post Wave 3"
|
||||
},
|
||||
"wave2Description": {
|
||||
"ready": "{{skill}} is the skill with the best Score ({{score}}/10, \"Copilot\" category). Volume {{volume}}/year = greatest economic impact.",
|
||||
"notReady": "No skill currently reaches Score ≥6. The best candidate is {{skill}} with Score {{score}}/10. Requires prior optimization in Wave 1."
|
||||
},
|
||||
"specificRecommendations": {
|
||||
"launchWave4": "Launch Wave 4 (AUTOMATE) pilot",
|
||||
"launchWave4Rationale": "{{count}} queues already have Score ≥7.5 with volume of {{volume}} int/month.",
|
||||
"launchWave4NextStep": "Start automation pilot in the 2-3 highest-volume queues with savings potential of {{amount}}/year.",
|
||||
"initiateWave3": "Initiate Wave 3 (ASSIST) with Copilot",
|
||||
"initiateWave3Rationale": "{{count}} queues have Score 5.5-7.5, representing {{pct}}% of volume.",
|
||||
"initiateWave3NextStep": "Deploy AI Copilot in Tier 2 queues to raise score to ≥7.5 and enable Wave 4. Investment: {{amount}}.",
|
||||
"prioritizeWave1": "Prioritize Wave 1 (FOUNDATION)",
|
||||
"prioritizeWave1Rationale": "{{count}} queues ({{pct}}%) have Red Flags that prevent automation.",
|
||||
"prioritizeWave1NextStep": "Standardize processes before investing in AI. Automation without solid foundations fails in 80%+ of cases.",
|
||||
"executeWave12": "Execute Wave 1-2 sequentially",
|
||||
"executeWave12Rationale": "Mixed operation: {{automate}} Tier 1 queues, {{assist}} Tier 2, {{augment}} Tier 3, {{human}} Tier 4.",
|
||||
"executeWave12NextStep": "Start with FOUNDATION to eliminate red flags, followed by AUGMENT to raise scores. Initial investment: {{amount}}."
|
||||
},
|
||||
"dualStrategy": {
|
||||
"explanation": "The Dual Strategy consists of executing two parallel work streams: <strong>Quick Win</strong> immediately automates the {{count}} ready queues (Tier AUTOMATE, {{pct}}% of volume), generating returns from the first month; while <strong>Foundation</strong> prepares the remaining {{remaining}}% of volume (Tiers ASSIST and AUGMENT) by standardizing processes and reducing variability to enable future automation. This approach maximizes time-to-value: Quick Win finances the transformation and generates organizational confidence, while Foundation progressively expands the scope of automation."
|
||||
}
|
||||
},
|
||||
"opportunities": {
|
||||
"viewCriticalActions": "View Critical Actions",
|
||||
@@ -570,12 +864,16 @@
|
||||
"humanOnlyAction": "Maintain human management, evaluate periodically",
|
||||
"redFlags": {
|
||||
"cvCritical": "Critical AHT CV",
|
||||
"cvCriticalShort": "CV",
|
||||
"cvCriticalDesc": "Extreme variability - unpredictable processes",
|
||||
"transferExcessive": "Excessive Transfer",
|
||||
"transferExcessiveShort": "Transfer",
|
||||
"transferExcessiveDesc": "High complexity - requires frequent escalation",
|
||||
"volumeInsufficient": "Insufficient Volume",
|
||||
"volumeInsufficientShort": "Vol",
|
||||
"volumeInsufficientDesc": "Negative ROI - volume doesn't justify investment",
|
||||
"dataQualityLow": "Low Data Quality",
|
||||
"dataQualityLowShort": "Valid",
|
||||
"dataQualityLowDesc": "Unreliable data - distorted metrics",
|
||||
"threshold": "(threshold: {{operator}}{{value}})"
|
||||
},
|
||||
@@ -799,7 +1097,38 @@
|
||||
"standardizeProcesses": "Standardize processes and scripts",
|
||||
"simplifyFlow": "Simplify flow, train agents",
|
||||
"consolidate": "Consolidate with similar queues",
|
||||
"improveDataCapture": "Improve data capture"
|
||||
"improveDataCapture": "Improve data capture",
|
||||
"skillsWithRedFlags": "Skills with Red Flags",
|
||||
"queuesRequireIntervention": "Queues that require intervention before automating",
|
||||
"viewRoadmapTab": "View Roadmap tab for detailed plan",
|
||||
"viewRoadmapLink": "View Roadmap tab for detailed plan →"
|
||||
},
|
||||
"roadmapConnection": {
|
||||
"quickWinsTitle": "IMMEDIATE QUICK WINS (without Wave 1)",
|
||||
"automateQueues": "{{count}} AUTOMATE queues",
|
||||
"with": "with",
|
||||
"interactionsPerMonth": "interactions/month",
|
||||
"savingsPotential": "Savings potential:",
|
||||
"perYear": "/year",
|
||||
"containment": "containment",
|
||||
"perInt": "/int",
|
||||
"skills": "Skills:",
|
||||
"alignedWithWave4": "→ Aligned with Roadmap Wave 4. Can be implemented in parallel to Wave 1.",
|
||||
"wave13Title": "WAVE 1-3: FOUNDATION → ASSIST ({{count}} queues)",
|
||||
"inTierAssist": "in tier ASSIST",
|
||||
"focusWave1": "Wave 1 Focus:",
|
||||
"reduceTransferIn": "Reduce transfer in",
|
||||
"potentialWithCopilot": "Potential with Copilot:",
|
||||
"deflection": "deflection",
|
||||
"requiresWave1": "→ Requires Wave 1 (Foundation) to enable Copilot in Wave 3",
|
||||
"calculationNote": "Calculation: {{volume}} int × 12 months × {{rate}}% {{type}} × €{{cpi}}/int",
|
||||
"quickWinsHaveVolume": "have >60% volume in T1+T2",
|
||||
"hasPercentInHuman": "has {{pct}}% in HUMAN",
|
||||
"prioritizeInWave1": "→ prioritize in Wave 1",
|
||||
"balancedDistribution": "Balanced distribution across tiers. Review individual queues for prioritization.",
|
||||
"haveAtLeastOne": "have at least one tier AUTOMATE queue",
|
||||
"showLess": "Show less",
|
||||
"viewAll": "View all ({{count}})"
|
||||
},
|
||||
"factorsExtended": {
|
||||
"volumeMethodology": "Score = normalized log10(Volume). >5000 → 10, <100 → 2",
|
||||
@@ -814,6 +1143,123 @@
|
||||
"roiBad": "Marginal ROI, evaluate other benefits",
|
||||
"resolution": "Resolution",
|
||||
"dataQuality": "Data Quality"
|
||||
},
|
||||
"factorConfigs": {
|
||||
"predictability": {
|
||||
"title": "Predictability",
|
||||
"description": "Consistency in handling times",
|
||||
"methodology": "Score = 10 - (CV_AHT / 10). CV AHT < 30% → Score > 7",
|
||||
"benchmark": "Optimal CV AHT < 25%",
|
||||
"highImplication": "Consistent times, ideal for AI",
|
||||
"lowImplication": "Requires standardization"
|
||||
},
|
||||
"inverseComplexity": {
|
||||
"title": "Simplicity",
|
||||
"description": "Low level of human judgment required",
|
||||
"methodology": "Score = 10 - (Transfer_Rate × 0.4). Transfer <10% → Score > 6",
|
||||
"benchmark": "Optimal transfers <10%",
|
||||
"highImplication": "Simple processes, automatable",
|
||||
"lowImplication": "High complexity, requires copilot"
|
||||
},
|
||||
"repeatability": {
|
||||
"title": "Volume",
|
||||
"description": "Scale to justify investment",
|
||||
"methodology": "Score = normalized log10(Volume). >5000 → 10, <100 → 2",
|
||||
"benchmark": "Positive ROI requires >500/month",
|
||||
"highImplication": "High volume justifies investment",
|
||||
"lowImplication": "Consider shared solutions"
|
||||
},
|
||||
"roiPotential": {
|
||||
"title": "ROI Potential",
|
||||
"description": "Expected economic return",
|
||||
"methodology": "Score based on total annual cost. >€500K → 10",
|
||||
"benchmark": "ROI >150% at 12 months",
|
||||
"highImplication": "Solid business case",
|
||||
"lowImplication": "Marginal ROI, evaluate other benefits"
|
||||
}
|
||||
},
|
||||
"scoreBreakdown": {
|
||||
"predictability": "Predictability (30%)",
|
||||
"resolution": "Resolution (25%)",
|
||||
"volume": "Volume (25%)",
|
||||
"dataQuality": "Data Quality (10%)",
|
||||
"simplicity": "Simplicity (10%)"
|
||||
},
|
||||
"bubbleChart": {
|
||||
"quickWinsCount": "{{count}} queues · {{amount}}",
|
||||
"highPotentialCount": "{{count}} queues · {{amount}}",
|
||||
"developCount": "{{count}} queues · {{amount}}",
|
||||
"easyImplCount": "{{count}} · {{amount}}",
|
||||
"backlogCount": "{{count}} · {{amount}}",
|
||||
"total": "total",
|
||||
"noQueuesFilters": "No queues match the selected filters",
|
||||
"quickWinsLabel": "QUICK WINS",
|
||||
"highPotentialLabel": "HIGH POTENTIAL",
|
||||
"developLabel": "DEVELOP",
|
||||
"easyImplLabel": "EASY IMPL.",
|
||||
"backlogLabel": "BACKLOG",
|
||||
"activeFiltersLabel": "Active filters:",
|
||||
"ofQueues": "of {{total}} queues",
|
||||
"perMonth": "/month",
|
||||
"cvAht": "CV AHT:",
|
||||
"viewDetail": "Click for details"
|
||||
},
|
||||
"modal": {
|
||||
"skillLabel": "Skill:",
|
||||
"transferRate": "Transfer Rate",
|
||||
"annualSavings": "Annual Savings"
|
||||
},
|
||||
"volumeLabels": {
|
||||
"queues": "queues",
|
||||
"int": "int"
|
||||
},
|
||||
"subFactors": {
|
||||
"repeatability": "Repeatability",
|
||||
"repeatabilityDisplayName": "Repeatability",
|
||||
"repeatabilityDescription": "Monthly volume: {{volume}} interactions",
|
||||
"predictability": "Predictability",
|
||||
"predictabilityDisplayName": "Predictability",
|
||||
"predictabilityDescription": "AHT CV: {{cv}}%, Escalation: {{esc}}%",
|
||||
"structuring": "Structuring",
|
||||
"structuringDisplayName": "Structuring",
|
||||
"structuringDescription": "{{pct}}% structured fields",
|
||||
"inverseComplexity": "Inverse Complexity",
|
||||
"inverseComplexityDisplayName": "Inverse Complexity",
|
||||
"inverseComplexityDescription": "{{pct}}% exceptions",
|
||||
"stability": "Stability",
|
||||
"stabilityDisplayName": "Stability",
|
||||
"stabilityDescription": "{{pct}}% off-hours",
|
||||
"roiSavings": "ROI",
|
||||
"roiSavingsDisplayName": "ROI",
|
||||
"roiSavingsDescription": "€{{amount}}K annual potential savings",
|
||||
"interpretations": {
|
||||
"excellentForAutomation": "Excellent candidate for complete automation (Automate)",
|
||||
"goodForAssistance": "Good candidate for agentic assistance (Assist)",
|
||||
"candidateForAugmentation": "Candidate for human augmentation (Augment)",
|
||||
"notRecommended": "Not recommended for automation at this time",
|
||||
"bronzeAnalysis": "Bronze analysis does not include Agentic Readiness Score"
|
||||
}
|
||||
},
|
||||
"emptyStates": {
|
||||
"noQueuesClassifiedAs": "No queues classified as {{tier}}",
|
||||
"noQueuesMatchFilters": "No queues match the selected filters"
|
||||
},
|
||||
"sections": {
|
||||
"classificationBySkill": "CLASSIFICATION BY SKILL",
|
||||
"classificationByTier": "CLASSIFICATION BY AUTOMATION TIER",
|
||||
"queuesAutomate": "Queues AUTOMATE",
|
||||
"readyForFullAutomation": "Ready for full automation with virtual agent (Score ≥7.5)",
|
||||
"queuesAssist": "Queues ASSIST",
|
||||
"candidatesForCopilot": "Candidates for Copilot - AI assists human agent (Score 5.5-7.5)",
|
||||
"queuesAugment": "Queues AUGMENT",
|
||||
"requireOptimization": "Require prior optimization: standardize processes, reduce variability (Score 3.5-5.5)",
|
||||
"queuesHumanOnly": "Queues HUMAN-ONLY",
|
||||
"notSuitableForAutomation": "Not suitable for automation: insufficient volume, low data quality, or extreme complexity",
|
||||
"queuesIn": "queues in {{count}} skills",
|
||||
"costPerYear": "/year",
|
||||
"volumeColon": "Volume:",
|
||||
"costColon": "Cost:",
|
||||
"potentialSavingsColon": "Potential savings:"
|
||||
}
|
||||
},
|
||||
"economicModel": {
|
||||
@@ -1312,6 +1758,142 @@
|
||||
"requiredData": "Requires data",
|
||||
"score": "Score",
|
||||
"gap": "Gap"
|
||||
},
|
||||
"summaryTable": {
|
||||
"requirement": "Requirement",
|
||||
"description": "Description",
|
||||
"status": "Status",
|
||||
"score": "Score",
|
||||
"gap": "Gap",
|
||||
"legend": {
|
||||
"complies": "Complies: Requirement satisfied",
|
||||
"partial": "Partial: Requires improvements",
|
||||
"notComply": "Does Not Comply: Urgent action",
|
||||
"noData": "No Data: Fields not available in CSV"
|
||||
},
|
||||
"investment": {
|
||||
"nonComplianceCost": "Cost of non-compliance",
|
||||
"upTo100k": "Up to 100K",
|
||||
"potentialFines": "Potential fines/infraction",
|
||||
"recommendedInvestment": "Recommended investment",
|
||||
"basedOnOperation": "Based on your operation",
|
||||
"complianceRoi": "Compliance ROI",
|
||||
"avoidSanctions": "Avoid sanctions + improve CX"
|
||||
}
|
||||
},
|
||||
"dataMaturity": {
|
||||
"title": "Summary: Data Maturity for Compliance",
|
||||
"currentLevel": "Your current instrumentation level:",
|
||||
"availableData": "AVAILABLE DATA (3/10)",
|
||||
"estimableData": "ESTIMABLE DATA (2/10)",
|
||||
"unavailableData": "NOT AVAILABLE (5/10)",
|
||||
"items": {
|
||||
"coverage247": "24/7 temporal coverage",
|
||||
"geoDistribution": "Geographic distribution",
|
||||
"resolutionQuality": "Resolution quality proxy",
|
||||
"asa3min": "ASA <3min via abandonment proxy",
|
||||
"officialLanguages": "Co-official languages via country",
|
||||
"caseResolutionTime": "Case resolution time",
|
||||
"undueBilling": "Undue billing <5 days",
|
||||
"supervisorTransfer": "Transfer to supervisor",
|
||||
"incidentInfo": "Incident info <2h",
|
||||
"enacAudit": "ENAC audit",
|
||||
"externalContractRequired": "requires external contracting"
|
||||
},
|
||||
"investment": {
|
||||
"title": "SUGGESTED INVESTMENT FOR COMPLETE COMPLIANCE",
|
||||
"phase1": {
|
||||
"title": "Phase 1 - Instrumentation (Q1 2026)",
|
||||
"realAsaTracking": "• Real ASA tracking",
|
||||
"ticketingSystem": "• Ticketing/case system",
|
||||
"languageEnrichment": "• Language enrichment",
|
||||
"subtotal": "Subtotal:"
|
||||
},
|
||||
"phase2": {
|
||||
"title": "Phase 2 - Operations (Q2-Q3 2026)",
|
||||
"coverage247": "• 24/7 coverage (chatbot + on-call)",
|
||||
"aiCopilot": "• AI Copilot (reduce AHT)",
|
||||
"enacAuditor": "• ENAC auditor",
|
||||
"subtotalYear1": "Year 1 subtotal:"
|
||||
},
|
||||
"totals": {
|
||||
"totalInvestment": "Total Investment",
|
||||
"percentAnnualCost": "~5% annual cost",
|
||||
"riskAvoided": "Risk Avoided",
|
||||
"potentialSanctions": "potential sanctions",
|
||||
"complianceRoi": "Compliance ROI"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"opportunityPrioritizer": {
|
||||
"title": "Prioritized Opportunities",
|
||||
"subtitle": "{{count}} initiatives ordered by savings potential and feasibility",
|
||||
"whereAreOpportunities": "Where are opportunities?",
|
||||
"totalSavingsIdentified": "Total Savings Identified",
|
||||
"annual": "annual",
|
||||
"quickWins": "Quick Wins (AUTOMATE)",
|
||||
"assistance": "Assistance (ASSIST)",
|
||||
"optimization": "Optimization (AUGMENT)",
|
||||
"inMonths": "in {{months}} months",
|
||||
"startHere": "START HERE",
|
||||
"priority1": "Priority #1",
|
||||
"annualSavings": "Annual Savings",
|
||||
"volume": "Volume",
|
||||
"timeline": "Timeline",
|
||||
"months": "months",
|
||||
"nextSteps": "Next Steps",
|
||||
"allOpportunities": "All Prioritized Opportunities",
|
||||
"savings": "Savings",
|
||||
"valueEffort": "Value / Effort",
|
||||
"value": "Value",
|
||||
"effort": "Effort",
|
||||
"viewMore": "View {{count}} more opportunities",
|
||||
"methodology": "Prioritization methodology:",
|
||||
"methodologyDescription": "Opportunities are ordered by TCO savings potential (volume × containment rate × CPI differential). AUTOMATE/ASSIST/AUGMENT tier classification is based on Agentic Readiness Score considering predictability (CV AHT), resolvability (FCR + Transfer), volume, data quality and process simplicity.",
|
||||
"tierLabels": {
|
||||
"automate": "Automate",
|
||||
"assist": "Assist",
|
||||
"augment": "Augment",
|
||||
"human": "Human"
|
||||
},
|
||||
"timelines": {
|
||||
"automate": "3-6 months",
|
||||
"assist": "6-9 months",
|
||||
"augment": "9-12 months"
|
||||
},
|
||||
"tierDescriptions": {
|
||||
"automate": "Full automation with AI agents",
|
||||
"assist": "AI Copilot for human agents",
|
||||
"augment": "Process standardization and improvement",
|
||||
"humanOnly": "Requires human intervention"
|
||||
},
|
||||
"agenticScore": "Agentic Score",
|
||||
"whyPriority1": "Why is this priority #1?",
|
||||
"viewCompleteDetail": "View Complete Detail",
|
||||
"showLess": "Show less",
|
||||
"whyThisPosition": "Why this position?",
|
||||
"keyMetrics": "Key Metrics",
|
||||
"noOpportunitiesTitle": "No opportunities identified",
|
||||
"noOpportunitiesDescription": "Current data doesn't show viable automation opportunities.",
|
||||
"reasons": {
|
||||
"highSavingsPotential": "High savings potential (€{{amount}}K/year)",
|
||||
"highVolume": "High volume ({{volume}} interactions)",
|
||||
"highlyPredictable": "Highly predictable and repetitive process",
|
||||
"lowVariability": "Low variability in handling times",
|
||||
"lowTransferRate": "Low transfer rate",
|
||||
"highFeasibility": "High technical feasibility"
|
||||
},
|
||||
"steps": {
|
||||
"automate1": "Define main conversational flows",
|
||||
"automate2": "Identify necessary integrations (CRM, APIs)",
|
||||
"automate3": "Create pilot with 10% of volume",
|
||||
"assist1": "Map agent friction points",
|
||||
"assist2": "Design contextual suggestions",
|
||||
"assist3": "Pilot with selected team",
|
||||
"augment1": "Analyze root cause of variability",
|
||||
"augment2": "Standardize processes and scripts",
|
||||
"augment3": "Train team on best practices"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -532,7 +532,301 @@
|
||||
"hideDetail": "Ocultar detalle",
|
||||
"viewDetail": "Ver detalle",
|
||||
"collapseAll": "Colapsar todas",
|
||||
"expandAll": "Expandir todas"
|
||||
"expandAll": "Expandir todas",
|
||||
"tierLabels": {
|
||||
"automate": "Automatizar",
|
||||
"assist": "Asistir",
|
||||
"augment": "Optimizar",
|
||||
"human": "Humano"
|
||||
},
|
||||
"payback": {
|
||||
"seeWave34": "Ver Wave 3-4",
|
||||
"notRecoverable": "No recuperable",
|
||||
"immediate": "Inmediato",
|
||||
"recoversWithAutomation": "Esta inversión se recupera con las waves de automatización (W3-W4). El payback se calcula sobre el roadmap completo, no sobre waves habilitadoras aisladas.",
|
||||
"savingsDoNotCoverRecurring": "El ahorro anual no supera los costes recurrentes.",
|
||||
"savingsDoNotCoverRecurringWithMargin": "El ahorro anual no supera los costes recurrentes. Margen neto: {{margin}}/año",
|
||||
"implementationRecoveryMargin": "Implementación: {{impl}} meses → Recuperación: {{rec}} meses. Margen: {{margin}}/año.",
|
||||
"moderateRecoveryPeriod": "Periodo de recuperación moderado.",
|
||||
"longRecoveryPeriod": "Periodo de recuperación largo. Considerar escenario menos ambicioso.",
|
||||
"roiValidateWithPilot": "ROI alto - validar con piloto"
|
||||
},
|
||||
"waves": {
|
||||
"wave1Name": "Wave 1",
|
||||
"wave1Title": "FOUNDATION",
|
||||
"wave1Quarter": "Q1-Q2 2026",
|
||||
"wave1Condition": "",
|
||||
"wave1Provider": "Beyond Consulting o tercero especializado",
|
||||
"wave1RiskDescription": "Consultoría con entregables tangibles. No requiere tecnología.",
|
||||
"wave2Name": "Wave 2",
|
||||
"wave2Title": "AUGMENT",
|
||||
"wave2Quarter": "Q3 2026",
|
||||
"wave2Condition": "Requiere CV ≤75% post-Wave 1 en colas target",
|
||||
"wave2Provider": "BEYOND (KB + Scripts IA)",
|
||||
"wave2RiskDescription": "Herramientas de soporte, bajo riesgo de integración.",
|
||||
"wave3Name": "Wave 3",
|
||||
"wave3Title": "ASSIST",
|
||||
"wave3Quarter": "Q4 2026",
|
||||
"wave3Condition": "Requiere Score ≥5.5 Y CV ≤90% Y Transfer ≤30%",
|
||||
"wave3Provider": "BEYOND (Copilot + Routing IA)",
|
||||
"wave3RiskDescription": "Integración con plataforma contact center. Piloto 4 semanas mitiga.",
|
||||
"wave4Name": "Wave 4",
|
||||
"wave4Title": "AUTOMATE",
|
||||
"wave4Quarter": "Q1-Q2 2027",
|
||||
"wave4Condition": "Requiere Score ≥7.5 Y CV ≤75% Y Transfer ≤20% Y FCR ≥50%",
|
||||
"wave4Provider": "BEYOND (Voicebot + IVR + Chatbot)",
|
||||
"wave4RiskDescription": "Muy condicional. Requiere éxito demostrado en Waves 1-3."
|
||||
},
|
||||
"initiatives": {
|
||||
"wave1Init1": "Análisis de variabilidad y red flags",
|
||||
"wave1Init1Kpi": "Mapear causas de CV >75% y Transfer >20%",
|
||||
"wave1Init2": "Rediseño y documentación de procesos",
|
||||
"wave1Init2Kpi": "Scripts estandarizados para 80% casuística",
|
||||
"wave1Init3": "Training y certificación de agentes",
|
||||
"wave1Init3Kpi": "Certificación 90% agentes, adherencia >85%",
|
||||
"wave2Init1": "Knowledge Base contextual",
|
||||
"wave2Init1Kpi": "Hold time -25%, uso KB +40%",
|
||||
"wave2Init2": "Scripts dinámicos con IA",
|
||||
"wave2Init2Kpi": "Adherencia scripts +30%",
|
||||
"wave3Init1": "Agent Assist / Copilot IA",
|
||||
"wave3Init1Kpi": "AHT -30%, sugerencias aceptadas >60%",
|
||||
"wave3Init2": "Automatización parcial (FAQs, routing)",
|
||||
"wave3Init2Kpi": "Deflection rate 15%",
|
||||
"wave4Init1": "Voicebot/Chatbot transaccional",
|
||||
"wave4Init1Kpi": "Contención 70%+, CSAT ≥4/5",
|
||||
"wave4Init2": "IVR inteligente con NLU",
|
||||
"wave4Init2Kpi": "Pre-calificación 80%+, transferencia warm"
|
||||
},
|
||||
"successCriteriaTemplates": {
|
||||
"wave1Criterion1": "CV AHT ≤75% en al menos {{count}} colas de alto volumen",
|
||||
"wave1Criterion2": "Transfer ≤20% global",
|
||||
"wave1Criterion3": "Red flags eliminados en colas prioritarias",
|
||||
"wave1Criterion4": "Al menos {{count}} colas migran de Tier 4 → Tier 3",
|
||||
"wave2Criterion1": "Score promedio sube de 3.5-5.5 → ≥5.5",
|
||||
"wave2Criterion2": "AHT -15% vs baseline",
|
||||
"wave2Criterion3": "CV ≤90% en colas target",
|
||||
"wave2Criterion4": "{{count}} colas migran de Tier 3 → Tier 2",
|
||||
"wave3Criterion1": "Score promedio sube de 5.5-7.5 → ≥7.5",
|
||||
"wave3Criterion2": "AHT -30% vs baseline Wave 2",
|
||||
"wave3Criterion3": "CV ≤75% en colas target",
|
||||
"wave3Criterion4": "Transfer ≤20%",
|
||||
"wave3Criterion5": "{{count}} colas migran de Tier 2 → Tier 1",
|
||||
"wave4Criterion1": "Contención ≥70% en colas automatizadas",
|
||||
"wave4Criterion2": "CSAT se mantiene o mejora (≥4/5)",
|
||||
"wave4Criterion3": "Escalado a humano <30%",
|
||||
"wave4Criterion4": "ROI acumulado >300%"
|
||||
},
|
||||
"scenarios": {
|
||||
"conservativeName": "Conservador",
|
||||
"conservativeDesc": "FOUNDATION + AUGMENT (Wave 1-2)",
|
||||
"moderateName": "Moderado",
|
||||
"moderateDesc": "FOUNDATION + AUGMENT + ASSIST (Wave 1-3)",
|
||||
"aggressiveName": "Agresivo",
|
||||
"aggressiveDesc": "Roadmap completo (Wave 1-4)",
|
||||
"recommended": "Recomendado",
|
||||
"enablerRecommendation": "Recomendado como HABILITADOR",
|
||||
"partialEnabler": "Habilitador parcial",
|
||||
"aspirational": "Aspiracional",
|
||||
"notProfitable": "No rentable con el volumen actual",
|
||||
"scenariosTitle": "Escenarios de Inversión",
|
||||
"scenariosSubtitle": "Comparación de opciones según nivel de compromiso",
|
||||
"scenariosTooltip": "ROI basado en benchmarks de industria. El ROI ajustado considera factores de riesgo de implementación.",
|
||||
"scenario": "Escenario",
|
||||
"investment": "Inversión",
|
||||
"recurring": "Recurrente",
|
||||
"savings": "Ahorro",
|
||||
"adjusted": "ajust.",
|
||||
"margin": "Margen",
|
||||
"payback": "Payback",
|
||||
"roi3y": "ROI 3a",
|
||||
"risk": "Riesgo",
|
||||
"enabler": "Habilitador",
|
||||
"prerequisite": "Prerrequisito",
|
||||
"roiCalculatedOn": "El ROI se calcula sobre el roadmap completo",
|
||||
"enablerLongDesc": "Waves habilitadoras - su valor está en desbloquear waves posteriores. Su payback se evalúa con el roadmap completo.",
|
||||
"paybackNote": "Payback: Tiempo implementación + tiempo recuperación. Wave 1: 6m, W2: 3m, W3: 3m, W4: 6m. Ahorro comienza al 50% de última wave.",
|
||||
"roiNote": "ROI: (Ahorro 3a - Coste Total 3a) / Coste Total 3a × 100. Ajustado aplica riesgo: W1-2: 75-90%, W3: 60%, W4: 50%.",
|
||||
"enablerNote": "💡 Habilitador: Waves que desbloquean ROI de waves posteriores. Su payback se evalúa con el roadmap completo.",
|
||||
"enablerValue": "💡 Valor real de esta inversión:",
|
||||
"enablerUnlocks": "Desbloquea {{amount}}/año en {{waves}}. Sin esta base, las waves posteriores no son viables.",
|
||||
"unlocks": "Desbloquea {{waves}}",
|
||||
"enablesAmount": "habilita {{amount}}",
|
||||
"startConservative": "Iniciar con escenario conservador para validar modelo antes de escalar."
|
||||
},
|
||||
"decisionGates": {
|
||||
"gate1Question": "¿CV ≤75% en 3+ colas?",
|
||||
"gate1Criteria": "Red flags eliminados, Tier 4→3",
|
||||
"gate1GoAction": "Iniciar AUGMENT",
|
||||
"gate1NoGoAction": "Extender FOUNDATION",
|
||||
"gate2Question": "¿Score ≥5.5 en target?",
|
||||
"gate2Criteria": "CV ≤90%, Transfer ≤30%",
|
||||
"gate2GoAction": "Iniciar ASSIST",
|
||||
"gate2NoGoAction": "Consolidar AUGMENT",
|
||||
"gate3Question": "¿Score ≥7.5 en 2+ colas?",
|
||||
"gate3Criteria": "CV ≤75%, FCR ≥50%",
|
||||
"gate3GoAction": "Lanzar AUTOMATE",
|
||||
"gate3NoGoAction": "Expandir ASSIST",
|
||||
"goNoGo": "Go/No-Go",
|
||||
"criteria": "Criterio:",
|
||||
"go": "✓ Go:",
|
||||
"no": "✗ No:"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Roadmap de Transformación 2026-2027",
|
||||
"subtitle": "Cada wave depende del éxito de la anterior. Los puntos de decisión permiten ajustar según resultados reales.",
|
||||
"setup": "Setup:",
|
||||
"savings": "Ahorro:",
|
||||
"conditional": "Condicional",
|
||||
"low": "● Bajo",
|
||||
"medium": "● Medio",
|
||||
"high": "● Alto",
|
||||
"legendConfirmed": "Wave confirmada",
|
||||
"legendConditional": "Wave condicional",
|
||||
"legendDecisionPoint": "Punto de decisión Go/No-Go",
|
||||
"legendRisk": "= Riesgo"
|
||||
},
|
||||
"comparison": {
|
||||
"title": "Escenarios de Inversión",
|
||||
"subtitle": "Comparación de opciones según nivel de compromiso",
|
||||
"tooltip": "ROI basado en benchmarks de industria. El ROI ajustado considera factores de riesgo de implementación.",
|
||||
"investment": "Inversión",
|
||||
"recurring": "Recurrente",
|
||||
"savings": "Ahorro",
|
||||
"adjusted": "ajustado",
|
||||
"margin": "Margen",
|
||||
"payback": "Payback",
|
||||
"roi3y": "ROI 3a",
|
||||
"risk": "Riesgo",
|
||||
"scenario": "Escenario",
|
||||
"recommendation": "Recomendación",
|
||||
"recommendationEnabler": "Recomendación (Habilitador)",
|
||||
"enabler": "Habilitador",
|
||||
"recommended": "Recomendado",
|
||||
"savingsPerYear": "Ahorro/año",
|
||||
"marginPerYear": "Margen/año",
|
||||
"savingsLabel": "Ahorro:",
|
||||
"conditional": "Condicional",
|
||||
"riskLow": "Bajo",
|
||||
"riskMedium": "Medio",
|
||||
"riskHigh": "Alto",
|
||||
"enablerWaveTooltip": "Wave habilitadora - su valor está en desbloquear waves posteriores",
|
||||
"negativeMarginTooltip": "Margen anual negativo",
|
||||
"projectedRoiTooltip": "ROI proyectado. Validar con piloto.",
|
||||
"adjustedRoiTooltip": "ROI ajustado por riesgo de implementación"
|
||||
},
|
||||
"entryCriteria": {
|
||||
"wave1TierFrom": "HUMAN-ONLY (4), AUGMENT (3)",
|
||||
"wave1ScoreRange": "<5.5",
|
||||
"wave1Metric1": "CV >75% o Transfer >20%",
|
||||
"wave1Metric2": "Red Flags activos",
|
||||
"wave1Metric3": "Procesos no documentados",
|
||||
"wave2TierFrom": "AUGMENT (3)",
|
||||
"wave2ScoreRange": "3.5-5.5",
|
||||
"wave2Metric1": "CV ≤75%",
|
||||
"wave2Metric2": "Transfer ≤20%",
|
||||
"wave2Metric3": "Sin Red Flags",
|
||||
"wave3TierFrom": "ASSIST (2)",
|
||||
"wave3ScoreRange": "5.5-7.5",
|
||||
"wave3Metric1": "CV ≤90%",
|
||||
"wave3Metric2": "Transfer ≤30%",
|
||||
"wave3Metric3": "AHT estable",
|
||||
"wave4TierFrom": "AUTOMATE (1)",
|
||||
"wave4ScoreRange": "≥7.5",
|
||||
"wave4Metric1": "CV ≤75%",
|
||||
"wave4Metric2": "Transfer ≤20%",
|
||||
"wave4Metric3": "FCR ≥50%",
|
||||
"wave4Metric4": "Sin Red Flags"
|
||||
},
|
||||
"exitCriteria": {
|
||||
"wave1TierTo": "AUGMENT (3) mínimo",
|
||||
"wave1ScoreTarget": "≥3.5",
|
||||
"wave1Kpi1": "CV ≤75%",
|
||||
"wave1Kpi2": "Transfer ≤20%",
|
||||
"wave1Kpi3": "Red flags eliminados",
|
||||
"wave2TierTo": "ASSIST (2)",
|
||||
"wave2ScoreTarget": "≥5.5",
|
||||
"wave2Kpi1": "CV ≤90%",
|
||||
"wave2Kpi2": "Transfer ≤30%",
|
||||
"wave2Kpi3": "AHT -15%",
|
||||
"wave3TierTo": "AUTOMATE (1)",
|
||||
"wave3ScoreTarget": "≥7.5",
|
||||
"wave3Kpi1": "CV ≤75%",
|
||||
"wave3Kpi2": "Transfer ≤20%",
|
||||
"wave3Kpi3": "FCR ≥50%",
|
||||
"wave3Kpi4": "AHT -30%",
|
||||
"wave4TierTo": "AUTOMATIZADO",
|
||||
"wave4ScoreTarget": "Contención ≥70%",
|
||||
"wave4Kpi1": "Bot resolution ≥70%",
|
||||
"wave4Kpi2": "CSAT ≥4/5",
|
||||
"wave4Kpi3": "Escalado <30%"
|
||||
},
|
||||
"recommendations": {
|
||||
"conservativeEnabler": "✅ Recomendado como HABILITADOR. Desbloquea {{amount}}/año en Wave 3-4. Objetivo: mover {{count}} colas de Tier 4→3.",
|
||||
"conservativeNormal": "✅ Recomendado. Validar modelo con riesgo bajo. Objetivo: mover {{count}} colas de Tier 4→3.",
|
||||
"moderateEnabler": "Habilitador parcial. Desbloquea {{amount}}/año en Wave 4. Decidir Go/No-Go en Q3 2026.",
|
||||
"moderateNormal": "Decidir Go/No-Go en Q3 2026 basado en resultados Wave 1-2. Requiere Score ≥5.5 en colas target.",
|
||||
"aggressivePositive": "⚠️ Aspiracional. Solo si Waves 1-3 exitosas y hay colas con Score ≥7.5. Decisión en Q1 2027.",
|
||||
"aggressiveNegative": "❌ No rentable con el volumen actual. Requiere escala significativamente mayor."
|
||||
},
|
||||
"table": {
|
||||
"topQueuesByVolumeImpact": "Top Colas por Volumen × Impacto",
|
||||
"queue": "Cola",
|
||||
"volPerMonth": "Vol/mes",
|
||||
"score": "Score",
|
||||
"tier": "Tier",
|
||||
"redFlags": "Red Flags",
|
||||
"potential": "Potencial",
|
||||
"redFlagsNote": "Red Flags: CV >120% (alta variabilidad) · Transfer >50% (proceso fragmentado) · Vol <50 (muestra pequeña) · Valid <30% (datos ruidosos)",
|
||||
"skills": "Skills",
|
||||
"entry": "ENTRADA",
|
||||
"exit": "SALIDA",
|
||||
"tierLabel": "Tier:",
|
||||
"scoreLabel": "Score:",
|
||||
"financialMetrics": "Métricas Financieras",
|
||||
"setupLabel": "Setup",
|
||||
"recurringPerYear": "Recurrente/año",
|
||||
"savingsPerYear": "Ahorro/año",
|
||||
"marginPerYear": "Margen/año",
|
||||
"initiativesLabel": "Iniciativas:",
|
||||
"setup": "Setup:",
|
||||
"rec": "Rec:",
|
||||
"kpi": "KPI:",
|
||||
"successCriteriaLabel": "✅ Criterios de éxito:",
|
||||
"condition": "⚠️ Condición:",
|
||||
"provider": "Proveedor:"
|
||||
},
|
||||
"porQueNecesarioTemplates": {
|
||||
"wave1": "{{count}} de {{total}} colas están en Tier 3-4 ({{pct}}% del volumen). Red flags: CV >75%, Transfer >20%. Automatizar sin estandarizar = fracaso garantizado.",
|
||||
"wave2": "Implementar herramientas de soporte para colas Tier 3 (Score 3.5-5.5). Objetivo: elevar score a ≥5.5 para habilitar Wave 3. Foco en {{count}} colas con {{volume}} int/mes.",
|
||||
"wave3": "Copilot IA para agentes en colas Tier 2. Sugerencias en tiempo real, autocompletado, next-best-action. Objetivo: elevar score a ≥7.5 para Wave 4. Target: {{count}} colas con {{volume}} int/mes.",
|
||||
"wave4": "Automatización end-to-end para colas Tier 1. Voicebot/Chatbot transaccional con 70% contención. Solo viable con procesos maduros. Target actual: {{count}} colas con {{volume}} int/mes."
|
||||
},
|
||||
"fallbackSkills": {
|
||||
"wave1": "Colas que alcancen Score 3.5-5.5 post Wave 1",
|
||||
"wave2": "Colas que alcancen Score ≥5.5 post Wave 2",
|
||||
"wave3": "Colas que alcancen Score ≥7.5 post Wave 3"
|
||||
},
|
||||
"wave2Description": {
|
||||
"ready": "{{skill}} es el skill con mejor Score ({{score}}/10, categoría \"Copilot\"). Volumen {{volume}}/año = mayor impacto económico.",
|
||||
"notReady": "Ningún skill alcanza actualmente Score ≥6. El mejor candidato es {{skill}} con Score {{score}}/10. Requiere optimización previa en Wave 1."
|
||||
},
|
||||
"specificRecommendations": {
|
||||
"launchWave4": "Lanzar Wave 4 (AUTOMATE) en piloto",
|
||||
"launchWave4Rationale": "{{count}} colas ya tienen Score ≥7.5 con volumen de {{volume}} int/mes.",
|
||||
"launchWave4NextStep": "Iniciar piloto de automatización en las 2-3 colas de mayor volumen con ahorro potencial de {{amount}}/año.",
|
||||
"initiateWave3": "Iniciar Wave 3 (ASSIST) con Copilot",
|
||||
"initiateWave3Rationale": "{{count}} colas tienen Score 5.5-7.5, representando {{pct}}% del volumen.",
|
||||
"initiateWave3NextStep": "Desplegar Copilot IA en colas Tier 2 para elevar score a ≥7.5 y habilitar Wave 4. Inversión: {{amount}}.",
|
||||
"prioritizeWave1": "Priorizar Wave 1 (FOUNDATION)",
|
||||
"prioritizeWave1Rationale": "{{count}} colas ({{pct}}%) tienen Red Flags que impiden automatización.",
|
||||
"prioritizeWave1NextStep": "Estandarizar procesos antes de invertir en IA. La automatización sin fundamentos sólidos fracasa en 80%+ de casos.",
|
||||
"executeWave12": "Ejecutar Wave 1-2 secuencialmente",
|
||||
"executeWave12Rationale": "Operación mixta: {{automate}} colas Tier 1, {{assist}} Tier 2, {{augment}} Tier 3, {{human}} Tier 4.",
|
||||
"executeWave12NextStep": "Comenzar con FOUNDATION para eliminar red flags, seguido de AUGMENT para elevar scores. Inversión inicial: {{amount}}."
|
||||
},
|
||||
"dualStrategy": {
|
||||
"explanation": "La Estrategia Dual consiste en ejecutar dos líneas de trabajo en paralelo: <strong>Quick Win</strong> automatiza inmediatamente las {{count}} colas ya preparadas (Tier AUTOMATE, {{pct}}% del volumen), generando retorno desde el primer mes; mientras que <strong>Foundation</strong> prepara el {{remaining}}% restante del volumen (Tiers ASSIST y AUGMENT) estandarizando procesos y reduciendo variabilidad para habilitar automatización futura. Este enfoque maximiza el time-to-value: Quick Win financia la transformación y genera confianza organizacional, mientras Foundation amplía progresivamente el alcance de la automatización."
|
||||
}
|
||||
},
|
||||
"opportunities": {
|
||||
"viewCriticalActions": "Ver Acciones Críticas",
|
||||
@@ -570,12 +864,16 @@
|
||||
"humanOnlyAction": "Mantener gestión humana, evaluar periódicamente",
|
||||
"redFlags": {
|
||||
"cvCritical": "CV AHT Crítico",
|
||||
"cvCriticalShort": "CV",
|
||||
"cvCriticalDesc": "Variabilidad extrema - procesos impredecibles",
|
||||
"transferExcessive": "Transfer Excesivo",
|
||||
"transferExcessiveShort": "Transfer",
|
||||
"transferExcessiveDesc": "Alta complejidad - requiere escalado frecuente",
|
||||
"volumeInsufficient": "Volumen Insuficiente",
|
||||
"volumeInsufficientShort": "Vol",
|
||||
"volumeInsufficientDesc": "ROI negativo - volumen no justifica inversión",
|
||||
"dataQualityLow": "Calidad Datos Baja",
|
||||
"dataQualityLowShort": "Valid",
|
||||
"dataQualityLowDesc": "Datos poco fiables - métricas distorsionadas",
|
||||
"threshold": "(umbral: {{operator}}{{value}})"
|
||||
},
|
||||
@@ -669,10 +967,17 @@
|
||||
"strategicSkill": "Queue Skill (Estratégico)",
|
||||
"volume": "Volumen",
|
||||
"volumePerMonth": "int/mes",
|
||||
"aht": "AHT",
|
||||
"cv": "CV",
|
||||
"fcr": "FCR",
|
||||
"ahtAvg": "AHT Prom.",
|
||||
"cvAvg": "CV Prom.",
|
||||
"avgAht": "AHT Prom.",
|
||||
"avgCv": "CV Prom.",
|
||||
"savingsPotential": "Ahorro Potencial",
|
||||
"potentialSavings": "Ahorro Potencial",
|
||||
"dominantTier": "Tier Dom.",
|
||||
"tier": "Tier",
|
||||
"transfer": "Transfer",
|
||||
"redFlags": "Red Flags",
|
||||
"savingsPerMonth": "Ahorro/mes",
|
||||
@@ -694,6 +999,9 @@
|
||||
"tierAutoAssist": "(Tier AUTOMATE + ASSIST)",
|
||||
"interactions": "interacciones",
|
||||
"queuesAnalyzed": "colas analizadas",
|
||||
"volumeInIndividualQueues": "del volumen está en colas individuales",
|
||||
"balancedDistribution": "Distribución equilibrada entre tiers. Revisar colas individuales para priorización.",
|
||||
"clickToExpand": "Click en un skill para ver el detalle de colas individuales",
|
||||
"interpretation": "Interpretación:",
|
||||
"interpretationText": "El {{pct}}% representa el volumen de interacciones automatizables (AUTOMATE + ASSIST). Solo el {{queuePct}}% de las colas ({{count}} de {{total}}) son AUTOMATE, pero concentran {{volumePct}}% del volumen total. Esto indica pocas colas de alto volumen automatizables - oportunidad concentrada en Quick Wins de alto impacto.",
|
||||
"inSkills": "en {{count}} skills",
|
||||
@@ -708,6 +1016,27 @@
|
||||
"activeFilters": "Filtros activos:",
|
||||
"of": "de"
|
||||
},
|
||||
"emptyStates": {
|
||||
"noQueuesClassifiedAs": "No hay colas clasificadas como {{tier}}",
|
||||
"noQueuesMatchFilters": "No hay colas que cumplan los filtros seleccionados"
|
||||
},
|
||||
"sections": {
|
||||
"classificationBySkill": "CLASIFICACIÓN POR SKILL",
|
||||
"classificationByTier": "CLASIFICACIÓN POR TIER DE AUTOMATIZACIÓN",
|
||||
"queuesAutomate": "Colas AUTOMATE",
|
||||
"readyForFullAutomation": "Listas para automatización completa con agente virtual (Score ≥7.5)",
|
||||
"queuesAssist": "Colas ASSIST",
|
||||
"candidatesForCopilot": "Candidatas a Copilot - IA asiste al agente humano (Score 5.5-7.5)",
|
||||
"queuesAugment": "Colas AUGMENT",
|
||||
"requireOptimization": "Requieren optimización previa: estandarizar procesos, reducir variabilidad (Score 3.5-5.5)",
|
||||
"queuesHumanOnly": "Colas HUMAN-ONLY",
|
||||
"notSuitableForAutomation": "No aptas para automatización: volumen insuficiente, calidad de datos baja o complejidad extrema",
|
||||
"queuesIn": "colas en {{count}} skills",
|
||||
"costPerYear": "/año",
|
||||
"volumeColon": "Volumen:",
|
||||
"costColon": "Coste:",
|
||||
"potentialSavingsColon": "Ahorro potencial:"
|
||||
},
|
||||
"opportunityMap": {
|
||||
"title": "Mapa de Oportunidades",
|
||||
"subtitle": "Tamaño = Volumen · Color = Tier · Posición = Score vs Ahorro TCO",
|
||||
@@ -799,7 +1128,38 @@
|
||||
"standardizeProcesses": "Estandarizar procesos y scripts",
|
||||
"simplifyFlow": "Simplificar flujo, capacitar agentes",
|
||||
"consolidate": "Consolidar con colas similares",
|
||||
"improveDataCapture": "Mejorar captura de datos"
|
||||
"improveDataCapture": "Mejorar captura de datos",
|
||||
"skillsWithRedFlags": "Skills con Red Flags",
|
||||
"queuesRequireIntervention": "Colas que requieren intervención antes de automatizar",
|
||||
"viewRoadmapTab": "Ver pestaña Roadmap para plan detallado",
|
||||
"viewRoadmapLink": "Ver pestaña Roadmap para plan detallado →"
|
||||
},
|
||||
"roadmapConnection": {
|
||||
"quickWinsTitle": "QUICK WINS INMEDIATOS (sin Wave 1)",
|
||||
"automateQueues": "{{count}} colas AUTOMATE",
|
||||
"with": "con",
|
||||
"interactionsPerMonth": "interacciones/mes",
|
||||
"savingsPotential": "Ahorro potencial:",
|
||||
"perYear": "/año",
|
||||
"containment": "contención",
|
||||
"perInt": "/int",
|
||||
"skills": "Skills:",
|
||||
"alignedWithWave4": "→ Alineado con Wave 4 del Roadmap. Pueden implementarse en paralelo a Wave 1.",
|
||||
"wave13Title": "WAVE 1-3: FOUNDATION → ASSIST ({{count}} colas)",
|
||||
"inTierAssist": "en tier ASSIST",
|
||||
"focusWave1": "Foco Wave 1:",
|
||||
"reduceTransferIn": "Reducir transfer en",
|
||||
"potentialWithCopilot": "Potencial con Copilot:",
|
||||
"deflection": "deflection",
|
||||
"requiresWave1": "→ Requiere Wave 1 (Foundation) para habilitar Copilot en Wave 3",
|
||||
"calculationNote": "Cálculo: {{volume}} int × 12 meses × {{rate}}% {{type}} × €{{cpi}}/int",
|
||||
"quickWinsHaveVolume": "tienen >60% volumen en T1+T2",
|
||||
"hasPercentInHuman": "tiene {{pct}}% en HUMAN",
|
||||
"prioritizeInWave1": "→ priorizar en Wave 1",
|
||||
"balancedDistribution": "Distribución equilibrada entre tiers. Revisar colas individuales para priorización.",
|
||||
"haveAtLeastOne": "tienen al menos una cola tier AUTOMATE",
|
||||
"showLess": "Mostrar menos",
|
||||
"viewAll": "Ver todos ({{count}})"
|
||||
},
|
||||
"factorsExtended": {
|
||||
"volumeMethodology": "Score = log10(Volumen) normalizado. >5000 → 10, <100 → 2",
|
||||
@@ -814,6 +1174,102 @@
|
||||
"roiBad": "ROI marginal, evaluar otros beneficios",
|
||||
"resolution": "Resolutividad",
|
||||
"dataQuality": "Calidad Datos"
|
||||
},
|
||||
"factorConfigs": {
|
||||
"predictability": {
|
||||
"title": "Predictibilidad",
|
||||
"description": "Consistencia en tiempos de gestión",
|
||||
"methodology": "Score = 10 - (CV_AHT / 10). CV AHT < 30% → Score > 7",
|
||||
"benchmark": "CV AHT óptimo < 25%",
|
||||
"highImplication": "Tiempos consistentes, ideal para IA",
|
||||
"lowImplication": "Requiere estandarización"
|
||||
},
|
||||
"inverseComplexity": {
|
||||
"title": "Simplicidad",
|
||||
"description": "Bajo nivel de juicio humano requerido",
|
||||
"methodology": "Score = 10 - (Tasa_Transfer × 0.4). Transfer <10% → Score > 6",
|
||||
"benchmark": "Transferencias óptimas <10%",
|
||||
"highImplication": "Procesos simples, automatizables",
|
||||
"lowImplication": "Alta complejidad, requiere copilot"
|
||||
},
|
||||
"repeatability": {
|
||||
"title": "Volumen",
|
||||
"description": "Escala para justificar inversión",
|
||||
"methodology": "Score = log10(Volumen) normalizado. >5000 → 10, <100 → 2",
|
||||
"benchmark": "ROI positivo requiere >500/mes",
|
||||
"highImplication": "Alto volumen justifica inversión",
|
||||
"lowImplication": "Considerar soluciones compartidas"
|
||||
},
|
||||
"roiPotential": {
|
||||
"title": "ROI Potencial",
|
||||
"description": "Retorno económico esperado",
|
||||
"methodology": "Score basado en coste anual total. >€500K → 10",
|
||||
"benchmark": "ROI >150% a 12 meses",
|
||||
"highImplication": "Caso de negocio sólido",
|
||||
"lowImplication": "ROI marginal, evaluar otros beneficios"
|
||||
}
|
||||
},
|
||||
"scoreBreakdown": {
|
||||
"predictability": "Predictibilidad (30%)",
|
||||
"resolution": "Resolutividad (25%)",
|
||||
"volume": "Volumen (25%)",
|
||||
"dataQuality": "Calidad Datos (10%)",
|
||||
"simplicity": "Simplicidad (10%)"
|
||||
},
|
||||
"bubbleChart": {
|
||||
"quickWinsCount": "{{count}} colas · {{amount}}",
|
||||
"highPotentialCount": "{{count}} colas · {{amount}}",
|
||||
"developCount": "{{count}} colas · {{amount}}",
|
||||
"easyImplCount": "{{count}} · {{amount}}",
|
||||
"backlogCount": "{{count}} · {{amount}}",
|
||||
"total": "total",
|
||||
"noQueuesFilters": "No hay colas que cumplan los filtros seleccionados",
|
||||
"quickWinsLabel": "QUICK WINS",
|
||||
"highPotentialLabel": "ALTO POTENCIAL",
|
||||
"developLabel": "DESARROLLAR",
|
||||
"easyImplLabel": "FÁCIL IMPL.",
|
||||
"backlogLabel": "BACKLOG",
|
||||
"activeFiltersLabel": "Filtros activos:",
|
||||
"ofQueues": "de {{total}} colas",
|
||||
"perMonth": "/mes",
|
||||
"cvAht": "CV AHT:",
|
||||
"viewDetail": "Click para ver detalle"
|
||||
},
|
||||
"modal": {
|
||||
"skillLabel": "Skill:",
|
||||
"transferRate": "Transfer Rate",
|
||||
"annualSavings": "Ahorro Anual"
|
||||
},
|
||||
"volumeLabels": {
|
||||
"queues": "colas",
|
||||
"int": "int"
|
||||
},
|
||||
"subFactors": {
|
||||
"repeatability": "Repetitividad",
|
||||
"repeatabilityDisplayName": "Repetitividad",
|
||||
"repeatabilityDescription": "Volumen mensual: {{volume}} interacciones",
|
||||
"predictability": "Predictibilidad",
|
||||
"predictabilityDisplayName": "Predictibilidad",
|
||||
"predictabilityDescription": "CV AHT: {{cv}}%, Escalación: {{esc}}%",
|
||||
"structuring": "Estructuración",
|
||||
"structuringDisplayName": "Estructuración",
|
||||
"structuringDescription": "{{pct}}% de campos estructurados",
|
||||
"inverseComplexity": "Complejidad Inversa",
|
||||
"inverseComplexityDisplayName": "Complejidad Inversa",
|
||||
"inverseComplexityDescription": "{{pct}}% de excepciones",
|
||||
"stability": "Estabilidad",
|
||||
"stabilityDisplayName": "Estabilidad",
|
||||
"stabilityDescription": "{{pct}}% fuera de horario",
|
||||
"roiSavings": "ROI",
|
||||
"roiSavingsDisplayName": "ROI",
|
||||
"roiSavingsDescription": "€{{amount}}K ahorro potencial anual",
|
||||
"interpretations": {
|
||||
"excellentForAutomation": "Excelente candidato para automatización completa (Automate)",
|
||||
"goodForAssistance": "Buen candidato para asistencia agéntica (Assist)",
|
||||
"candidateForAugmentation": "Candidato para augmentación humana (Augment)",
|
||||
"notRecommended": "No recomendado para automatización en este momento",
|
||||
"bronzeAnalysis": "Análisis Bronze no incluye Agentic Readiness Score"
|
||||
}
|
||||
}
|
||||
},
|
||||
"economicModel": {
|
||||
@@ -1312,6 +1768,142 @@
|
||||
"requiredData": "Requiere datos",
|
||||
"score": "Score",
|
||||
"gap": "Gap"
|
||||
},
|
||||
"summaryTable": {
|
||||
"requirement": "Requisito",
|
||||
"description": "Descripción",
|
||||
"status": "Estado",
|
||||
"score": "Score",
|
||||
"gap": "Gap",
|
||||
"legend": {
|
||||
"complies": "Cumple: Requisito satisfecho",
|
||||
"partial": "Parcial: Requiere mejoras",
|
||||
"notComply": "No Cumple: Acción urgente",
|
||||
"noData": "Sin Datos: Campos no disponibles en CSV"
|
||||
},
|
||||
"investment": {
|
||||
"nonComplianceCost": "Coste de no cumplimiento",
|
||||
"upTo100k": "Hasta 100K",
|
||||
"potentialFines": "Multas potenciales/infracción",
|
||||
"recommendedInvestment": "Inversión recomendada",
|
||||
"basedOnOperation": "Basada en tu operación",
|
||||
"complianceRoi": "ROI de cumplimiento",
|
||||
"avoidSanctions": "Evitar sanciones + mejora CX"
|
||||
}
|
||||
},
|
||||
"dataMaturity": {
|
||||
"title": "Resumen: Madurez de Datos para Compliance",
|
||||
"currentLevel": "Tu nivel actual de instrumentación:",
|
||||
"availableData": "DATOS DISPONIBLES (3/10)",
|
||||
"estimableData": "DATOS ESTIMABLES (2/10)",
|
||||
"unavailableData": "NO DISPONIBLES (5/10)",
|
||||
"items": {
|
||||
"coverage247": "Cobertura temporal 24/7",
|
||||
"geoDistribution": "Distribución geográfica",
|
||||
"resolutionQuality": "Calidad resolución proxy",
|
||||
"asa3min": "ASA <3min vía proxy abandono",
|
||||
"officialLanguages": "Lenguas cooficiales vía país",
|
||||
"caseResolutionTime": "Tiempo resolución casos",
|
||||
"undueBilling": "Cobros indebidos <5 días",
|
||||
"supervisorTransfer": "Transfer a supervisor",
|
||||
"incidentInfo": "Info incidencias <2h",
|
||||
"enacAudit": "Auditoría ENAC",
|
||||
"externalContractRequired": "requiere contratación externa"
|
||||
},
|
||||
"investment": {
|
||||
"title": "INVERSIÓN SUGERIDA PARA COMPLIANCE COMPLETO",
|
||||
"phase1": {
|
||||
"title": "Fase 1 - Instrumentación (Q1 2026)",
|
||||
"realAsaTracking": "• Tracking ASA real",
|
||||
"ticketingSystem": "• Sistema ticketing/casos",
|
||||
"languageEnrichment": "• Enriquecimiento lenguas",
|
||||
"subtotal": "Subtotal:"
|
||||
},
|
||||
"phase2": {
|
||||
"title": "Fase 2 - Operaciones (Q2-Q3 2026)",
|
||||
"coverage247": "• Cobertura 24/7 (chatbot + on-call)",
|
||||
"aiCopilot": "• Copilot IA (reducir AHT)",
|
||||
"enacAuditor": "• Auditor ENAC",
|
||||
"subtotalYear1": "Subtotal año 1:"
|
||||
},
|
||||
"totals": {
|
||||
"totalInvestment": "Inversión Total",
|
||||
"percentAnnualCost": "~5% coste anual",
|
||||
"riskAvoided": "Riesgo Evitado",
|
||||
"potentialSanctions": "sanciones potenciales",
|
||||
"complianceRoi": "ROI Compliance"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"opportunityPrioritizer": {
|
||||
"title": "Oportunidades Priorizadas",
|
||||
"subtitle": "{{count}} iniciativas ordenadas por potencial de ahorro y factibilidad",
|
||||
"whereAreOpportunities": "¿Dónde están las oportunidades?",
|
||||
"totalSavingsIdentified": "Ahorro Total Identificado",
|
||||
"annual": "anuales",
|
||||
"quickWins": "Quick Wins (AUTOMATE)",
|
||||
"assistance": "Asistencia (ASSIST)",
|
||||
"optimization": "Optimización (AUGMENT)",
|
||||
"inMonths": "en {{count}} meses",
|
||||
"startHere": "EMPIEZA AQUÍ",
|
||||
"priority1": "Prioridad #1",
|
||||
"annualSavings": "Ahorro Anual",
|
||||
"volume": "Volumen",
|
||||
"timeline": "Timeline",
|
||||
"months": "meses",
|
||||
"nextSteps": "Próximos Pasos",
|
||||
"allOpportunities": "Todas las Oportunidades Priorizadas",
|
||||
"savings": "Ahorro",
|
||||
"valueEffort": "Valor / Esfuerzo",
|
||||
"value": "Valor",
|
||||
"effort": "Esfuerzo",
|
||||
"viewMore": "Ver {{count}} oportunidades más",
|
||||
"methodology": "Metodología de priorización:",
|
||||
"methodologyDescription": "Las oportunidades se ordenan por potencial de ahorro TCO (volumen × tasa de contención × diferencial CPI). La clasificación de tier AUTOMATE/ASSIST/AUGMENT se basa en el Agentic Readiness Score considerando predictibilidad (CV AHT), resolvibilidad (FCR + Transfer), volumen, calidad de datos y simplicidad del proceso.",
|
||||
"tierLabels": {
|
||||
"automate": "Automatizar",
|
||||
"assist": "Asistir",
|
||||
"augment": "Aumentar",
|
||||
"human": "Humano"
|
||||
},
|
||||
"timelines": {
|
||||
"automate": "3-6 meses",
|
||||
"assist": "6-9 meses",
|
||||
"augment": "9-12 meses"
|
||||
},
|
||||
"tierDescriptions": {
|
||||
"automate": "Automatización completa con agentes IA",
|
||||
"assist": "Copilot IA para agentes humanos",
|
||||
"augment": "Estandarización y mejora de procesos",
|
||||
"humanOnly": "Requiere intervención humana"
|
||||
},
|
||||
"agenticScore": "Puntuación Agéntica",
|
||||
"whyPriority1": "¿Por qué es la prioridad #1?",
|
||||
"viewCompleteDetail": "Ver Detalle Completo",
|
||||
"showLess": "Mostrar menos",
|
||||
"whyThisPosition": "¿Por qué esta posición?",
|
||||
"keyMetrics": "Métricas Clave",
|
||||
"noOpportunitiesTitle": "No hay oportunidades identificadas",
|
||||
"noOpportunitiesDescription": "Los datos actuales no muestran oportunidades de automatización viables.",
|
||||
"reasons": {
|
||||
"highSavingsPotential": "Alto ahorro potencial (€{{amount}}K/año)",
|
||||
"highVolume": "Alto volumen ({{volume}} interacciones)",
|
||||
"highlyPredictable": "Proceso altamente predecible y repetitivo",
|
||||
"lowVariability": "Baja variabilidad en tiempos de gestión",
|
||||
"lowTransferRate": "Baja tasa de transferencias",
|
||||
"highFeasibility": "Alta factibilidad técnica"
|
||||
},
|
||||
"steps": {
|
||||
"automate1": "Definir flujos conversacionales principales",
|
||||
"automate2": "Identificar integraciones necesarias (CRM, APIs)",
|
||||
"automate3": "Crear piloto con 10% del volumen",
|
||||
"assist1": "Mapear puntos de fricción del agente",
|
||||
"assist2": "Diseñar sugerencias contextuales",
|
||||
"assist3": "Piloto con equipo seleccionado",
|
||||
"augment1": "Analizar causa raíz de variabilidad",
|
||||
"augment2": "Estandarizar procesos y scripts",
|
||||
"augment3": "Capacitar equipo en mejores prácticas"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,20 @@
|
||||
/**
|
||||
* Agentic Readiness Score v2.0
|
||||
* Algoritmo basado en metodología de 6 dimensiones con normalización continua
|
||||
* Algorithm based on 6-dimension methodology with continuous normalization
|
||||
*/
|
||||
|
||||
import type { TierKey, SubFactor, AgenticReadinessResult, CustomerSegment } from '../types';
|
||||
import { AGENTIC_READINESS_WEIGHTS, AGENTIC_READINESS_THRESHOLDS } from '../constants';
|
||||
|
||||
export interface AgenticReadinessInput {
|
||||
// Datos básicos (SILVER)
|
||||
// Basic data (SILVER)
|
||||
volumen_mes: number;
|
||||
aht_values: number[];
|
||||
escalation_rate: number;
|
||||
cpi_humano: number;
|
||||
volumen_anual: number;
|
||||
|
||||
// Datos avanzados (GOLD)
|
||||
|
||||
// Advanced data (GOLD)
|
||||
structured_fields_pct?: number;
|
||||
exception_rate?: number;
|
||||
hourly_distribution?: number[];
|
||||
@@ -22,27 +22,27 @@ export interface AgenticReadinessInput {
|
||||
csat_values?: number[];
|
||||
motivo_contacto_entropy?: number;
|
||||
resolucion_entropy?: number;
|
||||
|
||||
|
||||
// Tier
|
||||
tier: TierKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* SUB-FACTOR 1: REPETITIVIDAD (25%)
|
||||
* Basado en volumen mensual con normalización logística
|
||||
* SUB-FACTOR 1: REPEATABILITY (25%)
|
||||
* Based on monthly volume with logistic normalization
|
||||
*/
|
||||
function calculateRepetitividadScore(volumen_mes: number): SubFactor {
|
||||
function calculateRepeatabilityScore(volumen_mes: number): SubFactor {
|
||||
const { k, x0 } = AGENTIC_READINESS_THRESHOLDS.repetitividad;
|
||||
|
||||
// Función logística: score = 10 / (1 + exp(-k * (volumen - x0)))
|
||||
|
||||
// Logistic function: score = 10 / (1 + exp(-k * (volume - x0)))
|
||||
const score = 10 / (1 + Math.exp(-k * (volumen_mes - x0)));
|
||||
|
||||
|
||||
return {
|
||||
name: 'repetitividad',
|
||||
displayName: 'Repetitividad',
|
||||
name: 'repeatability',
|
||||
displayName: 'Repeatability',
|
||||
score: Math.round(score * 10) / 10,
|
||||
weight: AGENTIC_READINESS_WEIGHTS.repetitividad,
|
||||
description: `Volumen mensual: ${volumen_mes} interacciones`,
|
||||
description: `Monthly volume: ${volumen_mes} interactions`,
|
||||
details: {
|
||||
volumen_mes,
|
||||
threshold_medio: x0
|
||||
@@ -51,58 +51,58 @@ function calculateRepetitividadScore(volumen_mes: number): SubFactor {
|
||||
}
|
||||
|
||||
/**
|
||||
* SUB-FACTOR 2: PREDICTIBILIDAD (20%)
|
||||
* Basado en variabilidad AHT + tasa de escalación + variabilidad input/output
|
||||
* SUB-FACTOR 2: PREDICTABILITY (20%)
|
||||
* Based on AHT variability + escalation rate + input/output variability
|
||||
*/
|
||||
function calculatePredictibilidadScore(
|
||||
function calculatePredictabilityScore(
|
||||
aht_values: number[],
|
||||
escalation_rate: number,
|
||||
motivo_contacto_entropy?: number,
|
||||
resolucion_entropy?: number
|
||||
): SubFactor {
|
||||
const thresholds = AGENTIC_READINESS_THRESHOLDS.predictibilidad;
|
||||
|
||||
// 1. VARIABILIDAD AHT (40%)
|
||||
|
||||
// 1. AHT VARIABILITY (40%)
|
||||
const aht_mean = aht_values.reduce((a, b) => a + b, 0) / aht_values.length;
|
||||
const aht_variance = aht_values.reduce((sum, val) => sum + Math.pow(val - aht_mean, 2), 0) / aht_values.length;
|
||||
const aht_std = Math.sqrt(aht_variance);
|
||||
const cv_aht = aht_std / aht_mean;
|
||||
|
||||
// Normalizar CV a escala 0-10
|
||||
const score_aht = Math.max(0, Math.min(10,
|
||||
|
||||
// Normalize CV to 0-10 scale
|
||||
const score_aht = Math.max(0, Math.min(10,
|
||||
10 * (1 - (cv_aht - thresholds.cv_aht_excellent) / (thresholds.cv_aht_poor - thresholds.cv_aht_excellent))
|
||||
));
|
||||
|
||||
// 2. TASA DE ESCALACIÓN (30%)
|
||||
const score_escalacion = Math.max(0, Math.min(10,
|
||||
|
||||
// 2. ESCALATION RATE (30%)
|
||||
const score_escalacion = Math.max(0, Math.min(10,
|
||||
10 * (1 - escalation_rate / thresholds.escalation_poor)
|
||||
));
|
||||
|
||||
// 3. VARIABILIDAD INPUT/OUTPUT (30%)
|
||||
|
||||
// 3. INPUT/OUTPUT VARIABILITY (30%)
|
||||
let score_variabilidad: number;
|
||||
if (motivo_contacto_entropy !== undefined && resolucion_entropy !== undefined) {
|
||||
// Alta entropía input + Baja entropía output = BUENA para automatización
|
||||
// High input entropy + Low output entropy = GOOD for automation
|
||||
const input_normalized = Math.min(motivo_contacto_entropy / 3.0, 1.0);
|
||||
const output_normalized = Math.min(resolucion_entropy / 3.0, 1.0);
|
||||
score_variabilidad = 10 * (input_normalized * (1 - output_normalized));
|
||||
} else {
|
||||
// Si no hay datos de entropía, usar promedio de AHT y escalación
|
||||
// If no entropy data, use average of AHT and escalation
|
||||
score_variabilidad = (score_aht + score_escalacion) / 2;
|
||||
}
|
||||
|
||||
// PONDERACIÓN FINAL
|
||||
const predictibilidad = (
|
||||
|
||||
// FINAL WEIGHTING
|
||||
const predictabilidad = (
|
||||
0.40 * score_aht +
|
||||
0.30 * score_escalacion +
|
||||
0.30 * score_variabilidad
|
||||
);
|
||||
|
||||
|
||||
return {
|
||||
name: 'predictibilidad',
|
||||
displayName: 'Predictibilidad',
|
||||
score: Math.round(predictibilidad * 10) / 10,
|
||||
name: 'predictability',
|
||||
displayName: 'Predictability',
|
||||
score: Math.round(predictabilidad * 10) / 10,
|
||||
weight: AGENTIC_READINESS_WEIGHTS.predictibilidad,
|
||||
description: `CV AHT: ${(cv_aht * 100).toFixed(1)}%, Escalación: ${(escalation_rate * 100).toFixed(1)}%`,
|
||||
description: `AHT CV: ${(cv_aht * 100).toFixed(1)}%, Escalation: ${(escalation_rate * 100).toFixed(1)}%`,
|
||||
details: {
|
||||
cv_aht: Math.round(cv_aht * 1000) / 1000,
|
||||
escalation_rate,
|
||||
@@ -114,18 +114,18 @@ function calculatePredictibilidadScore(
|
||||
}
|
||||
|
||||
/**
|
||||
* SUB-FACTOR 3: ESTRUCTURACIÓN (15%)
|
||||
* Porcentaje de campos estructurados vs texto libre
|
||||
* SUB-FACTOR 3: STRUCTURING (15%)
|
||||
* Percentage of structured fields vs free text
|
||||
*/
|
||||
function calculateEstructuracionScore(structured_fields_pct: number): SubFactor {
|
||||
function calculateStructuringScore(structured_fields_pct: number): SubFactor {
|
||||
const score = structured_fields_pct * 10;
|
||||
|
||||
|
||||
return {
|
||||
name: 'estructuracion',
|
||||
displayName: 'Estructuración',
|
||||
name: 'structuring',
|
||||
displayName: 'Structuring',
|
||||
score: Math.round(score * 10) / 10,
|
||||
weight: AGENTIC_READINESS_WEIGHTS.estructuracion,
|
||||
description: `${(structured_fields_pct * 100).toFixed(0)}% de campos estructurados`,
|
||||
description: `${(structured_fields_pct * 100).toFixed(0)}% structured fields`,
|
||||
details: {
|
||||
structured_fields_pct
|
||||
}
|
||||
@@ -133,21 +133,21 @@ function calculateEstructuracionScore(structured_fields_pct: number): SubFactor
|
||||
}
|
||||
|
||||
/**
|
||||
* SUB-FACTOR 4: COMPLEJIDAD INVERSA (15%)
|
||||
* Basado en tasa de excepciones
|
||||
* SUB-FACTOR 4: INVERSE COMPLEXITY (15%)
|
||||
* Based on exception rate
|
||||
*/
|
||||
function calculateComplejidadInversaScore(exception_rate: number): SubFactor {
|
||||
// Menor tasa de excepciones → Mayor score
|
||||
// < 5% → Excelente (score 10)
|
||||
// > 30% → Muy complejo (score 0)
|
||||
function calculateInverseComplexityScore(exception_rate: number): SubFactor {
|
||||
// Lower exception rate → Higher score
|
||||
// < 5% → Excellent (score 10)
|
||||
// > 30% → Very complex (score 0)
|
||||
const score_excepciones = Math.max(0, Math.min(10, 10 * (1 - exception_rate / 0.30)));
|
||||
|
||||
|
||||
return {
|
||||
name: 'complejidad_inversa',
|
||||
displayName: 'Complejidad Inversa',
|
||||
name: 'inverseComplexity',
|
||||
displayName: 'Inverse Complexity',
|
||||
score: Math.round(score_excepciones * 10) / 10,
|
||||
weight: AGENTIC_READINESS_WEIGHTS.complejidad_inversa,
|
||||
description: `${(exception_rate * 100).toFixed(1)}% de excepciones`,
|
||||
description: `${(exception_rate * 100).toFixed(1)}% exceptions`,
|
||||
details: {
|
||||
exception_rate
|
||||
}
|
||||
@@ -155,15 +155,15 @@ function calculateComplejidadInversaScore(exception_rate: number): SubFactor {
|
||||
}
|
||||
|
||||
/**
|
||||
* SUB-FACTOR 5: ESTABILIDAD (10%)
|
||||
* Basado en distribución horaria y % llamadas fuera de horas
|
||||
* SUB-FACTOR 5: STABILITY (10%)
|
||||
* Based on hourly distribution and % off-hours calls
|
||||
*/
|
||||
function calculateEstabilidadScore(
|
||||
function calculateStabilityScore(
|
||||
hourly_distribution: number[],
|
||||
off_hours_pct: number
|
||||
): SubFactor {
|
||||
// 1. UNIFORMIDAD DISTRIBUCIÓN HORARIA (60%)
|
||||
// Calcular entropía de Shannon
|
||||
// 1. HOURLY DISTRIBUTION UNIFORMITY (60%)
|
||||
// Calculate Shannon entropy
|
||||
const total = hourly_distribution.reduce((a, b) => a + b, 0);
|
||||
let score_uniformidad = 0;
|
||||
let entropy_normalized = 0;
|
||||
@@ -175,23 +175,23 @@ function calculateEstabilidadScore(
|
||||
entropy_normalized = entropy / max_entropy;
|
||||
score_uniformidad = entropy_normalized * 10;
|
||||
}
|
||||
|
||||
// 2. % LLAMADAS FUERA DE HORAS (40%)
|
||||
// Más llamadas fuera de horas → Mayor necesidad agentes → Mayor score
|
||||
|
||||
// 2. % OFF-HOURS CALLS (40%)
|
||||
// More off-hours calls → Higher agent need → Higher score
|
||||
const score_off_hours = Math.min(10, (off_hours_pct / 0.30) * 10);
|
||||
|
||||
// PONDERACIÓN
|
||||
|
||||
// WEIGHTING
|
||||
const estabilidad = (
|
||||
0.60 * score_uniformidad +
|
||||
0.40 * score_off_hours
|
||||
);
|
||||
|
||||
|
||||
return {
|
||||
name: 'estabilidad',
|
||||
displayName: 'Estabilidad',
|
||||
name: 'stability',
|
||||
displayName: 'Stability',
|
||||
score: Math.round(estabilidad * 10) / 10,
|
||||
weight: AGENTIC_READINESS_WEIGHTS.estabilidad,
|
||||
description: `${(off_hours_pct * 100).toFixed(1)}% fuera de horario`,
|
||||
description: `${(off_hours_pct * 100).toFixed(1)}% off-hours`,
|
||||
details: {
|
||||
entropy_normalized: Math.round(entropy_normalized * 1000) / 1000,
|
||||
off_hours_pct,
|
||||
@@ -203,7 +203,7 @@ function calculateEstabilidadScore(
|
||||
|
||||
/**
|
||||
* SUB-FACTOR 6: ROI (15%)
|
||||
* Basado en ahorro potencial anual
|
||||
* Based on annual potential savings
|
||||
*/
|
||||
function calculateROIScore(
|
||||
volumen_anual: number,
|
||||
@@ -211,17 +211,17 @@ function calculateROIScore(
|
||||
automation_savings_pct: number = 0.70
|
||||
): SubFactor {
|
||||
const ahorro_anual = volumen_anual * cpi_humano * automation_savings_pct;
|
||||
|
||||
// Normalización logística
|
||||
|
||||
// Logistic normalization
|
||||
const { k, x0 } = AGENTIC_READINESS_THRESHOLDS.roi;
|
||||
const score = 10 / (1 + Math.exp(-k * (ahorro_anual - x0)));
|
||||
|
||||
|
||||
return {
|
||||
name: 'roi',
|
||||
displayName: 'ROI',
|
||||
score: Math.round(score * 10) / 10,
|
||||
weight: AGENTIC_READINESS_WEIGHTS.roi,
|
||||
description: `€${(ahorro_anual / 1000).toFixed(0)}K ahorro potencial anual`,
|
||||
description: `€${(ahorro_anual / 1000).toFixed(0)}K annual potential savings`,
|
||||
details: {
|
||||
ahorro_anual: Math.round(ahorro_anual),
|
||||
volumen_anual,
|
||||
@@ -232,98 +232,98 @@ function calculateROIScore(
|
||||
}
|
||||
|
||||
/**
|
||||
* AJUSTE POR DISTRIBUCIÓN CSAT (Opcional, ±10%)
|
||||
* Distribución normal → Proceso estable
|
||||
* CSAT DISTRIBUTION ADJUSTMENT (Optional, ±10%)
|
||||
* Normal distribution → Stable process
|
||||
*/
|
||||
function calculateCSATDistributionAdjustment(csat_values: number[]): number {
|
||||
// Test de normalidad simplificado (basado en skewness y kurtosis)
|
||||
// Simplified normality test (based on skewness and kurtosis)
|
||||
const n = csat_values.length;
|
||||
const mean = csat_values.reduce((a, b) => a + b, 0) / n;
|
||||
const variance = csat_values.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / n;
|
||||
const std = Math.sqrt(variance);
|
||||
|
||||
|
||||
// Skewness
|
||||
const skewness = csat_values.reduce((sum, val) => sum + Math.pow((val - mean) / std, 3), 0) / n;
|
||||
|
||||
|
||||
// Kurtosis
|
||||
const kurtosis = csat_values.reduce((sum, val) => sum + Math.pow((val - mean) / std, 4), 0) / n;
|
||||
|
||||
// Normalidad: skewness cercano a 0, kurtosis cercano a 3
|
||||
|
||||
// Normality: skewness close to 0, kurtosis close to 3
|
||||
const skewness_score = Math.max(0, 1 - Math.abs(skewness));
|
||||
const kurtosis_score = Math.max(0, 1 - Math.abs(kurtosis - 3) / 3);
|
||||
const normality_score = (skewness_score + kurtosis_score) / 2;
|
||||
|
||||
// Ajuste: +5% si muy normal, -5% si muy anormal
|
||||
|
||||
// Adjustment: +5% if very normal, -5% if very abnormal
|
||||
const adjustment = 1 + ((normality_score - 0.5) * 0.10);
|
||||
|
||||
|
||||
return adjustment;
|
||||
}
|
||||
|
||||
/**
|
||||
* ALGORITMO COMPLETO (Tier GOLD)
|
||||
* COMPLETE ALGORITHM (Tier GOLD)
|
||||
*/
|
||||
export function calculateAgenticReadinessScoreGold(data: AgenticReadinessInput): AgenticReadinessResult {
|
||||
const sub_factors: SubFactor[] = [];
|
||||
|
||||
// 1. REPETITIVIDAD
|
||||
sub_factors.push(calculateRepetitividadScore(data.volumen_mes));
|
||||
|
||||
// 2. PREDICTIBILIDAD
|
||||
sub_factors.push(calculatePredictibilidadScore(
|
||||
|
||||
// 1. REPEATABILITY
|
||||
sub_factors.push(calculateRepeatabilityScore(data.volumen_mes));
|
||||
|
||||
// 2. PREDICTABILITY
|
||||
sub_factors.push(calculatePredictabilityScore(
|
||||
data.aht_values,
|
||||
data.escalation_rate,
|
||||
data.motivo_contacto_entropy,
|
||||
data.resolucion_entropy
|
||||
));
|
||||
|
||||
// 3. ESTRUCTURACIÓN
|
||||
sub_factors.push(calculateEstructuracionScore(data.structured_fields_pct || 0.5));
|
||||
|
||||
// 4. COMPLEJIDAD INVERSA
|
||||
sub_factors.push(calculateComplejidadInversaScore(data.exception_rate || 0.15));
|
||||
|
||||
// 5. ESTABILIDAD
|
||||
sub_factors.push(calculateEstabilidadScore(
|
||||
|
||||
// 3. STRUCTURING
|
||||
sub_factors.push(calculateStructuringScore(data.structured_fields_pct || 0.5));
|
||||
|
||||
// 4. INVERSE COMPLEXITY
|
||||
sub_factors.push(calculateInverseComplexityScore(data.exception_rate || 0.15));
|
||||
|
||||
// 5. STABILITY
|
||||
sub_factors.push(calculateStabilityScore(
|
||||
data.hourly_distribution || Array(24).fill(1),
|
||||
data.off_hours_pct || 0.2
|
||||
));
|
||||
|
||||
|
||||
// 6. ROI
|
||||
sub_factors.push(calculateROIScore(
|
||||
data.volumen_anual,
|
||||
data.cpi_humano
|
||||
));
|
||||
|
||||
// PONDERACIÓN BASE
|
||||
|
||||
// BASE WEIGHTING
|
||||
const agentic_readiness_base = sub_factors.reduce(
|
||||
(sum, factor) => sum + (factor.score * factor.weight),
|
||||
0
|
||||
);
|
||||
|
||||
// AJUSTE POR DISTRIBUCIÓN CSAT (Opcional)
|
||||
|
||||
// CSAT DISTRIBUTION ADJUSTMENT (Optional)
|
||||
let agentic_readiness_final = agentic_readiness_base;
|
||||
if (data.csat_values && data.csat_values.length > 10) {
|
||||
const adjustment = calculateCSATDistributionAdjustment(data.csat_values);
|
||||
agentic_readiness_final = agentic_readiness_base * adjustment;
|
||||
}
|
||||
|
||||
// Limitar a rango 0-10
|
||||
|
||||
// Limit to 0-10 range
|
||||
agentic_readiness_final = Math.max(0, Math.min(10, agentic_readiness_final));
|
||||
|
||||
// Interpretación
|
||||
|
||||
// Interpretation
|
||||
let interpretation = '';
|
||||
let confidence: 'high' | 'medium' | 'low' = 'high';
|
||||
|
||||
|
||||
if (agentic_readiness_final >= 8) {
|
||||
interpretation = 'Excelente candidato para automatización completa (Automate)';
|
||||
interpretation = 'Excellent candidate for complete automation (Automate)';
|
||||
} else if (agentic_readiness_final >= 5) {
|
||||
interpretation = 'Buen candidato para asistencia agéntica (Assist)';
|
||||
interpretation = 'Good candidate for agentic assistance (Assist)';
|
||||
} else if (agentic_readiness_final >= 3) {
|
||||
interpretation = 'Candidato para augmentación humana (Augment)';
|
||||
interpretation = 'Candidate for human augmentation (Augment)';
|
||||
} else {
|
||||
interpretation = 'No recomendado para automatización en este momento';
|
||||
interpretation = 'Not recommended for automation at this time';
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
score: Math.round(agentic_readiness_final * 10) / 10,
|
||||
sub_factors,
|
||||
@@ -334,45 +334,45 @@ export function calculateAgenticReadinessScoreGold(data: AgenticReadinessInput):
|
||||
}
|
||||
|
||||
/**
|
||||
* ALGORITMO SIMPLIFICADO (Tier SILVER)
|
||||
* SIMPLIFIED ALGORITHM (Tier SILVER)
|
||||
*/
|
||||
export function calculateAgenticReadinessScoreSilver(data: AgenticReadinessInput): AgenticReadinessResult {
|
||||
const sub_factors: SubFactor[] = [];
|
||||
|
||||
// 1. REPETITIVIDAD (30%)
|
||||
const repetitividad = calculateRepetitividadScore(data.volumen_mes);
|
||||
repetitividad.weight = 0.30;
|
||||
sub_factors.push(repetitividad);
|
||||
|
||||
// 2. PREDICTIBILIDAD SIMPLIFICADA (30%)
|
||||
const predictibilidad = calculatePredictibilidadScore(
|
||||
|
||||
// 1. REPEATABILITY (30%)
|
||||
const repeatability = calculateRepeatabilityScore(data.volumen_mes);
|
||||
repeatability.weight = 0.30;
|
||||
sub_factors.push(repeatability);
|
||||
|
||||
// 2. SIMPLIFIED PREDICTABILITY (30%)
|
||||
const predictability = calculatePredictabilityScore(
|
||||
data.aht_values,
|
||||
data.escalation_rate
|
||||
);
|
||||
predictibilidad.weight = 0.30;
|
||||
sub_factors.push(predictibilidad);
|
||||
|
||||
predictability.weight = 0.30;
|
||||
sub_factors.push(predictability);
|
||||
|
||||
// 3. ROI (40%)
|
||||
const roi = calculateROIScore(data.volumen_anual, data.cpi_humano);
|
||||
roi.weight = 0.40;
|
||||
sub_factors.push(roi);
|
||||
|
||||
// PONDERACIÓN SIMPLIFICADA
|
||||
|
||||
// SIMPLIFIED WEIGHTING
|
||||
const agentic_readiness = sub_factors.reduce(
|
||||
(sum, factor) => sum + (factor.score * factor.weight),
|
||||
0
|
||||
);
|
||||
|
||||
// Interpretación
|
||||
|
||||
// Interpretation
|
||||
let interpretation = '';
|
||||
if (agentic_readiness >= 7) {
|
||||
interpretation = 'Buen candidato para automatización';
|
||||
interpretation = 'Good candidate for automation';
|
||||
} else if (agentic_readiness >= 4) {
|
||||
interpretation = 'Candidato para asistencia agéntica';
|
||||
interpretation = 'Candidate for agentic assistance';
|
||||
} else {
|
||||
interpretation = 'Requiere análisis más profundo (considerar GOLD)';
|
||||
interpretation = 'Requires deeper analysis (consider GOLD)';
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
score: Math.round(agentic_readiness * 10) / 10,
|
||||
sub_factors,
|
||||
@@ -383,7 +383,7 @@ export function calculateAgenticReadinessScoreSilver(data: AgenticReadinessInput
|
||||
}
|
||||
|
||||
/**
|
||||
* FUNCIÓN PRINCIPAL - Selecciona algoritmo según tier
|
||||
* MAIN FUNCTION - Selects algorithm based on tier
|
||||
*/
|
||||
export function calculateAgenticReadinessScore(data: AgenticReadinessInput): AgenticReadinessResult {
|
||||
if (data.tier === 'gold') {
|
||||
@@ -391,13 +391,13 @@ export function calculateAgenticReadinessScore(data: AgenticReadinessInput): Age
|
||||
} else if (data.tier === 'silver') {
|
||||
return calculateAgenticReadinessScoreSilver(data);
|
||||
} else {
|
||||
// BRONZE: Sin Agentic Readiness
|
||||
// BRONZE: No Agentic Readiness
|
||||
return {
|
||||
score: 0,
|
||||
sub_factors: [],
|
||||
tier: 'bronze',
|
||||
confidence: 'low',
|
||||
interpretation: 'Análisis Bronze no incluye Agentic Readiness Score'
|
||||
interpretation: 'Bronze analysis does not include Agentic Readiness Score'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,13 +23,13 @@ function safeNumber(value: any, fallback = 0): number {
|
||||
function normalizeAhtMetric(ahtSeconds: number): number {
|
||||
if (!Number.isFinite(ahtSeconds) || ahtSeconds <= 0) return 0;
|
||||
|
||||
// Ajusta estos números si ves que tus AHTs reales son muy distintos
|
||||
const MIN_AHT = 300; // AHT muy bueno
|
||||
const MAX_AHT = 1000; // AHT muy malo
|
||||
// Adjust these numbers if your actual AHTs are very different
|
||||
const MIN_AHT = 300; // Very good AHT
|
||||
const MAX_AHT = 1000; // Very bad AHT
|
||||
|
||||
const clamped = Math.max(MIN_AHT, Math.min(MAX_AHT, ahtSeconds));
|
||||
const ratio = (clamped - MIN_AHT) / (MAX_AHT - MIN_AHT); // 0 (mejor) -> 1 (peor)
|
||||
const score = 100 - ratio * 100; // 100 (mejor) -> 0 (peor)
|
||||
const ratio = (clamped - MIN_AHT) / (MAX_AHT - MIN_AHT); // 0 (better) -> 1 (worse)
|
||||
const score = 100 - ratio * 100; // 100 (better) -> 0 (worse)
|
||||
|
||||
return Math.round(score);
|
||||
}
|
||||
@@ -74,7 +74,7 @@ function getTopLabel(
|
||||
return String(labels[maxIdx]);
|
||||
}
|
||||
|
||||
// ==== Helpers para distribución horaria (desde heatmap_24x7) ====
|
||||
// ==== Helpers for hourly distribution (from heatmap_24x7) ====
|
||||
|
||||
function computeHourlyFromHeatmap(heatmap24x7: any): number[] {
|
||||
if (!Array.isArray(heatmap24x7) || !heatmap24x7.length) {
|
||||
@@ -146,7 +146,7 @@ function mapAgenticReadiness(
|
||||
description:
|
||||
value?.reason ||
|
||||
value?.details?.description ||
|
||||
'Sub-factor calculado a partir de KPIs agregados.',
|
||||
'Sub-factor calculated from aggregated KPIs.',
|
||||
details: value?.details || {},
|
||||
};
|
||||
}
|
||||
@@ -156,7 +156,7 @@ function mapAgenticReadiness(
|
||||
|
||||
const interpretation =
|
||||
classification?.description ||
|
||||
`Puntuación de preparación agentic: ${score.toFixed(1)}/10`;
|
||||
`Agentic readiness score: ${score.toFixed(1)}/10`;
|
||||
|
||||
const computedCount = Object.values(sub_scores).filter(
|
||||
(s: any) => s?.computed
|
||||
@@ -176,7 +176,7 @@ function mapAgenticReadiness(
|
||||
};
|
||||
}
|
||||
|
||||
// ==== Volumetría (dimensión + KPIs) ====
|
||||
// ==== Volumetry (dimension + KPIs) ====
|
||||
|
||||
function buildVolumetryDimension(
|
||||
raw: BackendRawResults
|
||||
@@ -216,13 +216,13 @@ function buildVolumetryDimension(
|
||||
const topChannel = getTopLabel(volumeByChannel?.labels, channelValues);
|
||||
const topSkill = getTopLabel(skillLabels, skillValues);
|
||||
|
||||
// Heatmap 24x7 -> distribución horaria
|
||||
// Heatmap 24x7 -> hourly distribution
|
||||
const heatmap24x7 = volumetry?.heatmap_24x7;
|
||||
const hourly = computeHourlyFromHeatmap(heatmap24x7);
|
||||
const offHoursPct = hourly.length ? calcOffHoursPct(hourly) : 0;
|
||||
const peakHours = hourly.length ? findPeakHours(hourly) : [];
|
||||
|
||||
console.log('📊 Volumetría backend (mapper):', {
|
||||
console.log('📊 Backend volumetry (mapper):', {
|
||||
volumetry,
|
||||
volumeByChannel,
|
||||
volumeBySkill,
|
||||
@@ -240,21 +240,21 @@ function buildVolumetryDimension(
|
||||
|
||||
if (totalVolume > 0) {
|
||||
extraKpis.push({
|
||||
label: 'Volumen total (backend)',
|
||||
label: 'Total volume (backend)',
|
||||
value: totalVolume.toLocaleString('es-ES'),
|
||||
});
|
||||
}
|
||||
|
||||
if (numChannels > 0) {
|
||||
extraKpis.push({
|
||||
label: 'Canales analizados',
|
||||
label: 'Channels analyzed',
|
||||
value: String(numChannels),
|
||||
});
|
||||
}
|
||||
|
||||
if (numSkills > 0) {
|
||||
extraKpis.push({
|
||||
label: 'Skills analizadas',
|
||||
label: 'Skills analyzed',
|
||||
value: String(numSkills),
|
||||
});
|
||||
|
||||
@@ -271,14 +271,14 @@ function buildVolumetryDimension(
|
||||
|
||||
if (topChannel) {
|
||||
extraKpis.push({
|
||||
label: 'Canal principal',
|
||||
label: 'Main channel',
|
||||
value: topChannel,
|
||||
});
|
||||
}
|
||||
|
||||
if (topSkill) {
|
||||
extraKpis.push({
|
||||
label: 'Skill principal',
|
||||
label: 'Main skill',
|
||||
value: topSkill,
|
||||
});
|
||||
}
|
||||
@@ -287,28 +287,28 @@ function buildVolumetryDimension(
|
||||
return { dimension: undefined, extraKpis };
|
||||
}
|
||||
|
||||
// Calcular ratio pico/valle para evaluar concentración de demanda
|
||||
// Calculate peak/valley ratio to evaluate demand concentration
|
||||
const validHourly = hourly.filter(v => v > 0);
|
||||
const maxHourly = validHourly.length > 0 ? Math.max(...validHourly) : 0;
|
||||
const minHourly = validHourly.length > 0 ? Math.min(...validHourly) : 1;
|
||||
const peakValleyRatio = minHourly > 0 ? maxHourly / minHourly : 1;
|
||||
console.log(`⏰ Hourly distribution (backend path): total=${totalVolume}, peak=${maxHourly}, valley=${minHourly}, ratio=${peakValleyRatio.toFixed(2)}`);
|
||||
|
||||
// Score basado en:
|
||||
// - % fuera de horario (>30% penaliza)
|
||||
// - Ratio pico/valle (>3x penaliza)
|
||||
// NO penalizar por tener volumen alto
|
||||
// Score based on:
|
||||
// - % off-hours (>30% penalty)
|
||||
// - Peak/valley ratio (>3x penalty)
|
||||
// DO NOT penalize for having high volume
|
||||
let score = 100;
|
||||
|
||||
// Penalización por fuera de horario
|
||||
// Penalty for off-hours
|
||||
const offHoursPctValue = offHoursPct * 100;
|
||||
if (offHoursPctValue > 30) {
|
||||
score -= Math.min(40, (offHoursPctValue - 30) * 2); // -2 pts por cada % sobre 30%
|
||||
score -= Math.min(40, (offHoursPctValue - 30) * 2); // -2 pts per % over30%
|
||||
} else if (offHoursPctValue > 20) {
|
||||
score -= (offHoursPctValue - 20); // -1 pt por cada % entre 20-30%
|
||||
score -= (offHoursPctValue - 20); // -1 pt per % between 20-30%
|
||||
}
|
||||
|
||||
// Penalización por ratio pico/valle alto
|
||||
// Penalty for high peak/valley ratio
|
||||
if (peakValleyRatio > 5) {
|
||||
score -= 30;
|
||||
} else if (peakValleyRatio > 3) {
|
||||
@@ -321,32 +321,32 @@ function buildVolumetryDimension(
|
||||
|
||||
const summaryParts: string[] = [];
|
||||
summaryParts.push(
|
||||
`${totalVolume.toLocaleString('es-ES')} interacciones analizadas.`
|
||||
`${totalVolume.toLocaleString('es-ES')} interactions analyzed.`
|
||||
);
|
||||
summaryParts.push(
|
||||
`${(offHoursPct * 100).toFixed(0)}% fuera de horario laboral (8-19h).`
|
||||
`${(offHoursPct * 100).toFixed(0)}% outside business hours (8-19h).`
|
||||
);
|
||||
if (peakValleyRatio > 2) {
|
||||
summaryParts.push(
|
||||
`Ratio pico/valle: ${peakValleyRatio.toFixed(1)}x - alta concentración de demanda.`
|
||||
`Peak/valley ratio: ${peakValleyRatio.toFixed(1)}x - high demand concentration.`
|
||||
);
|
||||
}
|
||||
if (topSkill) {
|
||||
summaryParts.push(`Skill principal: ${topSkill}.`);
|
||||
summaryParts.push(`Main skill: ${topSkill}.`);
|
||||
}
|
||||
|
||||
// Métrica principal accionable: % fuera de horario
|
||||
// Main actionable metric: % off-hours
|
||||
const dimension: DimensionAnalysis = {
|
||||
id: 'volumetry_distribution',
|
||||
name: 'volumetry_distribution',
|
||||
title: 'Volumetría y distribución de demanda',
|
||||
title: 'Volumetry and demand distribution',
|
||||
score,
|
||||
percentile: undefined,
|
||||
summary: summaryParts.join(' '),
|
||||
kpi: {
|
||||
label: 'Fuera de horario',
|
||||
label: 'Off-hours',
|
||||
value: `${(offHoursPct * 100).toFixed(0)}%`,
|
||||
change: peakValleyRatio > 2 ? `Pico/valle: ${peakValleyRatio.toFixed(1)}x` : undefined,
|
||||
change: peakValleyRatio > 2 ? `Peak/valley: ${peakValleyRatio.toFixed(1)}x` : undefined,
|
||||
changeType: offHoursPct > 0.3 ? 'negative' : offHoursPct > 0.2 ? 'neutral' : 'positive'
|
||||
},
|
||||
icon: BarChartHorizontal,
|
||||
@@ -362,7 +362,7 @@ function buildVolumetryDimension(
|
||||
return { dimension, extraKpis };
|
||||
}
|
||||
|
||||
// ==== Eficiencia Operativa (v3.2 - con segmentación horaria) ====
|
||||
// ==== Operational Efficiency (v3.2 - with hourly segmentation) ====
|
||||
|
||||
function buildOperationalEfficiencyDimension(
|
||||
raw: BackendRawResults,
|
||||
@@ -371,25 +371,25 @@ function buildOperationalEfficiencyDimension(
|
||||
const op = raw?.operational_performance;
|
||||
if (!op) return undefined;
|
||||
|
||||
// AHT Global
|
||||
// Global AHT
|
||||
const ahtP50 = safeNumber(op.aht_distribution?.p50, 0);
|
||||
const ahtP90 = safeNumber(op.aht_distribution?.p90, 0);
|
||||
const ratioGlobal = ahtP90 > 0 && ahtP50 > 0 ? ahtP90 / ahtP50 : safeNumber(op.aht_distribution?.p90_p50_ratio, 1.5);
|
||||
|
||||
// AHT Horario Laboral (8-19h) - estimación basada en distribución
|
||||
// Asumimos que el AHT en horario laboral es ligeramente menor (más eficiente)
|
||||
const ahtBusinessHours = Math.round(ahtP50 * 0.92); // ~8% más eficiente en horario laboral
|
||||
const ratioBusinessHours = ratioGlobal * 0.85; // Menor variabilidad en horario laboral
|
||||
// Business Hours AHT (8-19h) - estimation based on distribution
|
||||
// We assume that AHT during business hours is slightly lower (more efficient)
|
||||
const ahtBusinessHours = Math.round(ahtP50 * 0.92); // ~8% more efficient during business hours
|
||||
const ratioBusinessHours = ratioGlobal * 0.85; // Lower variability during business hours
|
||||
|
||||
// Determinar si la variabilidad se reduce fuera de horario
|
||||
// Determine if variability reduces outside hours
|
||||
const variabilityReduction = ratioGlobal - ratioBusinessHours;
|
||||
const variabilityInsight = variabilityReduction > 0.3
|
||||
? 'La variabilidad se reduce significativamente en horario laboral.'
|
||||
? 'Variability significantly reduces during business hours.'
|
||||
: variabilityReduction > 0.1
|
||||
? 'La variabilidad se mantiene similar en ambos horarios.'
|
||||
: 'La variabilidad es consistente independientemente del horario.';
|
||||
? 'Variability remains similar in both schedules.'
|
||||
: 'Variability is consistent regardless of schedule.';
|
||||
|
||||
// Score basado en escala definida:
|
||||
// Score based on defined scale:
|
||||
// <1.5 = 100pts, 1.5-2.0 = 70pts, 2.0-2.5 = 50pts, 2.5-3.0 = 30pts, >3.0 = 20pts
|
||||
let score: number;
|
||||
if (ratioGlobal < 1.5) {
|
||||
@@ -404,9 +404,9 @@ function buildOperationalEfficiencyDimension(
|
||||
score = 20;
|
||||
}
|
||||
|
||||
// Summary con segmentación
|
||||
let summary = `AHT Global: ${Math.round(ahtP50)}s (P50), ratio ${ratioGlobal.toFixed(2)}. `;
|
||||
summary += `AHT Horario Laboral (8-19h): ${ahtBusinessHours}s (P50), ratio ${ratioBusinessHours.toFixed(2)}. `;
|
||||
// Summary with segmentation
|
||||
let summary = `Global AHT: ${Math.round(ahtP50)}s (P50), ratio ${ratioGlobal.toFixed(2)}. `;
|
||||
summary += `Business Hours AHT (8-19h): ${ahtBusinessHours}s (P50), ratio ${ratioBusinessHours.toFixed(2)}. `;
|
||||
summary += variabilityInsight;
|
||||
|
||||
// KPI principal: AHT P50 (industry standard for operational efficiency)
|
||||
@@ -420,7 +420,7 @@ function buildOperationalEfficiencyDimension(
|
||||
const dimension: DimensionAnalysis = {
|
||||
id: 'operational_efficiency',
|
||||
name: 'operational_efficiency',
|
||||
title: 'Eficiencia Operativa',
|
||||
title: 'Operational Efficiency',
|
||||
score,
|
||||
percentile: undefined,
|
||||
summary,
|
||||
@@ -431,7 +431,7 @@ function buildOperationalEfficiencyDimension(
|
||||
return dimension;
|
||||
}
|
||||
|
||||
// ==== Efectividad & Resolución (v3.2 - enfocada en FCR Técnico) ====
|
||||
// ==== Effectiveness & Resolution (v3.2 - focused on Technical FCR) ====
|
||||
|
||||
function buildEffectivenessResolutionDimension(
|
||||
raw: BackendRawResults
|
||||
@@ -439,20 +439,20 @@ function buildEffectivenessResolutionDimension(
|
||||
const op = raw?.operational_performance;
|
||||
if (!op) return undefined;
|
||||
|
||||
// FCR Técnico = 100 - transfer_rate (comparable con benchmarks de industria)
|
||||
// Usamos escalation_rate que es la tasa de transferencias
|
||||
// Technical FCR = 100 - transfer_rate (comparable with industry benchmarks)
|
||||
// We use escalation_rate which is the transfer rate
|
||||
const escalationRate = safeNumber(op.escalation_rate, NaN);
|
||||
const abandonmentRate = safeNumber(op.abandonment_rate, 0);
|
||||
|
||||
// FCR Técnico: 100 - tasa de transferencia
|
||||
// Technical FCR: 100 - tasa de transferencia
|
||||
const fcrRate = Number.isFinite(escalationRate) && escalationRate >= 0
|
||||
? Math.max(0, Math.min(100, 100 - escalationRate))
|
||||
: 70; // valor por defecto benchmark aéreo
|
||||
: 70; // default airline benchmark value
|
||||
|
||||
// Tasa de transferencia (complemento del FCR Técnico)
|
||||
// Transfer rate (complement of Technical FCR)
|
||||
const transferRate = Number.isFinite(escalationRate) ? escalationRate : 100 - fcrRate;
|
||||
|
||||
// Score basado en FCR Técnico (benchmark sector aéreo: 85-90%)
|
||||
// Score based on Technical FCR (benchmark airline sector: 85-90%)
|
||||
// FCR >= 90% = 100pts, 85-90% = 80pts, 80-85% = 60pts, 75-80% = 40pts, <75% = 20pts
|
||||
let score: number;
|
||||
if (fcrRate >= 90) {
|
||||
@@ -467,25 +467,25 @@ function buildEffectivenessResolutionDimension(
|
||||
score = 20;
|
||||
}
|
||||
|
||||
// Penalización adicional por abandono alto (>8%)
|
||||
// Additional penalty for high abandonment (>8%)
|
||||
if (abandonmentRate > 8) {
|
||||
score = Math.max(0, score - Math.round((abandonmentRate - 8) * 2));
|
||||
}
|
||||
|
||||
// Summary enfocado en FCR Técnico
|
||||
let summary = `FCR Técnico: ${fcrRate.toFixed(1)}% (benchmark: 85-90%). `;
|
||||
summary += `Tasa de transferencia: ${transferRate.toFixed(1)}%. `;
|
||||
// Summary focused on Technical FCR
|
||||
let summary = `Technical FCR: ${fcrRate.toFixed(1)}% (benchmark: 85-90%). `;
|
||||
summary += `Transfer rate: ${transferRate.toFixed(1)}%. `;
|
||||
|
||||
if (fcrRate >= 90) {
|
||||
summary += 'Excelente resolución en primer contacto.';
|
||||
summary += 'Excellent first contact resolution.';
|
||||
} else if (fcrRate >= 85) {
|
||||
summary += 'Resolución dentro del benchmark del sector.';
|
||||
summary += 'Resolution within sector benchmark.';
|
||||
} else {
|
||||
summary += 'Oportunidad de mejora reduciendo transferencias.';
|
||||
summary += 'Opportunity to improve by reducing transfers.';
|
||||
}
|
||||
|
||||
const kpi: Kpi = {
|
||||
label: 'FCR Técnico',
|
||||
label: 'Technical FCR',
|
||||
value: `${fcrRate.toFixed(0)}%`,
|
||||
change: `Transfer: ${transferRate.toFixed(0)}%`,
|
||||
changeType: fcrRate >= 85 ? 'positive' : fcrRate >= 80 ? 'neutral' : 'negative'
|
||||
@@ -494,7 +494,7 @@ function buildEffectivenessResolutionDimension(
|
||||
const dimension: DimensionAnalysis = {
|
||||
id: 'effectiveness_resolution',
|
||||
name: 'effectiveness_resolution',
|
||||
title: 'Efectividad & Resolución',
|
||||
title: 'Effectiveness & Resolution',
|
||||
score,
|
||||
percentile: undefined,
|
||||
summary,
|
||||
@@ -505,7 +505,7 @@ function buildEffectivenessResolutionDimension(
|
||||
return dimension;
|
||||
}
|
||||
|
||||
// ==== Complejidad & Predictibilidad (v3.4 - basada en CV AHT per industry standards) ====
|
||||
// ==== Complexity & Predictability (v3.4 - based on CV AHT per industry standards) ====
|
||||
|
||||
function buildComplexityPredictabilityDimension(
|
||||
raw: BackendRawResults
|
||||
@@ -514,18 +514,18 @@ function buildComplexityPredictabilityDimension(
|
||||
if (!op) return undefined;
|
||||
|
||||
// KPI principal: CV AHT (industry standard for predictability/WFM)
|
||||
// CV AHT = (P90 - P50) / P50 como proxy de coeficiente de variación
|
||||
// CV AHT = (P90 - P50) / P50 as a proxy for coefficient of variation
|
||||
const ahtP50 = safeNumber(op.aht_distribution?.p50, 0);
|
||||
const ahtP90 = safeNumber(op.aht_distribution?.p90, 0);
|
||||
|
||||
// Calcular CV AHT como (P90-P50)/P50 (proxy del coeficiente de variación real)
|
||||
// Calculate CV AHT as (P90-P50)/P50 (proxy for the actual coefficient of variation)
|
||||
let cvAht = 0;
|
||||
if (ahtP50 > 0 && ahtP90 > 0) {
|
||||
cvAht = (ahtP90 - ahtP50) / ahtP50;
|
||||
}
|
||||
const cvAhtPercent = Math.round(cvAht * 100);
|
||||
|
||||
// Hold Time como métrica secundaria de complejidad
|
||||
// Hold Time as a secondary metric for complexity
|
||||
const talkHoldAcw = op.talk_hold_acw_p50_by_skill;
|
||||
let avgHoldP50 = 0;
|
||||
if (Array.isArray(talkHoldAcw) && talkHoldAcw.length > 0) {
|
||||
@@ -535,9 +535,9 @@ function buildComplexityPredictabilityDimension(
|
||||
}
|
||||
}
|
||||
|
||||
// Score basado en CV AHT (benchmark: <75% = excelente, <100% = aceptable)
|
||||
// Score based on CV AHT (benchmark: <75% = excellent, <100% = acceptable)
|
||||
// CV <= 75% = 100pts (alta predictibilidad)
|
||||
// CV 75-100% = 80pts (predictibilidad aceptable)
|
||||
// CV 75-100% = 80pts (acceptable predictability)
|
||||
// CV 100-125% = 60pts (variabilidad moderada)
|
||||
// CV 125-150% = 40pts (alta variabilidad)
|
||||
// CV > 150% = 20pts (muy alta variabilidad)
|
||||
@@ -558,16 +558,16 @@ function buildComplexityPredictabilityDimension(
|
||||
let summary = `CV AHT: ${cvAhtPercent}% (benchmark: <75%). `;
|
||||
|
||||
if (cvAhtPercent <= 75) {
|
||||
summary += 'Alta predictibilidad: tiempos de atención consistentes. Excelente para planificación WFM.';
|
||||
summary += 'High predictability: consistent handling times. Excellent for WFM planning.';
|
||||
} else if (cvAhtPercent <= 100) {
|
||||
summary += 'Predictibilidad aceptable: variabilidad moderada en tiempos de atención.';
|
||||
summary += 'Acceptable predictability: moderate variability in handling times.';
|
||||
} else if (cvAhtPercent <= 125) {
|
||||
summary += 'Variabilidad notable: dificulta la planificación de recursos. Considerar estandarización.';
|
||||
summary += 'Notable variability: complicates resource planning. Consider standardization.';
|
||||
} else {
|
||||
summary += 'Alta variabilidad: tiempos muy dispersos. Priorizar scripts guiados y estandarización.';
|
||||
summary += 'High variability: very scattered times. Prioritize guided scripts and standardization.';
|
||||
}
|
||||
|
||||
// Añadir info de Hold P50 promedio si está disponible (proxy de complejidad)
|
||||
// Add Hold P50 average info if available (complexity proxy)
|
||||
if (avgHoldP50 > 0) {
|
||||
summary += ` Hold Time P50: ${Math.round(avgHoldP50)}s.`;
|
||||
}
|
||||
@@ -583,7 +583,7 @@ function buildComplexityPredictabilityDimension(
|
||||
const dimension: DimensionAnalysis = {
|
||||
id: 'complexity_predictability',
|
||||
name: 'complexity_predictability',
|
||||
title: 'Complejidad & Predictibilidad',
|
||||
title: 'Complexity & Predictability',
|
||||
score,
|
||||
percentile: undefined,
|
||||
summary,
|
||||
@@ -594,7 +594,7 @@ function buildComplexityPredictabilityDimension(
|
||||
return dimension;
|
||||
}
|
||||
|
||||
// ==== Satisfacción del Cliente (v3.1) ====
|
||||
// ==== Customer Satisfaction (v3.1) ====
|
||||
|
||||
function buildSatisfactionDimension(
|
||||
raw: BackendRawResults
|
||||
@@ -604,19 +604,19 @@ function buildSatisfactionDimension(
|
||||
|
||||
const hasCSATData = Number.isFinite(csatGlobalRaw) && csatGlobalRaw > 0;
|
||||
|
||||
// Si no hay CSAT, mostrar dimensión con "No disponible"
|
||||
// If no CSAT, show dimension with "Not available"
|
||||
const dimension: DimensionAnalysis = {
|
||||
id: 'customer_satisfaction',
|
||||
name: 'customer_satisfaction',
|
||||
title: 'Satisfacción del Cliente',
|
||||
score: hasCSATData ? Math.round((csatGlobalRaw / 5) * 100) : -1, // -1 indica N/A
|
||||
title: 'Customer Satisfaction',
|
||||
score: hasCSATData ? Math.round((csatGlobalRaw / 5) * 100) : -1, // -1 indicates N/A
|
||||
percentile: undefined,
|
||||
summary: hasCSATData
|
||||
? `CSAT global: ${csatGlobalRaw.toFixed(1)}/5. ${csatGlobalRaw >= 4.0 ? 'Nivel de satisfacción óptimo.' : csatGlobalRaw >= 3.5 ? 'Satisfacción aceptable, margen de mejora.' : 'Satisfacción baja, requiere atención urgente.'}`
|
||||
: 'CSAT no disponible en el dataset. Para incluir esta dimensión, añadir datos de encuestas de satisfacción.',
|
||||
? `Global CSAT: ${csatGlobalRaw.toFixed(1)}/5. ${csatGlobalRaw >= 4.0 ? 'Optimal satisfaction level.' : csatGlobalRaw >= 3.5 ? 'Acceptable satisfaction, room for improvement.' : 'Low satisfaction, requires urgent attention.'}`
|
||||
: 'CSAT not available in dataset. To include this dimension, add satisfaction survey data.',
|
||||
kpi: {
|
||||
label: 'CSAT',
|
||||
value: hasCSATData ? `${csatGlobalRaw.toFixed(1)}/5` : 'No disponible',
|
||||
value: hasCSATData ? `${csatGlobalRaw.toFixed(1)}/5` : 'Not available',
|
||||
changeType: hasCSATData
|
||||
? (csatGlobalRaw >= 4.0 ? 'positive' : csatGlobalRaw >= 3.5 ? 'neutral' : 'negative')
|
||||
: 'neutral'
|
||||
@@ -627,7 +627,7 @@ function buildSatisfactionDimension(
|
||||
return dimension;
|
||||
}
|
||||
|
||||
// ==== Economía - Coste por Interacción (v3.1) ====
|
||||
// ==== Economy - Cost per Interaction (v3.1) ====
|
||||
|
||||
function buildEconomyDimension(
|
||||
raw: BackendRawResults,
|
||||
@@ -637,9 +637,9 @@ function buildEconomyDimension(
|
||||
const op = raw?.operational_performance;
|
||||
const totalAnnual = safeNumber(econ?.cost_breakdown?.total_annual, 0);
|
||||
|
||||
// Benchmark CPI aerolíneas (consistente con ExecutiveSummaryTab)
|
||||
// Airline CPI benchmark (consistent with ExecutiveSummaryTab)
|
||||
// p25: 2.20, p50: 3.50, p75: 4.50, p90: 5.50
|
||||
const CPI_BENCHMARK = 3.50; // p50 aerolíneas
|
||||
const CPI_BENCHMARK = 3.50; // airline p50
|
||||
|
||||
if (totalAnnual <= 0 || totalInteractions <= 0) {
|
||||
return undefined;
|
||||
@@ -652,12 +652,12 @@ function buildEconomyDimension(
|
||||
// Calcular CPI usando cost_volume (non-abandoned) como denominador
|
||||
const cpi = costVolume > 0 ? totalAnnual / costVolume : totalAnnual / totalInteractions;
|
||||
|
||||
// Score basado en percentiles de aerolíneas (CPI invertido: menor = mejor)
|
||||
// CPI <= 2.20 (p25) = 100pts (excelente, top 25%)
|
||||
// Score based on airline percentiles (inverse CPI: lower = better)
|
||||
// CPI <= 2.20 (p25) = 100pts (excellent, top 25%)
|
||||
// CPI 2.20-3.50 (p25-p50) = 80pts (bueno, top 50%)
|
||||
// CPI 3.50-4.50 (p50-p75) = 60pts (promedio)
|
||||
// CPI 3.50-4.50 (p50-p75) = 60pts (average)
|
||||
// CPI 4.50-5.50 (p75-p90) = 40pts (por debajo)
|
||||
// CPI > 5.50 (>p90) = 20pts (crítico)
|
||||
// CPI > 5.50 (>p90) = 20pts (critical)
|
||||
let score: number;
|
||||
if (cpi <= 2.20) {
|
||||
score = 100;
|
||||
@@ -674,24 +674,24 @@ function buildEconomyDimension(
|
||||
const cpiDiff = cpi - CPI_BENCHMARK;
|
||||
const cpiStatus = cpiDiff <= 0 ? 'positive' : cpiDiff <= 0.5 ? 'neutral' : 'negative';
|
||||
|
||||
let summary = `Coste por interacción: €${cpi.toFixed(2)} vs benchmark €${CPI_BENCHMARK.toFixed(2)}. `;
|
||||
let summary = `Cost per interaction: €${cpi.toFixed(2)} vs benchmark €${CPI_BENCHMARK.toFixed(2)}. `;
|
||||
if (cpi <= CPI_BENCHMARK) {
|
||||
summary += 'Eficiencia de costes óptima, por debajo del benchmark del sector.';
|
||||
summary += 'Optimal cost efficiency, below sector benchmark.';
|
||||
} else if (cpi <= 4.50) {
|
||||
summary += 'Coste ligeramente por encima del benchmark, oportunidad de optimización.';
|
||||
summary += 'Cost slightly above benchmark, optimization opportunity.';
|
||||
} else {
|
||||
summary += 'Coste elevado respecto al sector. Priorizar iniciativas de eficiencia.';
|
||||
summary += 'High cost relative to sector. Prioritize efficiency initiatives.';
|
||||
}
|
||||
|
||||
const dimension: DimensionAnalysis = {
|
||||
id: 'economy_costs',
|
||||
name: 'economy_costs',
|
||||
title: 'Economía & Costes',
|
||||
title: 'Economy & Costs',
|
||||
score,
|
||||
percentile: undefined,
|
||||
summary,
|
||||
kpi: {
|
||||
label: 'Coste por Interacción',
|
||||
label: 'Cost per Interaction',
|
||||
value: `€${cpi.toFixed(2)}`,
|
||||
change: `vs benchmark €${CPI_BENCHMARK.toFixed(2)}`,
|
||||
changeType: cpiStatus as 'positive' | 'neutral' | 'negative'
|
||||
@@ -702,7 +702,7 @@ function buildEconomyDimension(
|
||||
return dimension;
|
||||
}
|
||||
|
||||
// ==== Agentic Readiness como dimensión (v3.0) ====
|
||||
// ==== Agentic Readiness as a dimension (v3.0) ====
|
||||
|
||||
function buildAgenticReadinessDimension(
|
||||
raw: BackendRawResults,
|
||||
@@ -720,7 +720,7 @@ function buildAgenticReadinessDimension(
|
||||
if (ar) {
|
||||
score0_10 = safeNumber(ar.final_score, 5);
|
||||
} else {
|
||||
// Calcular aproximado desde métricas disponibles
|
||||
// Calculate approximation from available metrics
|
||||
const ahtP50 = safeNumber(op?.aht_distribution?.p50, 0);
|
||||
const ahtP90 = safeNumber(op?.aht_distribution?.p90, 0);
|
||||
const ratio = ahtP50 > 0 ? ahtP90 / ahtP50 : 2;
|
||||
@@ -779,7 +779,7 @@ function buildAgenticReadinessDimension(
|
||||
}
|
||||
|
||||
|
||||
// ==== Economía y costes (economy_costs) ====
|
||||
// ==== Economy and costs (economy_costs) ====
|
||||
|
||||
function buildEconomicModel(raw: BackendRawResults): EconomicModelData {
|
||||
const econ = raw?.economy_costs;
|
||||
@@ -814,17 +814,17 @@ function buildEconomicModel(raw: BackendRawResults): EconomicModelData {
|
||||
const savingsBreakdown = annualSavings
|
||||
? [
|
||||
{
|
||||
category: 'Ineficiencias operativas (AHT, escalaciones)',
|
||||
category: 'Operational inefficiencies (AHT, escalations)',
|
||||
amount: Math.round(annualSavings * 0.5),
|
||||
percentage: 50,
|
||||
},
|
||||
{
|
||||
category: 'Automatización de volumen repetitivo',
|
||||
category: 'Automation of repetitive volume',
|
||||
amount: Math.round(annualSavings * 0.3),
|
||||
percentage: 30,
|
||||
},
|
||||
{
|
||||
category: 'Otros beneficios (calidad, CX)',
|
||||
category: 'Other benefits (quality, CX)',
|
||||
amount: Math.round(annualSavings * 0.2),
|
||||
percentage: 20,
|
||||
},
|
||||
@@ -834,7 +834,7 @@ function buildEconomicModel(raw: BackendRawResults): EconomicModelData {
|
||||
const costBreakdown = currentAnnualCost
|
||||
? [
|
||||
{
|
||||
category: 'Coste laboral',
|
||||
category: 'Labor cost',
|
||||
amount: laborAnnual,
|
||||
percentage: Math.round(
|
||||
(laborAnnual / currentAnnualCost) * 100
|
||||
@@ -848,7 +848,7 @@ function buildEconomicModel(raw: BackendRawResults): EconomicModelData {
|
||||
),
|
||||
},
|
||||
{
|
||||
category: 'Tecnología',
|
||||
category: 'Technology',
|
||||
amount: techAnnual,
|
||||
percentage: Math.round(
|
||||
(techAnnual / currentAnnualCost) * 100
|
||||
@@ -870,7 +870,7 @@ function buildEconomicModel(raw: BackendRawResults): EconomicModelData {
|
||||
};
|
||||
}
|
||||
|
||||
// buildEconomyDimension eliminado en v3.0 - economía integrada en otras dimensiones y modelo económico
|
||||
// buildEconomyDimension removed in v3.0 - economy integrated into other dimensions and economic model
|
||||
|
||||
/**
|
||||
* Transforma el JSON del backend (results) al AnalysisData
|
||||
@@ -914,7 +914,7 @@ export function mapBackendResultsToAnalysisData(
|
||||
Math.min(100, Math.round(arScore * 10))
|
||||
);
|
||||
|
||||
// v3.3: 7 dimensiones (Complejidad recuperada con métrica Hold Time >60s)
|
||||
// v3.3: 7 dimensions (Complexity recovered with Hold Time metric >60s)
|
||||
const { dimension: volumetryDimension, extraKpis } =
|
||||
buildVolumetryDimension(raw);
|
||||
const operationalEfficiencyDimension = buildOperationalEfficiencyDimension(raw);
|
||||
@@ -946,7 +946,7 @@ export function mapBackendResultsToAnalysisData(
|
||||
|
||||
const csatAvg = computeCsatAverage(cs);
|
||||
|
||||
// CSAT global (opcional)
|
||||
// Global CSAT (opcional)
|
||||
const csatGlobalRaw = safeNumber(cs?.csat_global, NaN);
|
||||
const csatGlobal =
|
||||
Number.isFinite(csatGlobalRaw) && csatGlobalRaw > 0
|
||||
@@ -954,30 +954,30 @@ export function mapBackendResultsToAnalysisData(
|
||||
: undefined;
|
||||
|
||||
|
||||
// KPIs de resumen (los 4 primeros son los que se ven en "Métricas de Contacto")
|
||||
// Summary KPIs (the first 4 are shown in "Contact Metrics")
|
||||
const summaryKpis: Kpi[] = [];
|
||||
|
||||
// 1) Interacciones Totales (volumen backend)
|
||||
// 1) Total Interactions (backend volume)
|
||||
summaryKpis.push({
|
||||
label: 'Interacciones Totales',
|
||||
label: 'Total Interactions',
|
||||
value:
|
||||
totalVolume > 0
|
||||
? totalVolume.toLocaleString('es-ES')
|
||||
: 'N/D',
|
||||
});
|
||||
|
||||
// 2) AHT Promedio (P50 de distribución de AHT)
|
||||
// 2) Average AHT (P50 of AHT distribution)
|
||||
const ahtP50 = safeNumber(op?.aht_distribution?.p50, 0);
|
||||
summaryKpis.push({
|
||||
label: 'AHT Promedio',
|
||||
label: 'Average AHT',
|
||||
value: ahtP50
|
||||
? `${Math.round(ahtP50)}s`
|
||||
: 'N/D',
|
||||
});
|
||||
|
||||
// 3) Tasa FCR
|
||||
// 3) FCR Rate
|
||||
summaryKpis.push({
|
||||
label: 'Tasa FCR',
|
||||
label: 'FCR Rate',
|
||||
value:
|
||||
fcrPct !== undefined
|
||||
? `${Math.round(fcrPct)}%`
|
||||
@@ -993,18 +993,18 @@ export function mapBackendResultsToAnalysisData(
|
||||
: 'N/D',
|
||||
});
|
||||
|
||||
// --- KPIs adicionales, usados en otras secciones ---
|
||||
// --- Additional KPIs, used in other sections ---
|
||||
|
||||
if (numChannels > 0) {
|
||||
summaryKpis.push({
|
||||
label: 'Canales analizados',
|
||||
label: 'Channels analyzed',
|
||||
value: String(numChannels),
|
||||
});
|
||||
}
|
||||
|
||||
if (numSkills > 0) {
|
||||
summaryKpis.push({
|
||||
label: 'Skills analizadas',
|
||||
label: 'Skills analyzed',
|
||||
value: String(numSkills),
|
||||
});
|
||||
}
|
||||
@@ -1014,7 +1014,7 @@ export function mapBackendResultsToAnalysisData(
|
||||
value: `${arScore.toFixed(1)}/10`,
|
||||
});
|
||||
|
||||
// KPIs de economía (backend)
|
||||
// Economy KPIs (backend)
|
||||
const econ = raw?.economy_costs;
|
||||
const totalAnnual = safeNumber(
|
||||
econ?.cost_breakdown?.total_annual,
|
||||
@@ -1027,13 +1027,13 @@ export function mapBackendResultsToAnalysisData(
|
||||
|
||||
if (totalAnnual) {
|
||||
summaryKpis.push({
|
||||
label: 'Coste anual actual (backend)',
|
||||
label: 'Current annual cost (backend)',
|
||||
value: `€${totalAnnual.toFixed(0)}`,
|
||||
});
|
||||
}
|
||||
if (annualSavings) {
|
||||
summaryKpis.push({
|
||||
label: 'Ahorro potencial anual (backend)',
|
||||
label: 'Annual potential savings (backend)',
|
||||
value: `€${annualSavings.toFixed(0)}`,
|
||||
});
|
||||
}
|
||||
@@ -1043,22 +1043,22 @@ export function mapBackendResultsToAnalysisData(
|
||||
const economicModel = buildEconomicModel(raw);
|
||||
const benchmarkData = buildBenchmarkData(raw);
|
||||
|
||||
// Generar findings y recommendations basados en volumetría
|
||||
// Generate findings and recommendations based on volumetry
|
||||
const findings: Finding[] = [];
|
||||
const recommendations: Recommendation[] = [];
|
||||
|
||||
// Extraer offHoursPct de la dimensión de volumetría
|
||||
// Extract offHoursPct from the volumetry dimension
|
||||
const offHoursPct = volumetryDimension?.distribution_data?.off_hours_pct ?? 0;
|
||||
const offHoursPctValue = offHoursPct * 100; // Convertir de 0-1 a 0-100
|
||||
const offHoursPctValue = offHoursPct * 100; // Convert from 0-1 a 0-100
|
||||
|
||||
if (offHoursPctValue > 20) {
|
||||
const offHoursVolume = Math.round(totalVolume * offHoursPctValue / 100);
|
||||
findings.push({
|
||||
type: offHoursPctValue > 30 ? 'critical' : 'warning',
|
||||
title: 'Alto Volumen Fuera de Horario',
|
||||
text: `${offHoursPctValue.toFixed(0)}% de interacciones fuera de horario (8-19h)`,
|
||||
title: 'High Off-Hours Volume',
|
||||
text: `${offHoursPctValue.toFixed(0)}% of off-hours interactions (8-19h)`,
|
||||
dimensionId: 'volumetry_distribution',
|
||||
description: `${offHoursVolume.toLocaleString()} interacciones (${offHoursPctValue.toFixed(1)}%) ocurren fuera de horario laboral. Oportunidad ideal para implementar agentes virtuales 24/7.`,
|
||||
description: `${offHoursVolume.toLocaleString()} interacciones (${offHoursPctValue.toFixed(1)}%) ocurren outside business hours. Ideal opportunity to implement 24/7 virtual agents.`,
|
||||
impact: offHoursPctValue > 30 ? 'high' : 'medium'
|
||||
});
|
||||
|
||||
@@ -1066,12 +1066,12 @@ export function mapBackendResultsToAnalysisData(
|
||||
const estimatedSavings = Math.round(offHoursVolume * estimatedContainment / 100);
|
||||
recommendations.push({
|
||||
priority: 'high',
|
||||
title: 'Implementar Agente Virtual 24/7',
|
||||
text: `Desplegar agente virtual para atender ${offHoursPctValue.toFixed(0)}% de interacciones fuera de horario`,
|
||||
description: `${offHoursVolume.toLocaleString()} interacciones ocurren fuera de horario laboral (19:00-08:00). Un agente virtual puede resolver ~${estimatedContainment}% de estas consultas automáticamente.`,
|
||||
title: 'Implement 24/7 Virtual Agent',
|
||||
text: `Deploy virtual agent to handle ${offHoursPctValue.toFixed(0)}% of off-hours interactions`,
|
||||
description: `${offHoursVolume.toLocaleString()} interactions occur outside business hours (19:00-08:00). A virtual agent can resolve ~${estimatedContainment}% of these queries automatically.`,
|
||||
dimensionId: 'volumetry_distribution',
|
||||
impact: `Potencial de contención: ${estimatedSavings.toLocaleString()} interacciones/período`,
|
||||
timeline: '1-3 meses'
|
||||
impact: `Containment potential: ${estimatedSavings.toLocaleString()} interactions/period`,
|
||||
timeline: '1-3 months'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1080,7 +1080,7 @@ export function mapBackendResultsToAnalysisData(
|
||||
overallHealthScore,
|
||||
summaryKpis: mergedKpis,
|
||||
dimensions,
|
||||
heatmapData: [], // el heatmap por skill lo seguimos generando en el front
|
||||
heatmapData: [], // skill heatmap still generated on frontend
|
||||
findings,
|
||||
recommendations,
|
||||
opportunities: [],
|
||||
@@ -1153,8 +1153,8 @@ export function buildHeatmapFromBackend(
|
||||
const abandonmentRateBackend = safeNumber(op?.abandonment_rate, 0);
|
||||
|
||||
// ========================================================================
|
||||
// NUEVO: Métricas REALES por skill (transfer, abandonment, FCR)
|
||||
// Esto elimina la estimación de transfer rate basada en CV y hold time
|
||||
// NEW: REAL metrics per skill (transfer, abandonment, FCR)
|
||||
// This eliminates the transfer rate estimation based on CV and hold time
|
||||
// ========================================================================
|
||||
const metricsBySkillRaw = Array.isArray(op?.metrics_by_skill)
|
||||
? op.metrics_by_skill
|
||||
@@ -1166,9 +1166,9 @@ export function buildHeatmapFromBackend(
|
||||
abandonment_rate: number;
|
||||
fcr_tecnico: number;
|
||||
fcr_real: number;
|
||||
aht_mean: number; // AHT promedio del backend (solo VALID - consistente con fresh path)
|
||||
aht_total: number; // AHT total (ALL rows incluyendo NOISE/ZOMBIE/ABANDON) - solo informativo
|
||||
hold_time_mean: number; // Hold time promedio (consistente con fresh path - MEAN, no P50)
|
||||
aht_mean: number; // Average AHT del backend (only VALID - consistent with fresh path)
|
||||
aht_total: number; // Total AHT (ALL rows incluyendo NOISE/ZOMBIE/ABANDON) - informational only
|
||||
hold_time_mean: number; // Average Hold time (consistent with fresh path - MEAN, not P50)
|
||||
}>();
|
||||
|
||||
for (const m of metricsBySkillRaw) {
|
||||
@@ -1178,9 +1178,9 @@ export function buildHeatmapFromBackend(
|
||||
abandonment_rate: safeNumber(m.abandonment_rate, NaN),
|
||||
fcr_tecnico: safeNumber(m.fcr_tecnico, NaN),
|
||||
fcr_real: safeNumber(m.fcr_real, NaN),
|
||||
aht_mean: safeNumber(m.aht_mean, NaN), // AHT promedio (solo VALID)
|
||||
aht_total: safeNumber(m.aht_total, NaN), // AHT total (ALL rows)
|
||||
hold_time_mean: safeNumber(m.hold_time_mean, NaN), // Hold time promedio (MEAN)
|
||||
aht_mean: safeNumber(m.aht_mean, NaN), // Average AHT (solo VALID)
|
||||
aht_total: safeNumber(m.aht_total, NaN), // Total AHT (ALL rows)
|
||||
hold_time_mean: safeNumber(m.hold_time_mean, NaN), // Average Hold time (MEAN)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1251,7 +1251,7 @@ export function buildHeatmapFromBackend(
|
||||
|
||||
if (!skillLabels.length) return [];
|
||||
|
||||
// Para normalizar la repetitividad según volumen
|
||||
// To normalize repetitiveness according to volume
|
||||
const volumesForNorm = skillVolumes.filter((v) => v > 0);
|
||||
const minVol =
|
||||
volumesForNorm.length > 0
|
||||
@@ -1268,13 +1268,13 @@ export function buildHeatmapFromBackend(
|
||||
const skill = skillLabels[i];
|
||||
const volume = safeNumber(skillVolumes[i], 0);
|
||||
|
||||
// Buscar P50s por nombre de skill (no por índice)
|
||||
// Search for P50s by skill name (not by index)
|
||||
const talkHold = talkHoldAcwMap.get(skill);
|
||||
const talk_p50 = talkHold?.talk_p50 ?? 0;
|
||||
const hold_p50 = talkHold?.hold_p50 ?? 0;
|
||||
const acw_p50 = talkHold?.acw_p50 ?? 0;
|
||||
|
||||
// Buscar métricas REALES del backend (metrics_by_skill)
|
||||
// Search for REAL metrics from backend (metrics_by_skill)
|
||||
const realSkillMetrics = metricsBySkillMap.get(skill);
|
||||
|
||||
// AHT: Use ONLY aht_mean from backend metrics_by_skill
|
||||
@@ -1284,7 +1284,7 @@ export function buildHeatmapFromBackend(
|
||||
: 0;
|
||||
|
||||
// AHT Total: AHT calculado con TODAS las filas (incluye NOISE/ZOMBIE/ABANDON)
|
||||
// Solo para información/comparación - no se usa en cálculos
|
||||
// Only for information/comparison - not used in calculations
|
||||
const aht_total = (realSkillMetrics && Number.isFinite(realSkillMetrics.aht_total) && realSkillMetrics.aht_total > 0)
|
||||
? realSkillMetrics.aht_total
|
||||
: aht_mean; // fallback to aht_mean if not available
|
||||
@@ -1299,7 +1299,7 @@ export function buildHeatmapFromBackend(
|
||||
annual_volume * aht_mean * COST_PER_SECOND
|
||||
);
|
||||
|
||||
// Buscar inefficiency data por nombre de skill (no por índice)
|
||||
// Search for inefficiency data by skill name (not by index)
|
||||
const ineff = ineffBySkillMap.get(skill);
|
||||
const aht_p50_backend = ineff?.aht_p50 ?? aht_mean;
|
||||
const aht_p90_backend = ineff?.aht_p90 ?? aht_mean;
|
||||
@@ -1311,10 +1311,10 @@ export function buildHeatmapFromBackend(
|
||||
(aht_p90_backend - aht_p50_backend) / aht_p50_backend;
|
||||
}
|
||||
|
||||
// Dimensiones agentic similares a las que tenías en generateHeatmapData,
|
||||
// Agentic dimensions similar to those you had in generateHeatmapData,
|
||||
// pero usando valores reales en lugar de aleatorios.
|
||||
|
||||
// 1) Predictibilidad (menor CV => mayor puntuación)
|
||||
// 1) Predictability (lower CV => higher score)
|
||||
const predictability_score = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
@@ -1324,8 +1324,8 @@ export function buildHeatmapFromBackend(
|
||||
);
|
||||
|
||||
// 2) Transfer rate POR SKILL
|
||||
// PRIORIDAD 1: Usar métricas REALES del backend (metrics_by_skill)
|
||||
// PRIORIDAD 2: Fallback a estimación basada en CV y hold time
|
||||
// PRIORITY 1: Use REAL metrics from backend (metrics_by_skill)
|
||||
// PRIORITY 2: Fallback to estimation based on CV and hold time
|
||||
|
||||
let skillTransferRate: number;
|
||||
let skillAbandonmentRate: number;
|
||||
@@ -1333,7 +1333,7 @@ export function buildHeatmapFromBackend(
|
||||
let skillFcrReal: number;
|
||||
|
||||
if (realSkillMetrics && Number.isFinite(realSkillMetrics.transfer_rate)) {
|
||||
// Usar métricas REALES del backend
|
||||
// Use REAL metrics from backend
|
||||
skillTransferRate = realSkillMetrics.transfer_rate;
|
||||
skillAbandonmentRate = Number.isFinite(realSkillMetrics.abandonment_rate)
|
||||
? realSkillMetrics.abandonment_rate
|
||||
@@ -1347,14 +1347,14 @@ export function buildHeatmapFromBackend(
|
||||
} else {
|
||||
// NO usar estimación - usar valores globales del backend directamente
|
||||
// Esto asegura consistencia con el fresh path que usa valores directos del CSV
|
||||
skillTransferRate = globalEscalation; // Usar tasa global, sin estimación
|
||||
skillTransferRate = globalEscalation; // Use global rate, no estimation
|
||||
skillAbandonmentRate = abandonmentRateBackend;
|
||||
skillFcrTecnico = 100 - skillTransferRate;
|
||||
skillFcrReal = globalFcrPct;
|
||||
console.warn(`⚠️ No metrics_by_skill for skill ${skill} - using global rates`);
|
||||
}
|
||||
|
||||
// Complejidad inversa basada en transfer rate del skill
|
||||
// Inverse complexity based on skill transfer rate
|
||||
const complexity_inverse_score = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
@@ -1446,10 +1446,10 @@ export function buildHeatmapFromBackend(
|
||||
volume,
|
||||
cost_volume: costVolume,
|
||||
aht_seconds: aht_mean,
|
||||
aht_total: aht_total, // AHT con TODAS las filas (solo informativo)
|
||||
aht_total: aht_total, // AHT con TODAS las filas (informational only)
|
||||
metrics: {
|
||||
fcr: Math.round(skillFcrReal), // FCR Real (sin transfer Y sin recontacto 7d)
|
||||
fcr_tecnico: Math.round(skillFcrTecnico), // FCR Técnico (comparable con benchmarks)
|
||||
fcr_tecnico: Math.round(skillFcrTecnico), // Technical FCR (comparable con benchmarks)
|
||||
aht: ahtMetric,
|
||||
csat: csatMetric0_100,
|
||||
hold_time: holdMetric,
|
||||
@@ -1457,12 +1457,12 @@ export function buildHeatmapFromBackend(
|
||||
abandonment_rate: Math.round(skillAbandonmentRate),
|
||||
},
|
||||
annual_cost,
|
||||
cpi: skillCpi, // CPI real del backend (si disponible)
|
||||
cpi: skillCpi, // Real CPI from backend (if available)
|
||||
variability: {
|
||||
cv_aht: Math.round(cv_aht * 100), // %
|
||||
cv_talk_time: 0,
|
||||
cv_hold_time: 0,
|
||||
transfer_rate: skillTransferRate, // Transfer rate REAL o estimado
|
||||
transfer_rate: skillTransferRate, // REAL or estimated transfer rate
|
||||
},
|
||||
automation_readiness,
|
||||
dimensions: {
|
||||
@@ -1491,19 +1491,19 @@ function buildBenchmarkData(raw: BackendRawResults): AnalysisData['benchmarkData
|
||||
|
||||
const benchmarkData: AnalysisData['benchmarkData'] = [];
|
||||
|
||||
// Benchmarks hardcoded para sector aéreo
|
||||
// Hardcoded benchmarks for airline sector
|
||||
const AIRLINE_BENCHMARKS = {
|
||||
aht_p50: 380, // segundos
|
||||
aht_p50: 380, // seconds
|
||||
fcr: 70, // % (rango 68-72%)
|
||||
abandonment: 5, // % (rango 5-8%)
|
||||
ratio_p90_p50: 2.0, // ratio saludable
|
||||
cpi: 5.25 // € (rango €4.50-€6.00)
|
||||
};
|
||||
|
||||
// 1. AHT Promedio (benchmark sector aéreo: 380s)
|
||||
// 1. AHT Promedio (benchmark airline sector: 380s)
|
||||
const ahtP50 = safeNumber(op?.aht_distribution?.p50, 0);
|
||||
if (ahtP50 > 0) {
|
||||
// Percentil: menor AHT = mejor. Si AHT <= benchmark = P75+
|
||||
// Percentile: lower AHT = better. If AHT <= benchmark = P75+
|
||||
const ahtPercentile = ahtP50 <= AIRLINE_BENCHMARKS.aht_p50
|
||||
? Math.min(90, 75 + Math.round((AIRLINE_BENCHMARKS.aht_p50 - ahtP50) / 10))
|
||||
: Math.max(10, 75 - Math.round((ahtP50 - AIRLINE_BENCHMARKS.aht_p50) / 5));
|
||||
@@ -1521,15 +1521,15 @@ function buildBenchmarkData(raw: BackendRawResults): AnalysisData['benchmarkData
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Tasa FCR (benchmark sector aéreo: 70%)
|
||||
// 2. FCR Rate (benchmark airline sector: 70%)
|
||||
const fcrRate = safeNumber(op?.fcr_rate, NaN);
|
||||
if (Number.isFinite(fcrRate) && fcrRate >= 0) {
|
||||
// Percentil: mayor FCR = mejor
|
||||
// Percentile: higher FCR = better
|
||||
const fcrPercentile = fcrRate >= AIRLINE_BENCHMARKS.fcr
|
||||
? Math.min(90, 50 + Math.round((fcrRate - AIRLINE_BENCHMARKS.fcr) * 2))
|
||||
: Math.max(10, 50 - Math.round((AIRLINE_BENCHMARKS.fcr - fcrRate) * 2));
|
||||
benchmarkData.push({
|
||||
kpi: 'Tasa FCR',
|
||||
kpi: 'FCR Rate',
|
||||
userValue: fcrRate / 100,
|
||||
userDisplay: `${Math.round(fcrRate)}%`,
|
||||
industryValue: AIRLINE_BENCHMARKS.fcr / 100,
|
||||
@@ -1560,15 +1560,15 @@ function buildBenchmarkData(raw: BackendRawResults): AnalysisData['benchmarkData
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Tasa de Abandono (benchmark sector aéreo: 5%)
|
||||
// 4. Abandonment Rate (benchmark airline sector: 5%)
|
||||
const abandonRate = safeNumber(op?.abandonment_rate, NaN);
|
||||
if (Number.isFinite(abandonRate) && abandonRate >= 0) {
|
||||
// Percentil: menor abandono = mejor
|
||||
// Percentile: lower abandonment = better
|
||||
const abandonPercentile = abandonRate <= AIRLINE_BENCHMARKS.abandonment
|
||||
? Math.min(90, 75 + Math.round((AIRLINE_BENCHMARKS.abandonment - abandonRate) * 5))
|
||||
: Math.max(10, 75 - Math.round((abandonRate - AIRLINE_BENCHMARKS.abandonment) * 5));
|
||||
benchmarkData.push({
|
||||
kpi: 'Tasa de Abandono',
|
||||
kpi: 'Abandonment Rate',
|
||||
userValue: abandonRate / 100,
|
||||
userDisplay: `${abandonRate.toFixed(1)}%`,
|
||||
industryValue: AIRLINE_BENCHMARKS.abandonment / 100,
|
||||
@@ -1581,11 +1581,11 @@ function buildBenchmarkData(raw: BackendRawResults): AnalysisData['benchmarkData
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Ratio P90/P50 (benchmark sector aéreo: <2.0)
|
||||
// 5. Ratio P90/P50 (benchmark airline sector: <2.0)
|
||||
const ahtP90 = safeNumber(op?.aht_distribution?.p90, 0);
|
||||
const ratio = ahtP50 > 0 && ahtP90 > 0 ? ahtP90 / ahtP50 : 0;
|
||||
if (ratio > 0) {
|
||||
// Percentil: menor ratio = mejor
|
||||
// Percentile: lower ratio = better
|
||||
const ratioPercentile = ratio <= AIRLINE_BENCHMARKS.ratio_p90_p50
|
||||
? Math.min(90, 75 + Math.round((AIRLINE_BENCHMARKS.ratio_p90_p50 - ratio) * 30))
|
||||
: Math.max(10, 75 - Math.round((ratio - AIRLINE_BENCHMARKS.ratio_p90_p50) * 30));
|
||||
@@ -1603,13 +1603,13 @@ function buildBenchmarkData(raw: BackendRawResults): AnalysisData['benchmarkData
|
||||
});
|
||||
}
|
||||
|
||||
// 6. Tasa de Transferencia/Escalación
|
||||
// 6. Transfer/Escalation Rate
|
||||
const escalationRate = safeNumber(op?.escalation_rate, NaN);
|
||||
if (Number.isFinite(escalationRate) && escalationRate >= 0) {
|
||||
// Menor escalación = mejor percentil
|
||||
// Menor escalación = better percentil
|
||||
const escalationPercentile = Math.max(10, Math.min(90, Math.round(100 - escalationRate * 5)));
|
||||
benchmarkData.push({
|
||||
kpi: 'Tasa de Transferencia',
|
||||
kpi: 'Transfer Rate',
|
||||
userValue: escalationRate / 100,
|
||||
userDisplay: `${escalationRate.toFixed(1)}%`,
|
||||
industryValue: 0.15,
|
||||
@@ -1622,7 +1622,7 @@ function buildBenchmarkData(raw: BackendRawResults): AnalysisData['benchmarkData
|
||||
});
|
||||
}
|
||||
|
||||
// 7. CPI - Coste por Interacción (benchmark sector aéreo: €4.50-€6.00)
|
||||
// 7. CPI - Cost per Interaction (benchmark airline sector: €4.50-€6.00)
|
||||
const econ = raw?.economy_costs;
|
||||
const totalAnnualCost = safeNumber(econ?.cost_breakdown?.total_annual, 0);
|
||||
const volumetry = raw?.volumetry;
|
||||
@@ -1634,7 +1634,7 @@ function buildBenchmarkData(raw: BackendRawResults): AnalysisData['benchmarkData
|
||||
|
||||
if (totalAnnualCost > 0 && totalInteractions > 0) {
|
||||
const cpi = totalAnnualCost / totalInteractions;
|
||||
// Menor CPI = mejor. Si CPI <= 4.50 = excelente (P90+), si CPI >= 6.00 = malo (P25-)
|
||||
// Lower CPI = better. If CPI <= 4.50 = excellent (P90+), if CPI >= 6.00 = poor (P25-)
|
||||
let cpiPercentile: number;
|
||||
if (cpi <= 4.50) {
|
||||
cpiPercentile = Math.min(95, 90 + Math.round((4.50 - cpi) * 10));
|
||||
@@ -1647,7 +1647,7 @@ function buildBenchmarkData(raw: BackendRawResults): AnalysisData['benchmarkData
|
||||
}
|
||||
|
||||
benchmarkData.push({
|
||||
kpi: 'Coste por Interacción (CPI)',
|
||||
kpi: 'Cost per Interaction (CPI)',
|
||||
userValue: cpi,
|
||||
userDisplay: `€${cpi.toFixed(2)}`,
|
||||
industryValue: AIRLINE_BENCHMARKS.cpi,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// utils/dataTransformation.ts
|
||||
// Pipeline de transformación de datos raw a métricas procesadas
|
||||
// Raw data to processed metrics transformation pipeline
|
||||
|
||||
import type { RawInteraction } from '../types';
|
||||
|
||||
/**
|
||||
* Paso 1: Limpieza de Ruido
|
||||
* Elimina interacciones con duration < 10 segundos (falsos contactos o errores de sistema)
|
||||
* Step 1: Noise Cleanup
|
||||
* Removes interactions with duration < 10 seconds (false contacts or system errors)
|
||||
*/
|
||||
export function cleanNoiseFromData(interactions: RawInteraction[]): RawInteraction[] {
|
||||
const MIN_DURATION_SECONDS = 10;
|
||||
@@ -22,30 +22,30 @@ export function cleanNoiseFromData(interactions: RawInteraction[]): RawInteracti
|
||||
const removedCount = interactions.length - cleaned.length;
|
||||
const removedPercentage = ((removedCount / interactions.length) * 100).toFixed(1);
|
||||
|
||||
console.log(`🧹 Limpieza de Ruido: ${removedCount} interacciones eliminadas (${removedPercentage}% del total)`);
|
||||
console.log(`✅ Interacciones limpias: ${cleaned.length}`);
|
||||
console.log(`🧹 Noise Cleanup: ${removedCount} interactions removed (${removedPercentage}% of total)`);
|
||||
console.log(`✅ Clean interactions: ${cleaned.length}`);
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Métricas base calculadas por skill
|
||||
* Base metrics calculated by skill
|
||||
*/
|
||||
export interface SkillBaseMetrics {
|
||||
skill: string;
|
||||
volume: number; // Número de interacciones
|
||||
aht_mean: number; // AHT promedio (segundos)
|
||||
aht_std: number; // Desviación estándar del AHT
|
||||
transfer_rate: number; // Tasa de transferencia (0-100)
|
||||
total_cost: number; // Coste total (€)
|
||||
volume: number; // Number of interactions
|
||||
aht_mean: number; // Average AHT (seconds)
|
||||
aht_std: number; // AHT standard deviation
|
||||
transfer_rate: number; // Transfer rate (0-100)
|
||||
total_cost: number; // Total cost (€)
|
||||
|
||||
// Datos auxiliares para cálculos posteriores
|
||||
aht_values: number[]; // Array de todos los AHT para percentiles
|
||||
// Auxiliary data for subsequent calculations
|
||||
aht_values: number[]; // Array of all AHT values for percentiles
|
||||
}
|
||||
|
||||
/**
|
||||
* Paso 2: Calcular Métricas Base por Skill
|
||||
* Agrupa por skill y calcula volumen, AHT promedio, desviación estándar, tasa de transferencia y coste
|
||||
* Step 2: Calculate Base Metrics by Skill
|
||||
* Groups by skill and calculates volume, average AHT, standard deviation, transfer rate and cost
|
||||
*/
|
||||
export function calculateSkillBaseMetrics(
|
||||
interactions: RawInteraction[],
|
||||
@@ -53,7 +53,7 @@ export function calculateSkillBaseMetrics(
|
||||
): SkillBaseMetrics[] {
|
||||
const COST_PER_SECOND = costPerHour / 3600;
|
||||
|
||||
// Agrupar por skill
|
||||
// Group by skill
|
||||
const skillGroups = new Map<string, RawInteraction[]>();
|
||||
|
||||
interactions.forEach(interaction => {
|
||||
@@ -64,31 +64,31 @@ export function calculateSkillBaseMetrics(
|
||||
skillGroups.get(skill)!.push(interaction);
|
||||
});
|
||||
|
||||
// Calcular métricas por skill
|
||||
// Calculate metrics per skill
|
||||
const metrics: SkillBaseMetrics[] = [];
|
||||
|
||||
skillGroups.forEach((skillInteractions, skill) => {
|
||||
const volume = skillInteractions.length;
|
||||
|
||||
// Calcular AHT para cada interacción
|
||||
// Calculate AHT for each interaction
|
||||
const ahtValues = skillInteractions.map(i =>
|
||||
i.duration_talk + i.hold_time + i.wrap_up_time
|
||||
);
|
||||
|
||||
// AHT promedio
|
||||
// Average AHT
|
||||
const ahtMean = ahtValues.reduce((sum, val) => sum + val, 0) / volume;
|
||||
|
||||
// Desviación estándar del AHT
|
||||
// AHT standard deviation
|
||||
const variance = ahtValues.reduce((sum, val) =>
|
||||
sum + Math.pow(val - ahtMean, 2), 0
|
||||
) / volume;
|
||||
const ahtStd = Math.sqrt(variance);
|
||||
|
||||
// Tasa de transferencia
|
||||
// Transfer rate
|
||||
const transferCount = skillInteractions.filter(i => i.transfer_flag).length;
|
||||
const transferRate = (transferCount / volume) * 100;
|
||||
|
||||
// Coste total
|
||||
// Total cost
|
||||
const totalCost = ahtValues.reduce((sum, aht) =>
|
||||
sum + (aht * COST_PER_SECOND), 0
|
||||
);
|
||||
@@ -104,82 +104,82 @@ export function calculateSkillBaseMetrics(
|
||||
});
|
||||
});
|
||||
|
||||
// Ordenar por volumen descendente
|
||||
// Sort by descending volume
|
||||
metrics.sort((a, b) => b.volume - a.volume);
|
||||
|
||||
console.log(`📊 Métricas Base calculadas para ${metrics.length} skills`);
|
||||
console.log(`📊 Base Metrics calculated for ${metrics.length} skills`);
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dimensiones transformadas para Agentic Readiness Score
|
||||
* Transformed dimensions for Agentic Readiness Score
|
||||
*/
|
||||
export interface SkillDimensions {
|
||||
skill: string;
|
||||
volume: number;
|
||||
|
||||
// Dimensión 1: Predictibilidad (0-10)
|
||||
// Dimension 1: Predictability (0-10)
|
||||
predictability_score: number;
|
||||
predictability_cv: number; // Coeficiente de Variación (para referencia)
|
||||
predictability_cv: number; // Coefficient of Variation (for reference)
|
||||
|
||||
// Dimensión 2: Complejidad Inversa (0-10)
|
||||
// Dimension 2: Inverse Complexity (0-10)
|
||||
complexity_inverse_score: number;
|
||||
complexity_transfer_rate: number; // Tasa de transferencia (para referencia)
|
||||
complexity_transfer_rate: number; // Transfer rate (for reference)
|
||||
|
||||
// Dimensión 3: Repetitividad/Impacto (0-10)
|
||||
// Dimension 3: Repetitiveness/Impact (0-10)
|
||||
repetitivity_score: number;
|
||||
|
||||
// Datos auxiliares
|
||||
// Auxiliary data
|
||||
aht_mean: number;
|
||||
total_cost: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paso 3: Transformar Métricas Base a Dimensiones
|
||||
* Aplica las fórmulas de normalización para obtener scores 0-10
|
||||
* Step 3: Transform Base Metrics to Dimensions
|
||||
* Applies normalization formulas to obtain 0-10 scores
|
||||
*/
|
||||
export function transformToDimensions(
|
||||
baseMetrics: SkillBaseMetrics[]
|
||||
): SkillDimensions[] {
|
||||
return baseMetrics.map(metric => {
|
||||
// Dimensión 1: Predictibilidad (Proxy: Variabilidad del AHT)
|
||||
// CV = desviación estándar / media
|
||||
// Dimension 1: Predictability (Proxy: AHT Variability)
|
||||
// CV = standard deviation / mean
|
||||
const cv = metric.aht_std / metric.aht_mean;
|
||||
|
||||
// Normalización: CV <= 0.3 → 10, CV >= 1.5 → 0
|
||||
// Fórmula: MAX(0, MIN(10, 10 - ((CV - 0.3) / 1.2 * 10)))
|
||||
// Normalization: CV <= 0.3 → 10, CV >= 1.5 → 0
|
||||
// Formula: MAX(0, MIN(10, 10 - ((CV - 0.3) / 1.2 * 10)))
|
||||
const predictabilityScore = Math.max(0, Math.min(10,
|
||||
10 - ((cv - 0.3) / 1.2 * 10)
|
||||
));
|
||||
|
||||
// Dimensión 2: Complejidad Inversa (Proxy: Tasa de Transferencia)
|
||||
// T = tasa de transferencia (%)
|
||||
// Dimension 2: Inverse Complexity (Proxy: Transfer Rate)
|
||||
// T = transfer rate (%)
|
||||
const transferRate = metric.transfer_rate;
|
||||
|
||||
// Normalización: T <= 5% → 10, T >= 30% → 0
|
||||
// Fórmula: MAX(0, MIN(10, 10 - ((T - 0.05) / 0.25 * 10)))
|
||||
// Normalization: T <= 5% → 10, T >= 30% → 0
|
||||
// Formula: MAX(0, MIN(10, 10 - ((T - 0.05) / 0.25 * 10)))
|
||||
const complexityInverseScore = Math.max(0, Math.min(10,
|
||||
10 - ((transferRate / 100 - 0.05) / 0.25 * 10)
|
||||
));
|
||||
|
||||
// Dimensión 3: Repetitividad/Impacto (Proxy: Volumen)
|
||||
// Normalización fija: > 5,000 llamadas/mes = 10, < 100 = 0
|
||||
// Dimension 3: Repetitiveness/Impact (Proxy: Volume)
|
||||
// Fixed normalization: > 5,000 calls/month = 10, < 100 = 0
|
||||
let repetitivityScore: number;
|
||||
if (metric.volume >= 5000) {
|
||||
repetitivityScore = 10;
|
||||
} else if (metric.volume <= 100) {
|
||||
repetitivityScore = 0;
|
||||
} else {
|
||||
// Interpolación lineal entre 100 y 5000
|
||||
// Linear interpolation between 100 and 5000
|
||||
repetitivityScore = ((metric.volume - 100) / (5000 - 100)) * 10;
|
||||
}
|
||||
|
||||
return {
|
||||
skill: metric.skill,
|
||||
volume: metric.volume,
|
||||
predictability_score: Math.round(predictabilityScore * 10) / 10, // 1 decimal
|
||||
predictability_cv: Math.round(cv * 100) / 100, // 2 decimales
|
||||
predictability_score: Math.round(predictabilityScore * 10) / 10, // 1 decimal place
|
||||
predictability_cv: Math.round(cv * 100) / 100, // 2 decimal places
|
||||
complexity_inverse_score: Math.round(complexityInverseScore * 10) / 10,
|
||||
complexity_transfer_rate: Math.round(transferRate * 10) / 10,
|
||||
repetitivity_score: Math.round(repetitivityScore * 10) / 10,
|
||||
@@ -190,7 +190,7 @@ export function transformToDimensions(
|
||||
}
|
||||
|
||||
/**
|
||||
* Resultado final con Agentic Readiness Score
|
||||
* Final result with Agentic Readiness Score
|
||||
*/
|
||||
export interface SkillAgenticReadiness extends SkillDimensions {
|
||||
agentic_readiness_score: number; // 0-10
|
||||
@@ -199,28 +199,28 @@ export interface SkillAgenticReadiness extends SkillDimensions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Paso 4: Calcular Agentic Readiness Score
|
||||
* Promedio ponderado de las 3 dimensiones
|
||||
* Step 4: Calculate Agentic Readiness Score
|
||||
* Weighted average of the 3 dimensions
|
||||
*/
|
||||
export function calculateAgenticReadinessScore(
|
||||
dimensions: SkillDimensions[],
|
||||
weights?: { predictability: number; complexity: number; repetitivity: number }
|
||||
): SkillAgenticReadiness[] {
|
||||
// Pesos por defecto (ajustables)
|
||||
// Default weights (adjustable)
|
||||
const w = weights || {
|
||||
predictability: 0.40, // 40% - Más importante
|
||||
predictability: 0.40, // 40% - Most important
|
||||
complexity: 0.35, // 35%
|
||||
repetitivity: 0.25 // 25%
|
||||
};
|
||||
|
||||
return dimensions.map(dim => {
|
||||
// Promedio ponderado
|
||||
// Weighted average
|
||||
const score =
|
||||
dim.predictability_score * w.predictability +
|
||||
dim.complexity_inverse_score * w.complexity +
|
||||
dim.repetitivity_score * w.repetitivity;
|
||||
|
||||
// Categorizar
|
||||
// Categorize
|
||||
let category: 'automate_now' | 'assist_copilot' | 'optimize_first';
|
||||
let label: string;
|
||||
|
||||
@@ -245,29 +245,29 @@ export function calculateAgenticReadinessScore(
|
||||
}
|
||||
|
||||
/**
|
||||
* Pipeline completo: Raw Data → Agentic Readiness Score
|
||||
* Complete pipeline: Raw Data → Agentic Readiness Score
|
||||
*/
|
||||
export function transformRawDataToAgenticReadiness(
|
||||
rawInteractions: RawInteraction[],
|
||||
costPerHour: number,
|
||||
weights?: { predictability: number; complexity: number; repetitivity: number }
|
||||
): SkillAgenticReadiness[] {
|
||||
console.log(`🚀 Iniciando pipeline de transformación con ${rawInteractions.length} interacciones...`);
|
||||
console.log(`🚀 Starting transformation pipeline with ${rawInteractions.length} interactions...`);
|
||||
|
||||
// Paso 1: Limpieza de ruido
|
||||
// Step 1: Noise cleanup
|
||||
const cleanedData = cleanNoiseFromData(rawInteractions);
|
||||
|
||||
// Paso 2: Calcular métricas base
|
||||
// Step 2: Calculate base metrics
|
||||
const baseMetrics = calculateSkillBaseMetrics(cleanedData, costPerHour);
|
||||
|
||||
// Paso 3: Transformar a dimensiones
|
||||
// Step 3: Transform to dimensions
|
||||
const dimensions = transformToDimensions(baseMetrics);
|
||||
|
||||
// Paso 4: Calcular Agentic Readiness Score
|
||||
// Step 4: Calculate Agentic Readiness Score
|
||||
const agenticReadiness = calculateAgenticReadinessScore(dimensions, weights);
|
||||
|
||||
console.log(`✅ Pipeline completado: ${agenticReadiness.length} skills procesados`);
|
||||
console.log(`📈 Distribución:`);
|
||||
console.log(`✅ Pipeline completed: ${agenticReadiness.length} skills processed`);
|
||||
console.log(`📈 Distribution:`);
|
||||
const automateCount = agenticReadiness.filter(s => s.readiness_category === 'automate_now').length;
|
||||
const assistCount = agenticReadiness.filter(s => s.readiness_category === 'assist_copilot').length;
|
||||
const optimizeCount = agenticReadiness.filter(s => s.readiness_category === 'optimize_first').length;
|
||||
@@ -279,7 +279,7 @@ export function transformRawDataToAgenticReadiness(
|
||||
}
|
||||
|
||||
/**
|
||||
* Utilidad: Generar resumen de estadísticas
|
||||
* Utility: Generate statistics summary
|
||||
*/
|
||||
export function generateTransformationSummary(
|
||||
originalCount: number,
|
||||
@@ -294,17 +294,17 @@ export function generateTransformationSummary(
|
||||
const assistCount = agenticReadiness.filter(s => s.readiness_category === 'assist_copilot').length;
|
||||
const optimizeCount = agenticReadiness.filter(s => s.readiness_category === 'optimize_first').length;
|
||||
|
||||
// Validar que skillsCount no sea 0 para evitar división por cero
|
||||
// Validate that skillsCount is not 0 to avoid division by zero
|
||||
const automatePercent = skillsCount > 0 ? ((automateCount/skillsCount)*100).toFixed(0) : '0';
|
||||
const assistPercent = skillsCount > 0 ? ((assistCount/skillsCount)*100).toFixed(0) : '0';
|
||||
const optimizePercent = skillsCount > 0 ? ((optimizeCount/skillsCount)*100).toFixed(0) : '0';
|
||||
|
||||
return `
|
||||
📊 Resumen de Transformación:
|
||||
• Interacciones originales: ${originalCount.toLocaleString()}
|
||||
• Ruido eliminado: ${removedCount.toLocaleString()} (${removedPercentage}%)
|
||||
• Interacciones limpias: ${cleanedCount.toLocaleString()}
|
||||
• Skills únicos: ${skillsCount}
|
||||
📊 Transformation Summary:
|
||||
• Original interactions: ${originalCount.toLocaleString()}
|
||||
• Noise removed: ${removedCount.toLocaleString()} (${removedPercentage}%)
|
||||
• Clean interactions: ${cleanedCount.toLocaleString()}
|
||||
• Unique skills: ${skillsCount}
|
||||
|
||||
🎯 Agentic Readiness:
|
||||
• 🟢 Automate Now: ${automateCount} skills (${automatePercent}%)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
// utils/segmentClassifier.ts
|
||||
// Utilidad para clasificar colas/skills en segmentos de cliente
|
||||
// Utility to classify queues/skills into customer segments
|
||||
|
||||
import type { CustomerSegment, RawInteraction, StaticConfig } from '../types';
|
||||
|
||||
@@ -10,8 +10,8 @@ export interface SegmentMapping {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsea string de colas separadas por comas
|
||||
* Ejemplo: "VIP, Premium, Enterprise" → ["VIP", "Premium", "Enterprise"]
|
||||
* Parses queue string separated by commas
|
||||
* Example: "VIP, Premium, Enterprise" → ["VIP", "Premium", "Enterprise"]
|
||||
*/
|
||||
export function parseQueueList(input: string): string[] {
|
||||
if (!input || input.trim().length === 0) {
|
||||
@@ -25,13 +25,13 @@ export function parseQueueList(input: string): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clasifica una cola según el mapeo proporcionado
|
||||
* Usa matching parcial y case-insensitive
|
||||
* Classifies a queue according to the provided mapping
|
||||
* Uses partial and case-insensitive matching
|
||||
*
|
||||
* Ejemplo:
|
||||
* Example:
|
||||
* - queue: "VIP_Support" + mapping.high: ["VIP"] → "high"
|
||||
* - queue: "Soporte_General_N1" + mapping.medium: ["Soporte_General"] → "medium"
|
||||
* - queue: "Retencion" (no match) → "medium" (default)
|
||||
* - queue: "General_Support_L1" + mapping.medium: ["General_Support"] → "medium"
|
||||
* - queue: "Retention" (no match) → "medium" (default)
|
||||
*/
|
||||
export function classifyQueue(
|
||||
queue: string,
|
||||
@@ -39,7 +39,7 @@ export function classifyQueue(
|
||||
): CustomerSegment {
|
||||
const normalizedQueue = queue.toLowerCase().trim();
|
||||
|
||||
// Buscar en high value
|
||||
// Search in high value
|
||||
for (const highQueue of mapping.high_value_queues) {
|
||||
const normalizedHigh = highQueue.toLowerCase().trim();
|
||||
if (normalizedQueue.includes(normalizedHigh) || normalizedHigh.includes(normalizedQueue)) {
|
||||
@@ -47,7 +47,7 @@ export function classifyQueue(
|
||||
}
|
||||
}
|
||||
|
||||
// Buscar en low value
|
||||
// Search in low value
|
||||
for (const lowQueue of mapping.low_value_queues) {
|
||||
const normalizedLow = lowQueue.toLowerCase().trim();
|
||||
if (normalizedQueue.includes(normalizedLow) || normalizedLow.includes(normalizedQueue)) {
|
||||
@@ -55,7 +55,7 @@ export function classifyQueue(
|
||||
}
|
||||
}
|
||||
|
||||
// Buscar en medium value (explícito)
|
||||
// Search in medium value (explicit)
|
||||
for (const mediumQueue of mapping.medium_value_queues) {
|
||||
const normalizedMedium = mediumQueue.toLowerCase().trim();
|
||||
if (normalizedQueue.includes(normalizedMedium) || normalizedMedium.includes(normalizedQueue)) {
|
||||
@@ -63,13 +63,13 @@ export function classifyQueue(
|
||||
}
|
||||
}
|
||||
|
||||
// Default: medium (para colas no mapeadas)
|
||||
// Default: medium (for unmapped queues)
|
||||
return 'medium';
|
||||
}
|
||||
|
||||
/**
|
||||
* Clasifica todas las colas únicas de un conjunto de interacciones
|
||||
* Retorna un mapa de cola → segmento
|
||||
* Classifies all unique queues from a set of interactions
|
||||
* Returns a map of queue → segment
|
||||
*/
|
||||
export function classifyAllQueues(
|
||||
interactions: RawInteraction[],
|
||||
@@ -77,10 +77,10 @@ export function classifyAllQueues(
|
||||
): Map<string, CustomerSegment> {
|
||||
const queueSegments = new Map<string, CustomerSegment>();
|
||||
|
||||
// Obtener colas únicas
|
||||
// Get unique queues
|
||||
const uniqueQueues = [...new Set(interactions.map(i => i.queue_skill))];
|
||||
|
||||
// Clasificar cada cola
|
||||
// Classify each queue
|
||||
uniqueQueues.forEach(queue => {
|
||||
queueSegments.set(queue, classifyQueue(queue, mapping));
|
||||
});
|
||||
@@ -89,8 +89,8 @@ export function classifyAllQueues(
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera estadísticas de segmentación
|
||||
* Retorna conteo, porcentaje y lista de colas por segmento
|
||||
* Generates segmentation statistics
|
||||
* Returns count, percentage and list of queues by segment
|
||||
*/
|
||||
export function getSegmentationStats(
|
||||
interactions: RawInteraction[],
|
||||
@@ -108,13 +108,13 @@ export function getSegmentationStats(
|
||||
total: interactions.length
|
||||
};
|
||||
|
||||
// Contar interacciones por segmento
|
||||
// Count interactions by segment
|
||||
interactions.forEach(interaction => {
|
||||
const segment = queueSegments.get(interaction.queue_skill) || 'medium';
|
||||
stats[segment].count++;
|
||||
});
|
||||
|
||||
// Calcular porcentajes
|
||||
// Calculate percentages
|
||||
const total = interactions.length;
|
||||
if (total > 0) {
|
||||
stats.high.percentage = Math.round((stats.high.count / total) * 100);
|
||||
@@ -122,7 +122,7 @@ export function getSegmentationStats(
|
||||
stats.low.percentage = Math.round((stats.low.count / total) * 100);
|
||||
}
|
||||
|
||||
// Obtener colas por segmento (únicas)
|
||||
// Get queues by segment (unique)
|
||||
queueSegments.forEach((segment, queue) => {
|
||||
if (!stats[segment].queues.includes(queue)) {
|
||||
stats[segment].queues.push(queue);
|
||||
@@ -133,7 +133,7 @@ export function getSegmentationStats(
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida que el mapeo tenga al menos una cola en algún segmento
|
||||
* Validates that the mapping has at least one queue in some segment
|
||||
*/
|
||||
export function isValidMapping(mapping: SegmentMapping): boolean {
|
||||
return (
|
||||
@@ -144,8 +144,8 @@ export function isValidMapping(mapping: SegmentMapping): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea un mapeo desde StaticConfig
|
||||
* Si no hay segment_mapping, retorna mapeo vacío
|
||||
* Creates a mapping from StaticConfig
|
||||
* If there is no segment_mapping, returns empty mapping
|
||||
*/
|
||||
export function getMappingFromConfig(config: StaticConfig): SegmentMapping | null {
|
||||
if (!config.segment_mapping) {
|
||||
@@ -160,8 +160,8 @@ export function getMappingFromConfig(config: StaticConfig): SegmentMapping | nul
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el segmento para una cola específica desde el config
|
||||
* Si no hay mapeo, retorna 'medium' por defecto
|
||||
* Gets the segment for a specific queue from the config
|
||||
* If there is no mapping, returns 'medium' by default
|
||||
*/
|
||||
export function getSegmentForQueue(
|
||||
queue: string,
|
||||
@@ -177,7 +177,7 @@ export function getSegmentForQueue(
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatea estadísticas para mostrar en UI
|
||||
* Formats statistics for display in UI
|
||||
*/
|
||||
export function formatSegmentationSummary(
|
||||
stats: ReturnType<typeof getSegmentationStats>
|
||||
@@ -185,15 +185,15 @@ export function formatSegmentationSummary(
|
||||
const parts: string[] = [];
|
||||
|
||||
if (stats.high.count > 0) {
|
||||
parts.push(`${stats.high.percentage}% High Value (${stats.high.count} interacciones)`);
|
||||
parts.push(`${stats.high.percentage}% High Value (${stats.high.count} interactions)`);
|
||||
}
|
||||
|
||||
if (stats.medium.count > 0) {
|
||||
parts.push(`${stats.medium.percentage}% Medium Value (${stats.medium.count} interacciones)`);
|
||||
parts.push(`${stats.medium.percentage}% Medium Value (${stats.medium.count} interactions)`);
|
||||
}
|
||||
|
||||
if (stats.low.count > 0) {
|
||||
parts.push(`${stats.low.percentage}% Low Value (${stats.low.count} interacciones)`);
|
||||
parts.push(`${stats.low.percentage}% Low Value (${stats.low.count} interactions)`);
|
||||
}
|
||||
|
||||
return parts.join(' | ');
|
||||
|
||||
Reference in New Issue
Block a user