Add AI insights feature

This commit is contained in:
Urtzi Alfaro
2025-12-15 21:14:22 +01:00
parent 5642b5a0c0
commit c566967bea
39 changed files with 17729 additions and 404 deletions

View File

@@ -15,6 +15,7 @@
*/
import { useQuery, useMutation, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
import { useState } from 'react';
import {
aiInsightsService,
AIInsight,
@@ -213,13 +214,13 @@ export function useApplyInsight(
* Mutation hook to dismiss an insight
*/
export function useDismissInsight(
options?: UseMutationOptions<AIInsight, Error, { tenantId: string; insightId: string; reason?: string }>
options?: UseMutationOptions<void, Error, { tenantId: string; insightId: string }>
) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ tenantId, insightId, reason }) =>
aiInsightsService.dismissInsight(tenantId, insightId, reason),
mutationFn: ({ tenantId, insightId }) =>
aiInsightsService.dismissInsight(tenantId, insightId),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: aiInsightsKeys.lists() });
queryClient.invalidateQueries({ queryKey: aiInsightsKeys.detail(variables.tenantId, variables.insightId) });
@@ -231,16 +232,16 @@ export function useDismissInsight(
}
/**
* Mutation hook to resolve an insight
* Mutation hook to update insight status
*/
export function useResolveInsight(
options?: UseMutationOptions<AIInsight, Error, { tenantId: string; insightId: string; resolution?: string }>
export function useUpdateInsightStatus(
options?: UseMutationOptions<AIInsight, Error, { tenantId: string; insightId: string; status: 'acknowledged' | 'in_progress' | 'applied' | 'expired' }>
) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ tenantId, insightId, resolution }) =>
aiInsightsService.resolveInsight(tenantId, insightId, resolution),
mutationFn: ({ tenantId, insightId, status }) =>
aiInsightsService.updateInsightStatus(tenantId, insightId, status),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: aiInsightsKeys.lists() });
queryClient.invalidateQueries({ queryKey: aiInsightsKeys.detail(variables.tenantId, variables.insightId) });
@@ -300,6 +301,3 @@ export function useInsightSelection() {
isSelected: (insightId: string) => selectedInsights.includes(insightId),
};
}
// Import useState for utility hook
import { useState } from 'react';

View File

