Imporve the AI models page
This commit is contained in:
404
frontend/src/components/domain/forecasting/ModelDetailsModal.tsx
Normal file
404
frontend/src/components/domain/forecasting/ModelDetailsModal.tsx
Normal file
@@ -0,0 +1,404 @@
|
||||
import React from 'react';
|
||||
import { StatusModal, StatusModalSection } from '@/components/ui/StatusModal/StatusModal';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Tooltip } from '@/components/ui/Tooltip';
|
||||
import { TrainedModelResponse, TrainingMetrics } from '@/types/training';
|
||||
import { Activity, TrendingUp, Calendar, Settings, Lightbulb, AlertTriangle, CheckCircle, Target } from 'lucide-react';
|
||||
|
||||
interface ModelDetailsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
model: TrainedModelResponse;
|
||||
}
|
||||
|
||||
// Helper function to determine performance color based on accuracy
|
||||
const getPerformanceColor = (accuracy: number): string => {
|
||||
if (accuracy >= 90) return 'var(--color-success)';
|
||||
if (accuracy >= 80) return 'var(--color-info)';
|
||||
if (accuracy >= 70) return 'var(--color-warning)';
|
||||
if (accuracy >= 60) return 'var(--color-warning-dark)';
|
||||
return 'var(--color-error)';
|
||||
};
|
||||
|
||||
|
||||
// Helper function to determine performance message for bakery owners
|
||||
const getPerformanceMessage = (accuracy: number): string => {
|
||||
if (accuracy >= 90) return 'Excelente: Las predicciones son muy precisas, ideales para planificar producción';
|
||||
if (accuracy >= 80) return 'Bueno: Las predicciones son confiables para planificación diaria';
|
||||
if (accuracy >= 70) return 'Aceptable: Predicciones útiles pero considera monitorear de cerca';
|
||||
if (accuracy >= 60) return 'Necesita mejora: Predicciones menos precisas, considera reentrenar';
|
||||
return 'Poco confiable: El modelo necesita ser reentrenado con más datos';
|
||||
};
|
||||
|
||||
// Helper function to format date
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to calculate accuracy from MAPE
|
||||
const calculateAccuracy = (mape: number | undefined): number => {
|
||||
if (mape === undefined || mape === null || isNaN(mape)) return 0;
|
||||
// MAPE in the API response is already in percentage form (e.g., 8.5 = 8.5%)
|
||||
const accuracy = Math.max(0, Math.min(100, 100 - mape));
|
||||
return accuracy;
|
||||
};
|
||||
|
||||
// Feature tag component
|
||||
const FeatureTag: React.FC<{ feature: string }> = ({ feature }) => {
|
||||
const getIcon = (feature: string) => {
|
||||
const featureLower = feature.toLowerCase();
|
||||
if (featureLower.includes('temperature')) return '☀️';
|
||||
if (featureLower.includes('weekend')) return '📅';
|
||||
if (featureLower.includes('holiday')) return '🎉';
|
||||
if (featureLower.includes('precipitation')) return '🌧️';
|
||||
if (featureLower.includes('traffic')) return '🚗';
|
||||
return '📊';
|
||||
};
|
||||
|
||||
const getTooltip = (feature: string) => {
|
||||
const featureLower = feature.toLowerCase();
|
||||
if (featureLower.includes('temperature')) return 'El clima afecta lo que los clientes quieren comprar - días calurosos significan menos productos horneados calientes';
|
||||
if (featureLower.includes('weekend')) return 'Tus patrones de ventas son diferentes los fines de semana vs días de semana';
|
||||
if (featureLower.includes('holiday')) return 'Días especiales y festivos cambian cuánto compra la gente';
|
||||
if (featureLower.includes('precipitation')) return 'La lluvia o nieve afecta cuántos clientes visitan tu panadería';
|
||||
if (featureLower.includes('traffic')) return 'Calles transitadas usualmente significan más clientes';
|
||||
return `${feature}: Esto ayuda a predecir cuánto venderás`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip content={getTooltip(feature)}>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1 text-xs md:text-sm px-2 py-1"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Característica: ${feature}. ${getTooltip(feature)}`}
|
||||
>
|
||||
<span className="text-xs" aria-hidden="true">{getIcon(feature)}</span>
|
||||
<span className="truncate max-w-[120px]">{feature}</span>
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const ModelDetailsModal: React.FC<ModelDetailsModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
model
|
||||
}) => {
|
||||
// Early return if model is not provided
|
||||
if (!model) {
|
||||
return (
|
||||
<StatusModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode="view"
|
||||
title="Model Details"
|
||||
sections={[]}
|
||||
actions={[]}
|
||||
size="xl"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate business metrics from technical metrics
|
||||
// The API response has metrics directly on the model object, not nested in training_metrics
|
||||
const accuracy = calculateAccuracy((model as any).mape || model.training_metrics?.mape);
|
||||
const performanceColor = getPerformanceColor(accuracy);
|
||||
const performanceMessage = getPerformanceMessage(accuracy);
|
||||
|
||||
// Prepare sections for StatusModal
|
||||
const sections: StatusModalSection[] = [
|
||||
{
|
||||
title: "Resumen",
|
||||
icon: Activity,
|
||||
fields: [
|
||||
{
|
||||
label: "Rendimiento del Modelo",
|
||||
value: (
|
||||
<div className="w-full">
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="font-medium">Precisión</span>
|
||||
<span className="font-bold" style={{ color: performanceColor }} aria-label={`Precisión del modelo: ${accuracy.toFixed(0)} porciento`}>
|
||||
{accuracy.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-[var(--bg-secondary)] rounded-full h-2.5" role="progressbar" aria-valuenow={accuracy} aria-valuemin={0} aria-valuemax={100} aria-label="Progreso de rendimiento del modelo">
|
||||
<div
|
||||
className="h-2.5 rounded-full transition-all duration-300 ease-out"
|
||||
style={{
|
||||
width: `${accuracy}%`,
|
||||
backgroundColor: performanceColor
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)] mt-2">
|
||||
{performanceMessage}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
span: 2
|
||||
},
|
||||
{
|
||||
label: "Estado",
|
||||
value: (
|
||||
<span
|
||||
className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium"
|
||||
style={{
|
||||
backgroundColor: (model as any).is_active
|
||||
? 'color-mix(in srgb, var(--color-success) 20%, transparent)'
|
||||
: 'color-mix(in srgb, var(--color-error) 20%, transparent)',
|
||||
color: (model as any).is_active ? 'var(--color-success)' : 'var(--color-error)'
|
||||
}}
|
||||
aria-label={`Estado del modelo: ${(model as any).is_active ? 'Activo' : 'Inactivo'}`}
|
||||
>
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full mr-2"
|
||||
style={{
|
||||
backgroundColor: (model as any).is_active ? 'var(--color-success)' : 'var(--color-error)'
|
||||
}}
|
||||
></span>
|
||||
{(model as any).is_active ? 'Activo' : 'Inactivo'}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
label: "Última Actualización",
|
||||
value: formatDate(model.created_at || new Date().toISOString())
|
||||
},
|
||||
{
|
||||
label: "Período de Entrenamiento",
|
||||
value: `${formatDate((model as any).training_start_date || model.training_period?.start_date || new Date().toISOString())} - ${formatDate((model as any).training_end_date || model.training_period?.end_date || new Date().toISOString())}`
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Métricas de Rendimiento",
|
||||
icon: TrendingUp,
|
||||
fields: [
|
||||
{
|
||||
label: "Tasa de Precisión",
|
||||
value: `${accuracy.toFixed(0)}%`,
|
||||
highlight: true
|
||||
},
|
||||
{
|
||||
label: "Diferencia Típica",
|
||||
value: (() => {
|
||||
const maeValue = ((model as any).mae || model.training_metrics?.mae || 0).toFixed(0);
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">±{maeValue} productos</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
En promedio, las predicciones se desvían por {maeValue} productos
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
},
|
||||
{
|
||||
label: "Confiabilidad General",
|
||||
value: `${(((model as any).r2_score || model.training_metrics?.r2_score || 0) * 100).toFixed(0)}% confiable`
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Qué Significa Esto para Tu Panadería",
|
||||
icon: Target,
|
||||
fields: [
|
||||
{
|
||||
label: "Precisión de las Predicciones",
|
||||
value: (() => {
|
||||
const mapeValue = Math.round((model as any).mape || model.training_metrics?.mape || 0);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium text-[var(--text-primary)]">
|
||||
Error promedio: {mapeValue}%
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)] leading-relaxed">
|
||||
<strong>Ejemplo práctico:</strong> Si el modelo predice que venderás 100 panes,
|
||||
es muy probable que vendas entre {100 - mapeValue} y {100 + mapeValue} panes.
|
||||
Mientras menor sea este porcentaje, más precisas son las predicciones.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})(),
|
||||
span: 2
|
||||
},
|
||||
{
|
||||
label: "Potencial de Reducción de Desperdicio",
|
||||
value: accuracy >= 80
|
||||
? `Podría ayudar a reducir el desperdicio diario en ~${Math.round(accuracy / 8)}% mediante mejor planificación`
|
||||
: accuracy >= 60
|
||||
? 'Alguna reducción de desperdicio posible con monitoreo cuidadoso'
|
||||
: 'Precisión del modelo demasiado baja para reducción confiable de desperdicio',
|
||||
highlight: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Factores que Considera el Modelo",
|
||||
icon: Settings,
|
||||
fields: [
|
||||
{
|
||||
label: "Información que Analiza",
|
||||
value: (() => {
|
||||
const features = ((model as any).features_used || model.features_used || []);
|
||||
const featureCount = features.length;
|
||||
|
||||
if (featureCount === 0) {
|
||||
return (
|
||||
<div className="text-sm text-[var(--text-secondary)] italic">
|
||||
Usando datos básicos de ventas e historial
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
El modelo analiza <strong>{featureCount} factores diferentes</strong> para hacer predicciones más precisas.
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium text-[var(--text-primary)]">📅 Temporales:</div>
|
||||
<div className="text-[var(--text-secondary)]">Días de semana, festivos, estaciones</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium text-[var(--text-primary)]">🌤️ Clima:</div>
|
||||
<div className="text-[var(--text-secondary)]">Temperatura, lluvia, humedad</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium text-[var(--text-primary)]">🚶 Tráfico:</div>
|
||||
<div className="text-[var(--text-secondary)]">Peatones, congestión, velocidad</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium text-[var(--text-primary)]">📊 Ventas:</div>
|
||||
<div className="text-[var(--text-secondary)]">Historial, patrones, tendencias</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-[var(--text-secondary)] bg-[var(--bg-secondary)] rounded-md p-3 border-l-4 border-[var(--color-info)]">
|
||||
<strong>💡 En resumen:</strong> Cuantos más factores analiza el modelo, más precisas son las predicciones para tu panadería.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})(),
|
||||
span: 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Detalles Técnicos",
|
||||
icon: Calendar,
|
||||
fields: [
|
||||
{
|
||||
label: "Método de Predicción",
|
||||
value: model.model_type || 'Pronóstico con IA'
|
||||
},
|
||||
{
|
||||
label: "Última Actualización",
|
||||
value: formatDate(model.created_at || new Date().toISOString())
|
||||
},
|
||||
{
|
||||
label: "Período de Entrenamiento",
|
||||
value: `${formatDate((model as any).training_start_date || model.training_period?.start_date || new Date().toISOString())} a ${formatDate((model as any).training_end_date || model.training_period?.end_date || new Date().toISOString())}`,
|
||||
span: 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Perspectivas de Negocio",
|
||||
icon: Lightbulb,
|
||||
fields: [
|
||||
{
|
||||
label: "Qué funciona mejor",
|
||||
value: (
|
||||
<div className="flex items-start">
|
||||
<CheckCircle className="text-[var(--color-success)] mr-2 mt-0.5 flex-shrink-0" size={16} />
|
||||
<span>Este modelo te da las predicciones más precisas para días de semana regulares</span>
|
||||
</div>
|
||||
),
|
||||
span: 2
|
||||
},
|
||||
{
|
||||
label: "Ten cuidado con",
|
||||
value: (
|
||||
<div className="flex items-start">
|
||||
<AlertTriangle className="text-[var(--color-warning)] mr-2 mt-0.5 flex-shrink-0" size={16} />
|
||||
<span>Las predicciones pueden ser menos confiables durante festivos y eventos especiales</span>
|
||||
</div>
|
||||
),
|
||||
span: 2
|
||||
},
|
||||
{
|
||||
label: "Patrones descubiertos",
|
||||
value: ((model as any).features_used || model.features_used || []).some((f: string) => f.toLowerCase().includes('weekend'))
|
||||
? "Tu negocio muestra patrones diferentes entre días de semana y fines de semana"
|
||||
: "Este modelo ha aprendido tus patrones regulares de ventas",
|
||||
span: 2
|
||||
},
|
||||
{
|
||||
label: "Próximos pasos",
|
||||
value: accuracy < 80
|
||||
? "Considera reentrenar con datos más recientes para mejorar la precisión"
|
||||
: "Tu modelo está funcionando bien. Continúa monitoreando las predicciones",
|
||||
span: 2
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// Actions for the modal - removed console.log placeholders for production readiness
|
||||
const actions = [
|
||||
{
|
||||
label: 'Actualizar Modelo',
|
||||
variant: 'primary' as const,
|
||||
onClick: () => {
|
||||
// TODO: Implement model retraining functionality
|
||||
// This should trigger a new training job for the product
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Ver Predicciones',
|
||||
variant: 'secondary' as const,
|
||||
onClick: () => {
|
||||
// TODO: Navigate to forecast history or predictions view
|
||||
// This should show historical predictions vs actual sales
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Only show deactivate if model is active
|
||||
if ((model as any).is_active) {
|
||||
actions.push({
|
||||
label: 'Desactivar',
|
||||
variant: 'secondary' as const,
|
||||
onClick: () => {
|
||||
// TODO: Implement model deactivation
|
||||
// This should set model status to inactive
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode="view"
|
||||
title="Detalles de Predicción de Ventas"
|
||||
subtitle={`Para ${model.model_type || 'tu producto'} • Actualizado ${model.created_at ? formatDate(model.created_at) : 'recientemente'}`}
|
||||
sections={sections}
|
||||
actions={actions}
|
||||
size="xl"
|
||||
actionsPosition="footer"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelDetailsModal;
|
||||
Reference in New Issue
Block a user