@@ -13,10 +13,41 @@ import { productionService } from '../services/production';
import { ProcurementService } from '../services/procurement-service';
import * as orchestratorService from '../services/orchestrator';
import { suppliersService } from '../services/suppliers';
import { aiInsightsService } from '../services/aiInsights';
import { useBatchNotifications, useDeliveryNotifications, useOrchestrationNotifications } from '../../hooks/useEventNotifications';
import { useSSEEvents } from '../../hooks/useSSE';
import { parseISO } from 'date-fns';
// ============================================================
// Helper Functions
// ============================================================
/**
* Map AI insight category to dashboard block type
*/
function mapInsightTypeToBlockType(category: string): string {
const mapping: Record<string, string> = {
'inventory': 'safety_stock',
'forecasting': 'demand_forecast',
'demand': 'demand_forecast',
'procurement': 'cost_optimization',
'cost': 'cost_optimization',
'production': 'waste_reduction',
'quality': 'risk_alert',
'efficiency': 'waste_reduction',
};
return mapping[category] || 'demand_forecast';
}
/**
* Map AI insight priority to dashboard impact level
*/
function mapPriorityToImpact(priority: string): 'high' | 'medium' | 'low' {
if (priority === 'critical' || priority === 'high') return 'high';
if (priority === 'medium') return 'medium';
return 'low';
}
// ============================================================
// Types
// ============================================================
@@ -75,20 +106,26 @@ export function useDashboardData(tenantId: string) {
const now = new Date(); // Keep for local time display
const nowUTC = new Date(); // UTC time for accurate comparison with API dates
// Parallel fetch ALL data needed by all 4 blocks (including suppliers for PO enrichment)
const [alertsResponse, pendingPOs, productionResponse, deliveriesResponse, orchestration, suppliers] = await Promise.all([
// Parallel fetch ALL data needed by all 4 blocks (including suppliers for PO enrichment and AI insights)
const [alertsResponse, pendingPOs, productionResponse, deliveriesResponse, orchestration, suppliers, aiInsightsResponse] = await Promise.all([
alertService.getEvents(tenantId, { status: 'active', limit: 100 }).catch(() => []),
getPendingApprovalPurchaseOrders(tenantId, 100).catch(() => []),
productionService.getBatches(tenantId, { start_date: today, page_size: 100 }).catch(() => ({ batches: [] })),
ProcurementService.getExpectedDeliveries(tenantId, { days_ahead: 1, include_overdue: true }).catch(() => ({ deliveries: [] })),
orchestratorService.getLastOrchestrationRun(tenantId).catch(() => null),
suppliersService.getSuppliers(tenantId).catch(() => []),
aiInsightsService.getInsights(tenantId, {
status: 'new',
priority: 'high',
limit: 5
}).catch(() => ({ items: [], total: 0, limit: 5, offset: 0, has_more: false })),
]);
// Normalize alerts (API returns array directly or {items: []})
const alerts = Array.isArray(alertsResponse) ? alertsResponse : (alertsResponse?.items || []);
const productionBatches = productionResponse?.batches || [];
const deliveries = deliveriesResponse?.deliveries || [];
const aiInsights = aiInsightsResponse?.items || [];
// Create supplier ID -> supplier name map for quick lookup
const supplierMap = new Map<string, string>();
@@ -246,6 +283,19 @@ export function useDashboardData(tenantId: string) {
};
}
// Map AI insights to dashboard format
const mappedAiInsights = aiInsights.map((insight: any) => ({
id: insight.id,
title: insight.title,
description: insight.description,
type: mapInsightTypeToBlockType(insight.category),
impact: mapPriorityToImpact(insight.priority),
impact_value: insight.impact_value?.toString(),
impact_currency: insight.impact_unit === 'euros' ? '€' : '',
created_at: insight.created_at,
recommendation_actions: insight.recommendation_actions || [],
}));
return {
// Raw data
alerts: deduplicatedAlerts,
@@ -253,7 +303,7 @@ export function useDashboardData(tenantId: string) {
productionBatches,
deliveries,
orchestrationSummary,
aiInsights: [], // AI-generated insights for professional/enterprise tiers
aiInsights: mappedAiInsights,
// Computed
preventedIssues,
@@ -295,6 +345,7 @@ export function useDashboardRealtimeSync(tenantId: string) {
const { notifications: deliveryNotifications } = useDeliveryNotifications();
const { recentNotifications: orchestrationNotifications } = useOrchestrationNotifications();
const { events: alertEvents } = useSSEEvents({ channels: ['*.alerts'] });
const { events: aiInsightEvents } = useSSEEvents({ channels: ['*.ai_insights'] });
// Invalidate dashboard data on batch events
useEffect(() => {
@@ -345,4 +396,15 @@ export function useDashboardRealtimeSync(tenantId: string) {
refetchType: 'active',
});
}, [alertEvents, tenantId, queryClient]);
// Invalidate dashboard data on AI insight events
useEffect(() => {
if (!aiInsightEvents || aiInsightEvents.length === 0 || !tenantId) return;
// Any new AI insight should trigger a refresh
queryClient.invalidateQueries({
queryKey: ['dashboard-data', tenantId],
refetchType: 'active',
});
}, [aiInsightEvents, tenantId, queryClient]);
}

View File

@@ -20,17 +20,12 @@ import { apiClient } from '../client';
export interface AIInsight {
id: string;
tenant_id: string;
type: 'forecast' | 'warning' | 'opportunity' | 'positive' | 'optimization' | 'rule';
priority: 'urgent' | 'high' | 'medium' | 'low';
category: 'demand' | 'procurement' | 'inventory' | 'production' | 'sales' | 'system' | 'business';
type: 'optimization' | 'alert' | 'prediction' | 'recommendation' | 'insight' | 'anomaly';
priority: 'low' | 'medium' | 'high' | 'critical';
category: 'forecasting' | 'inventory' | 'production' | 'procurement' | 'customer' | 'cost' | 'quality' | 'efficiency' | 'demand' | 'maintenance' | 'energy' | 'scheduling';
title: string;
description: string;
reasoning_data?: {
type: string;
parameters: Record<string, any>;
[key: string]: any;
};
impact_type: 'cost_savings' | 'waste_reduction' | 'yield_improvement' | 'revenue' | 'system_health' | 'process_improvement';
impact_type?: 'cost_savings' | 'revenue_increase' | 'waste_reduction' | 'efficiency_gain' | 'quality_improvement' | 'risk_mitigation';
impact_value?: number;
impact_unit?: string;
confidence: number;
@@ -39,31 +34,27 @@ export interface AIInsight {
recommendation_actions?: Array<{
label: string;
action: string;
params: Record<string, any>;
endpoint?: string;
}>;
source_service: string;
source_model: string;
detected_at: string;
resolved_at?: string;
resolved_by?: string;
status: 'active' | 'applied' | 'dismissed' | 'resolved';
feedback_count?: number;
avg_feedback_rating?: number;
source_service?: string;
source_data_id?: string;
status: 'new' | 'acknowledged' | 'in_progress' | 'applied' | 'dismissed' | 'expired';
created_at: string;
updated_at: string;
applied_at?: string;
expired_at?: string;
}
export interface AIInsightFilters {
type?: string;
priority?: string;
category?: string;
source_model?: string;
status?: string;
min_confidence?: number;
type?: 'optimization' | 'alert' | 'prediction' | 'recommendation' | 'insight' | 'anomaly';
priority?: 'low' | 'medium' | 'high' | 'critical';
status?: 'new' | 'acknowledged' | 'in_progress' | 'applied' | 'dismissed' | 'expired';
category?: 'forecasting' | 'inventory' | 'production' | 'procurement' | 'customer' | 'cost' | 'quality' | 'efficiency' | 'demand' | 'maintenance' | 'energy' | 'scheduling';
actionable_only?: boolean;
start_date?: string;
end_date?: string;
search?: string;
min_confidence?: number;
source_service?: string;
from_date?: string;
to_date?: string;
limit?: number;
offset?: number;
}
@@ -78,14 +69,15 @@ export interface AIInsightListResponse {
export interface AIInsightStatsResponse {
total_insights: number;
insights_by_type: Record<string, number>;
insights_by_priority: Record<string, number>;
insights_by_category: Record<string, number>;
insights_by_status: Record<string, number>;
avg_confidence: number;
total_impact_value: number;
actionable_insights: number;
resolved_insights: number;
average_confidence: number;
high_priority_count: number;
medium_priority_count: number;
low_priority_count: number;
critical_priority_count: number;
by_category: Record<string, number>;
by_status: Record<string, number>;
total_potential_impact?: number;
}
export interface FeedbackRequest {
@@ -139,13 +131,12 @@ export class AIInsightsService {
if (filters?.type) queryParams.append('type', filters.type);
if (filters?.priority) queryParams.append('priority', filters.priority);
if (filters?.category) queryParams.append('category', filters.category);
if (filters?.source_model) queryParams.append('source_model', filters.source_model);
if (filters?.status) queryParams.append('status', filters.status);
if (filters?.min_confidence) queryParams.append('min_confidence', filters.min_confidence.toString());
if (filters?.actionable_only) queryParams.append('actionable_only', 'true');
if (filters?.start_date) queryParams.append('start_date', filters.start_date);
if (filters?.end_date) queryParams.append('end_date', filters.end_date);
if (filters?.search) queryParams.append('search', filters.search);
if (filters?.source_service) queryParams.append('source_service', filters.source_service);
if (filters?.from_date) queryParams.append('from_date', filters.from_date);
if (filters?.to_date) queryParams.append('to_date', filters.to_date);
if (filters?.limit) queryParams.append('limit', filters.limit.toString());
if (filters?.offset) queryParams.append('offset', filters.offset.toString());
@@ -235,23 +226,22 @@ export class AIInsightsService {
*/
async dismissInsight(
tenantId: string,
insightId: string,
reason?: string
): Promise<AIInsight> {
const url = `${this.baseUrl}/${tenantId}/insights/${insightId}/dismiss`;
return apiClient.post<AIInsight>(url, { reason });
insightId: string
): Promise<void> {
const url = `${this.baseUrl}/${tenantId}/insights/${insightId}`;
return apiClient.delete(url);
}
/**
* Resolve an insight
* Update an insight status (acknowledge, apply, etc.)
*/
async resolveInsight(
async updateInsightStatus(
tenantId: string,
insightId: string,
resolution?: string
status: 'acknowledged' | 'in_progress' | 'applied' | 'expired'
): Promise<AIInsight> {
const url = `${this.baseUrl}/${tenantId}/insights/${insightId}/resolve`;
return apiClient.post<AIInsight>(url, { resolution });
const url = `${this.baseUrl}/${tenantId}/insights/${insightId}`;
return apiClient.patch<AIInsight>(url, { status });
}
/**
@@ -261,17 +251,18 @@ export class AIInsightsService {
tenantId: string,
limit: number = 10
): Promise<AIInsight[]> {
// Fetch critical priority insights first
const response = await this.getInsights(tenantId, {
priority: 'urgent',
status: 'active',
priority: 'critical',
status: 'new',
limit,
});
if (response.items.length < limit) {
// Add high priority if not enough urgent
// Add high priority if not enough critical
const highPriorityResponse = await this.getInsights(tenantId, {
priority: 'high',
status: 'active',
status: 'new',
limit: limit - response.items.length,
});
return [...response.items, ...highPriorityResponse.items];
@@ -289,7 +280,7 @@ export class AIInsightsService {
): Promise<AIInsight[]> {
const response = await this.getInsights(tenantId, {
actionable_only: true,
status: 'active',
status: 'new',
limit,
});
@@ -305,8 +296,8 @@ export class AIInsightsService {
limit: number = 20
): Promise<AIInsight[]> {
const response = await this.getInsights(tenantId, {
category,
status: 'active',
category: category as any, // Category comes from user input
status: 'new',
limit,
});
@@ -321,13 +312,20 @@ export class AIInsightsService {
query: string,
filters?: Partial<AIInsightFilters>
): Promise<AIInsight[]> {
// Note: search parameter not supported by backend API
// This is a client-side workaround - fetch all and filter
const response = await this.getInsights(tenantId, {
...filters,
search: query,
limit: filters?.limit || 50,
});
return response.items;
// Filter by query on client side
const lowerQuery = query.toLowerCase();
return response.items.filter(
(insight) =>
insight.title.toLowerCase().includes(lowerQuery) ||
insight.description.toLowerCase().includes(lowerQuery)
);
}
/**

View File

@@ -18,6 +18,10 @@ interface AIInsight {
impact_value?: string;
impact_currency?: string;
created_at: string;
recommendation_actions?: Array<{
label: string;
action: string;
}>;
}
interface AIInsightsBlockProps {
@@ -143,17 +147,44 @@ export function AIInsightsBlock({ insights = [], loading = false, onViewAll }: A
{insight.description}
</p>
{/* Recommendations */}
{insight.recommendation_actions && insight.recommendation_actions.length > 0 && (
<div className="mb-2 p-2 bg-[var(--color-primary-50)] border border-[var(--color-primary-100)] rounded">
<div className="flex items-start gap-1.5">
<Lightbulb className="w-4 h-4 text-[var(--color-primary-600)] flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-[var(--color-primary-700)] mb-1">
{t('dashboard:ai_insights.recommendations', 'Recomendaciones')}:
</p>
<ul className="space-y-1">
{insight.recommendation_actions.slice(0, 2).map((action, idx) => (
<li key={idx} className="text-xs text-[var(--color-primary-700)] flex items-start gap-1">
<span className="flex-shrink-0"></span>
<span className="flex-1">{action.label || action.action}</span>
</li>
))}
</ul>
</div>
</div>
</div>
)}
{/* Impact Value */}
{insight.impact_value && (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 mt-2">
{insight.type === 'cost_optimization' && (
<span className="text-sm font-semibold text-[var(--color-success-600)]">
{insight.impact_currency}{insight.impact_value} {t('dashboard:ai_insights.savings')}
💰 {insight.impact_currency}{insight.impact_value} {t('dashboard:ai_insights.savings')}
</span>
)}
{insight.type === 'waste_reduction' && (
<span className="text-sm font-semibold text-[var(--color-success-600)]">
{insight.impact_value} {t('dashboard:ai_insights.reduction')}
{insight.impact_value} {t('dashboard:ai_insights.reduction')}
</span>
)}
{!['cost_optimization', 'waste_reduction'].includes(insight.type) && (
<span className="text-sm font-semibold text-[var(--color-success-600)]">
💰 {insight.impact_currency}{insight.impact_value}
</span>
)}
</div>

View File

@@ -16,10 +16,11 @@ const AIInsightsPage: React.FC = () => {
const { t } = useTranslation('reasoning');
// Fetch real insights from API
// Note: Backend expects status values: 'new', 'acknowledged', 'in_progress', 'applied', 'dismissed', 'expired'
// We fetch 'new' and 'acknowledged' insights (not dismissed, applied, or expired)
const { data: insightsData, isLoading, refetch } = useAIInsights(
tenantId || '',
{
status: 'active',
category: selectedCategory === 'all' ? undefined : selectedCategory,
limit: 100,
},
@@ -44,20 +45,20 @@ const AIInsightsPage: React.FC = () => {
const categories = [
{ value: 'all', label: 'Todas las Categorías', count: stats?.total_insights || 0 },
{ value: 'production', label: 'Producción', count: stats?.insights_by_category?.production || 0 },
{ value: 'sales', label: 'Ventas', count: stats?.insights_by_category?.sales || 0 },
{ value: 'demand', label: 'Pronósticos', count: stats?.insights_by_category?.demand || 0 },
{ value: 'inventory', label: 'Inventario', count: stats?.insights_by_category?.inventory || 0 },
{ value: 'procurement', label: 'Compras', count: stats?.insights_by_category?.procurement || 0 },
{ value: 'production', label: 'Producción', count: stats?.by_category?.production || 0 },
{ value: 'sales', label: 'Ventas', count: stats?.by_category?.sales || 0 },
{ value: 'demand', label: 'Pronósticos', count: stats?.by_category?.demand || stats?.by_category?.forecasting || 0 },
{ value: 'inventory', label: 'Inventario', count: stats?.by_category?.inventory || 0 },
{ value: 'procurement', label: 'Compras', count: stats?.by_category?.procurement || 0 },
];
const aiMetrics = {
totalInsights: stats?.total_insights || 0,
actionableInsights: stats?.actionable_insights || 0,
averageConfidence: stats?.avg_confidence ? Math.round(stats.avg_confidence) : 0,
highPriorityInsights: stats?.insights_by_priority?.high || stats?.insights_by_priority?.urgent || 0,
mediumPriorityInsights: stats?.insights_by_priority?.medium || 0,
lowPriorityInsights: stats?.insights_by_priority?.low || 0,
averageConfidence: stats?.average_confidence ? Math.round(stats.average_confidence) : 0,
highPriorityInsights: (stats?.high_priority_count || 0) + (stats?.critical_priority_count || 0),
mediumPriorityInsights: stats?.medium_priority_count || 0,
lowPriorityInsights: stats?.low_priority_count || 0,
};
const getTypeIcon = (type: string) => {
@@ -258,22 +259,73 @@ const AIInsightsPage: React.FC = () => {
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">{insight.title}</h3>
<p className="text-[var(--text-secondary)] mb-3">{getInsightDescription(insight)}</p>
<p className="text-sm font-medium text-[var(--color-success)] mb-4">{insight.impact}</p>
{/* Metrics */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
{Object.entries(insight.metrics).map(([key, value]) => (
<div key={key} className="bg-[var(--bg-secondary)] p-3 rounded-lg">
<p className="text-xs text-[var(--text-tertiary)] uppercase tracking-wider">
{key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase())}
</p>
<p className="text-sm font-semibold text-[var(--text-primary)]">{value}</p>
{/* Impact */}
{insight.impact_value && insight.impact_type && (
<div className="mb-4 p-3 bg-[var(--color-success)]/10 border border-[var(--color-success)]/20 rounded-lg">
<p className="text-sm font-medium text-[var(--color-success)]">
💰 Impacto: {insight.impact_type.replace(/_/g, ' ')} - {insight.impact_value} {insight.impact_unit || ''}
</p>
</div>
)}
{/* Recommendation */}
{insight.recommendation_actions && insight.recommendation_actions.length > 0 && (
<div className="mb-4 p-4 bg-[var(--color-primary)]/5 border border-[var(--color-primary)]/20 rounded-lg">
<div className="flex items-start gap-2 mb-2">
<Lightbulb className="w-5 h-5 text-[var(--color-primary)] flex-shrink-0 mt-0.5" />
<h4 className="text-sm font-semibold text-[var(--text-primary)]">Recomendaciones</h4>
</div>
))}
</div>
<ul className="space-y-2 ml-7">
{insight.recommendation_actions.map((action, idx) => (
<li key={idx} className="text-sm text-[var(--text-secondary)] flex items-start gap-2">
<span className="text-[var(--color-primary)] flex-shrink-0"></span>
<span>{action.label || action.action}</span>
</li>
))}
</ul>
</div>
)}
{/* Metrics - Only show non-redundant metrics */}
{insight.metrics_json && Object.keys(insight.metrics_json).length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
{Object.entries(insight.metrics_json)
.filter(([key]) => !['pattern', 'recommendation'].includes(key)) // Filter out already displayed data
.map(([key, value]) => {
// Format the value for display
let displayValue: string;
if (typeof value === 'object' && value !== null) {
// For objects, try to display them nicely
if (Object.keys(value).length < 5) {
displayValue = Object.entries(value)
.map(([k, v]) => `${k}: ${v}`)
.join(', ');
} else {
displayValue = JSON.stringify(value, null, 2);
}
} else if (typeof value === 'number') {
displayValue = value.toLocaleString();
} else {
displayValue = String(value);
}
return (
<div key={key} className="bg-[var(--bg-secondary)] p-3 rounded-lg">
<p className="text-xs text-[var(--text-tertiary)] uppercase tracking-wider">
{key.replace(/_/g, ' ').replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase())}
</p>
<p className="text-sm font-semibold text-[var(--text-primary)] break-words">
{displayValue}
</p>
</div>
);
})}
</div>
)}
<div className="flex items-center justify-between">
<p className="text-xs text-[var(--text-tertiary)]">{insight.timestamp}</p>
<p className="text-xs text-[var(--text-tertiary)]">{insight.created_at ? new Date(insight.created_at).toLocaleString() : ''}</p>
{insight.actionable && (
<div className="flex gap-2">
<Button

View File

@@ -394,7 +394,7 @@ export const routesConfig: RouteConfig[] = [
icon: 'insights',
requiresAuth: true,
requiredRoles: ROLE_COMBINATIONS.MANAGEMENT_ACCESS,
requiredAnalyticsLevel: 'predictive',
requiredAnalyticsLevel: 'advanced', // Available for Professional and Enterprise tiers
showInNavigation: true,
showInBreadcrumbs: true,
},