AI Assistant per Analisi Produzione - Roadmap
AI Assistant per Analisi Produzione - Roadmap
Data Creazione: 20 Ottobre 2025 Versione: 1.0 Modulo: Industria 4.0 - AI Assistant
---
π Indice
1. Vision e Obiettivi 2. Architettura Proposta 3. Tecnologie e Stack 4. Roadmap Implementazione 5. Fase 1: Foundation 6. Fase 2: Knowledge Base 7. Fase 3: AI Integration 8. Fase 4: UI/UX 9. Fase 5: Advanced Features 10. Esempi Query AI 11. Costi e Considerazioni
---
π― Vision e Obiettivi
Cosa Vogliamo Ottenere?
Un assistente AI conversazionale integrato nella vista "Analisi Lavorazioni" che permetta agli operatori di:
Domande Operative
- "Qual Γ¨ stata la produzione media di questa settimana rispetto alla scorsa?"
- "PerchΓ© ieri abbiamo prodotto meno del solito?"
- "Quale operatore ha la resa migliore sul taglio laser?"
- "Ci sono stati picchi di produzione anomali nell'ultimo mese?"
- "Riusciremo a completare l'ordine X123 entro venerdì?"
- "Qual Γ¨ il tempo medio per tagliare 1000 pezzi di materiale Y?"
- "Quanto tempo serve mediamente per un setup macchina?"
- "Mostrami i trend di produzione per materiale"
- "Quali sono i giorni della settimana piΓΉ produttivi?"
- "Ci sono pattern ricorrenti negli errori di taglio?"
- "Come posso aumentare la produttivitΓ del 10%?"
- "Quali sono i colli di bottiglia nella produzione?"
- "Suggeriscimi un piano di lavoro ottimale per domani"
- API matura e stabile
- Ottimo per text-to-SQL
- Function calling nativo
- Documentazione eccellente
- Pay-per-use (no infrastruttura)
- Costi variabili (~ $0.01-0.03 per query)
- Dipendenza esterna (latency 1-3s)
- Privacy: dati inviati a OpenAI (mitigabile con Azure OpenAI)
- Contesto piΓΉ lungo (200K tokens vs 128K GPT-4)
- Ottimo per analisi dati complesse
- Prezzi competitivi
- Meno integrazioni disponibili
- Community piΓΉ piccola
- Zero costi API
- Privacy totale
- Personalizzabile
- Richiede GPU (costi infrastruttura)
- Setup complesso
- QualitΓ inferiore (per ora)
- Manutenzione continua
- Analisi dati temporali (trend, medie, confronti)
- Generazione query SQL per estrarre informazioni
- Identificazione anomalie e pattern
- Suggerimenti per ottimizzazione produzione
- "Confronta con l'altro ieri"
- "Mostrami il trend settimanale"
- "Chi ha prodotto di piΓΉ ieri?"
- [ ] Creare migrations database (4 tabelle)
- [ ] Creare Models Eloquent
- [ ] Configurare
- [ ] Testare connessione database
- [ ] Implementare
- [ ] Implementare
- [ ] Implementare
- [ ] Testare estrazione schema profilo
- [ ] Installare
- [ ] Implementare
- [ ] Implementare
- [ ] Testare Function Calling con mock
- [ ] Implementare SQL safety checks
- [ ] Testare end-to-end con query reali
- [ ] Creare Vue component
- [ ] Integrare in
- [ ] Implementare voice input (opzionale)
- [ ] Testare responsive design
- [ ] Implementare feedback UI
- [ ] Implementare
- [ ] Setup Scheduled Job pulizia cache
- [ ] Implementare export conversazioni
- [ ] Implementare alert automation
- [ ] Implementare response suggestions
- [ ] Unit tests (services)
- [ ] Integration tests (controller)
- [ ] Performance tests (response time)
- [ ] Security tests (SQL injection)
- [ ] User acceptance testing
- [ ] Verificare .env production
- [ ] Setup monitoring (logs, analytics)
- [ ] Configurare backups database AI
- [ ] Documentazione utente
- [ ] Training team
- OpenAI API: https://platform.openai.com/docs/api-reference
- Function Calling: https://platform.openai.com/docs/guides/function-calling
- Laravel OpenAI Package: https://github.com/openai-php/laravel
- Chart.js: https://www.chartjs.org/docs/latest/
- Vue 3: https://vuejs.org/guide/introduction.html
- Prompt Engineering: https://www.promptingguide.ai/
- Text-to-SQL: https://arxiv.org/abs/2204.00498
- AI Safety: https://www.anthropic.com/index/ai-safety
- OpenAI Community: https://community.openai.com/
- Laravel Forum: https://laracasts.com/discuss
- Stack Overflow: Tag
Analisi Predittive
Insights Automatici
Ottimizzazione
Vantaggi Competitivi
β Democratizzazione Dati: Anche chi non sa SQL puΓ² analizzare la produzione β Decisioni Data-Driven: Risposte immediate basate su dati reali β ProattivitΓ : L'AI segnala anomalie e opportunitΓ β Continuous Learning: Migliora col tempo imparando dai pattern aziendali
---
ποΈ Architettura Proposta
Componenti Sistema
``
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β FRONTEND (Vue/React Component) β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Chat UI: β β
β β β’ Input utente (textarea + voice) β β
β β β’ Storico conversazione β β
β β β’ Suggerimenti query β β
β β β’ Visualizzazione grafici inline β β
β β β’ Export risposte (PDF/Excel) β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
ββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββ
β WebSocket / AJAX
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β BACKEND (Laravel API) β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β AI Controller: β β
β β β’ POST /ai/chat (nuova query) β β
β β β’ GET /ai/history (conversazioni passate) β β
β β β’ POST /ai/feedback (thumbs up/down) β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β AI Service Layer: β β
β β β’ Query Understanding (NLP) β β
β β β’ Context Builder (prepara dati per LLM) β β
β β β’ Response Generator (chiama LLM API) β β
β β β’ SQL Generator (converte linguaggio β query) β β
β β β’ Cache Layer (evita chiamate duplicate) β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
ββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββ
β
ββββββββββββββββββββΌβββββββββββββββββββ
β β β
ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ
β LLM API β β Vector DB β β MySQL DB β
β (OpenAI/ β β (Embeddings)β β (Raw Data) β
β Anthropic) β β - Pinecone β β - machine_ β
β β β - Weaviate β β operationsβ
β β β - Qdrant β β _log β
ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ</p><p><h3>Flusso Richiesta AI</h3></p><p></code>`<code>
1. UTENTE: "Qual Γ¨ stata la produzione di ieri?"
β
2. FRONTEND: Invia POST /ai/chat con { query: "...", machine_id: 5, context: {...} }
β
3. BACKEND (AI Service):
a) Query Understanding
β Estrae intent: "produzione_totale"
β Estrae entitΓ : periodo="ieri", metrica="pezzi"
β Estrae filtri: machine_id=5
b) Context Builder
β Recupera profilo macchina (colonne, aggregatori)
β Recupera ultimi 30 giorni di dati (per contesto)
β Prepara prompt per LLM:
"Sei un assistente AI per analisi produzione.
Database schema: [columns...]
Ultimi dati: [sample rows...]
Query utente: 'Qual Γ¨ stata la produzione di ieri?'
Genera SQL query e spiega il risultato."
c) LLM API Call
β Invia prompt a OpenAI GPT-4 / Claude
β Riceve risposta:
{
"sql": "SELECT SUM(JSON_EXTRACT(...)) FROM ... WHERE DATE(timestamp)=CURDATE()-1",
"explanation": "Ho calcolato la somma dei pezzi prodotti ieri...",
"confidence": 0.95
}
d) SQL Execution
β Valida SQL (whitelist operazioni, prevent injection)
β Esegue query su DB
β Ottiene risultato: 1500 pezzi
e) Response Generation
β LLM formatta risposta user-friendly:
"Ieri hai prodotto 1.500 pezzi, in linea con la media settimanale di 1.450."
β Genera grafico suggerito (se rilevante)
β Aggiunge follow-up suggestions
β
4. FRONTEND: Mostra risposta + grafico + suggestions---
π οΈ Tecnologie e Stack
Opzione A: OpenAI GPT-4 (Consigliata per MVP)
Pro:
Contro:
Stack:
`php
// Laravel
composer require openai-php/laravel// Vector DB (opzionale per ora)
// composer require pinecone/pinecone-php
`Opzione B: Anthropic Claude (Alternativa)
Pro:
Contro:
Opzione C: Open Source (LLaMA, Mistral) + Self-Hosted
Pro:
Contro:
Decisione: Partire con OpenAI GPT-4 per MVP, poi valutare self-hosted se volumi alti.
---
πΊοΈ Roadmap Implementazione
Timeline Stimata: 6-8 Settimane
`
SETTIMANA 1-2: FASE 1 - Foundation
SETTIMANA 3-4: FASE 2 - Knowledge Base
SETTIMANA 5-6: FASE 3 - AI Integration
SETTIMANA 7: FASE 4 - UI/UX
SETTIMANA 8: FASE 5 - Testing & Refinement
`---
π¦ Fase 1: Foundation
Durata: 1-2 settimane
Obiettivo: Preparare infrastruttura base per AI assistant
1.1 Database Schema Extensions
Tabella:
ai_conversations
`sql
CREATE TABLE ai_conversations (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
machine_id BIGINT UNSIGNED NULL, -- Contesto macchina (opzionale)
title VARCHAR(255), -- Auto-generato dal primo messaggio
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user (user_id),
INDEX idx_machine (machine_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`Tabella:
ai_messages
`sql
CREATE TABLE ai_messages (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
conversation_id BIGINT UNSIGNED NOT NULL,
role ENUM('user', 'assistant', 'system') NOT NULL,
content TEXT NOT NULL,
metadata JSON, -- { sql: "...", confidence: 0.95, execution_time: 1.2, chart_config: {...} }
tokens_used INT UNSIGNED, -- Per tracking costi
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_conversation (conversation_id),
FOREIGN KEY (conversation_id) REFERENCES ai_conversations(id) ON DELETE CASCADE
);
`Tabella:
ai_feedback
`sql
CREATE TABLE ai_feedback (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
message_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
rating ENUM('up', 'down') NOT NULL,
comment TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (message_id) REFERENCES ai_messages(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`Tabella:
ai_cache (Opzionale per performance)
`sql
CREATE TABLE ai_cache (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
query_hash VARCHAR(64) UNIQUE NOT NULL, -- MD5/SHA256 della query normalizzata
machine_id BIGINT UNSIGNED NULL,
date_range VARCHAR(50), -- "2025-10-01_2025-10-20"
response_data JSON NOT NULL,
hits INT UNSIGNED DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP,
INDEX idx_hash (query_hash),
INDEX idx_expires (expires_at)
);
`1.2 Laravel Setup
Migration
`bash
php artisan make:migration create_ai_assistant_tables
``php
// database/migrations/*_create_ai_assistant_tables.php
public function up()
{
Schema::create('ai_conversations', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->foreignId('machine_id')->nullable()->constrained('machines_i40')->onDelete('set null');
$table->string('title')->nullable();
$table->timestamps();
$table->index('user_id');
$table->index('machine_id');
});
Schema::create('ai_messages', function (Blueprint $table) {
$table->id();
$table->foreignId('conversation_id')->constrained('ai_conversations')->onDelete('cascade');
$table->enum('role', ['user', 'assistant', 'system']);
$table->text('content');
$table->json('metadata')->nullable();
$table->unsignedInteger('tokens_used')->nullable();
$table->timestamp('created_at')->useCurrent();
$table->index('conversation_id');
});
Schema::create('ai_feedback', function (Blueprint $table) {
$table->id();
$table->foreignId('message_id')->constrained('ai_messages')->onDelete('cascade');
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->enum('rating', ['up', 'down']);
$table->text('comment')->nullable();
$table->timestamp('created_at')->useCurrent();
});
Schema::create('ai_cache', function (Blueprint $table) {
$table->id();
$table->string('query_hash', 64)->unique();
$table->foreignId('machine_id')->nullable()->constrained('machines_i40')->onDelete('cascade');
$table->string('date_range', 50)->nullable();
$table->json('response_data');
$table->unsignedInteger('hits')->default(0);
$table->timestamp('created_at')->useCurrent();
$table->timestamp('expires_at')->nullable();
$table->index('query_hash');
$table->index('expires_at');
});
}
`Models
`bash
php artisan make:model I40/AiConversation
php artisan make:model I40/AiMessage
php artisan make:model I40/AiFeedback
``php
// app/Models/I40/AiConversation.php
class AiConversation extends Model
{
protected $fillable = ['user_id', 'machine_id', 'title'];
public function messages()
{
return $this->hasMany(AiMessage::class, 'conversation_id');
}
public function user()
{
return $this->belongsTo(User::class);
}
public function machine()
{
return $this->belongsTo(Machine::class);
}
// Auto-genera titolo dal primo messaggio
public function generateTitle()
{
$firstMessage = $this->messages()->where('role', 'user')->first();
if ($firstMessage) {
$this->title = Str::limit($firstMessage->content, 50);
$this->save();
}
}
}
``php
// app/Models/I40/AiMessage.php
class AiMessage extends Model
{
protected $fillable = ['conversation_id', 'role', 'content', 'metadata', 'tokens_used'];
protected $casts = ['metadata' => 'array'];
public $timestamps = false;
public function conversation()
{
return $this->belongsTo(AiConversation::class, 'conversation_id');
}
public function feedback()
{
return $this->hasOne(AiFeedback::class, 'message_id');
}
}
`1.3 Configuration
`bash
.env aggiungi
OPENAI_API_KEY=sk-proj-...
OPENAI_MODEL=gpt-4-turbo-preview
OPENAI_MAX_TOKENS=1000
AI_CACHE_TTL=3600 # 1 ora
AI_ENABLED=true
``php
// config/ai.php
return [
'enabled' => env('AI_ENABLED', true),
'openai' => [
'api_key' => env('OPENAI_API_KEY'),
'model' => env('OPENAI_MODEL', 'gpt-4-turbo-preview'),
'max_tokens' => env('OPENAI_MAX_TOKENS', 1000),
'temperature' => 0.3, // Basso per risposte deterministiche
],
'cache' => [
'enabled' => true,
'ttl' => env('AI_CACHE_TTL', 3600), // secondi
],
'limits' => [
'max_messages_per_conversation' => 50,
'max_conversations_per_user' => 100,
'max_query_length' => 500, // caratteri
],
];
`---
π§ Fase 2: Knowledge Base
Durata: 1-2 settimane
Obiettivo: Preparare dati e contesto per LLM
2.1 Database Schema Extraction
Service:
DatabaseSchemaService`php
// app/Services/I40/DatabaseSchemaService.php
namespace App\Services\I40;class DatabaseSchemaService
{
/
* Estrae schema leggibile per LLM dal profilo macchina
*/
public function getSchemaForMachine(Machine $machine): array
{
$profile = $machine->getActiveProfile();
if (!$profile) return [];
$columns = $profile->columns ?? [];
$columnNames = $profile->column_names ?? [];
$schema = [
'table' => 'machine_operations_log',
'description' => "Log operazioni macchina: {$machine->name}",
'columns' => []
];
// Colonne standard
$schema['columns'][] = [
'name' => 'id',
'type' => 'integer',
'description' => 'ID univoco operazione'
];
$schema['columns'][] = [
'name' => 'timestamp',
'type' => 'datetime',
'description' => 'Data e ora operazione',
'is_primary_timestamp' => true
];
// Colonne dinamiche da profilo
foreach ($columns as $index => $col) {
$csvName = $col['csv_name'] ?? $col['name'];
$label = $columnNames[$index] ?? $col['label'] ?? $csvName;
$schema['columns'][] = [
'name' => $csvName,
'type' => $col['type'] ?? 'string',
'description' => $label,
'is_filterable' => $col['is_filterable'] ?? false,
'is_aggregatable' => $col['is_aggregatable'] ?? false,
'aggregators' => $col['aggregators'] ?? [],
'sample_values' => $this->getSampleValues($machine->id, $csvName, 3)
];
}
return $schema;
}
/
* Ottiene valori di esempio per una colonna
*/
protected function getSampleValues(int $machineId, string $columnName, int $limit = 3): array
{
return \DB::table('machine_operations_log')
->where('machine_id', $machineId)
->selectRaw("DISTINCT JSON_UNQUOTE(JSON_EXTRACT(raw_data, '$.{$columnName}')) as val")
->whereNotNull(\DB::raw("JSON_EXTRACT(raw_data, '$.{$columnName}')"))
->limit($limit)
->pluck('val')
->toArray();
}
/
* Genera descrizione testuale dello schema per prompt LLM
*/
public function generateSchemaPrompt(Machine $machine): string
{
$schema = $this->getSchemaForMachine($machine);
$prompt = "DATABASE SCHEMA PER MACCHINA: {$machine->name}\n\n";
$prompt .= "Tabella: {$schema['table']}\n";
$prompt .= "Descrizione: {$schema['description']}\n\n";
$prompt .= "COLONNE DISPONIBILI:\n";
foreach ($schema['columns'] as $col) {
$prompt .= "- {$col['name']} ({$col['type']}): {$col['description']}\n";
if (!empty($col['aggregators'])) {
$prompt .= " β Aggregabile: " . implode(', ', $col['aggregators']) . "\n";
}
if (!empty($col['sample_values'])) {
$prompt .= " β Esempi: " . implode(', ', array_slice($col['sample_values'], 0, 3)) . "\n";
}
}
return $prompt;
}
}
`2.2 Context Window Builder
Service:
AiContextService`php
// app/Services/I40/AiContextService.php
namespace App\Services\I40;class AiContextService
{
protected $schemaService;
public function __construct(DatabaseSchemaService $schemaService)
{
$this->schemaService = $schemaService;
}
/
* Prepara contesto completo per LLM
*/
public function buildContext(array $params): array
{
$machineId = $params['machine_id'] ?? null;
$dateFrom = $params['date_from'] ?? now()->subMonth();
$dateTo = $params['date_to'] ?? now();
$machine = Machine::find($machineId);
if (!$machine) {
throw new \Exception('Macchina non trovata');
}
return [
'schema' => $this->schemaService->generateSchemaPrompt($machine),
'statistics' => $this->getRecentStatistics($machine, $dateFrom, $dateTo),
'sample_data' => $this->getSampleRecords($machine, 5),
'business_rules' => $this->getBusinessRules($machine),
];
}
/
* Statistiche recenti per dare contesto temporale
*/
protected function getRecentStatistics(Machine $machine, $dateFrom, $dateTo): string
{
$stats = MachineOperationLog::query()
->where('machine_id', $machine->id)
->whereBetween('timestamp', [$dateFrom, $dateTo])
->selectRaw('
COUNT(*) as total_operations,
COUNT(DISTINCT DATE(timestamp)) as active_days,
MIN(timestamp) as first_operation,
MAX(timestamp) as last_operation
')
->first();
return sprintf(
"STATISTICHE PERIODO %s - %s:\n" .
"- Operazioni totali: %d\n" .
"- Giorni attivi: %d\n" .
"- Prima operazione: %s\n" .
"- Ultima operazione: %s",
$dateFrom->format('d/m/Y'),
$dateTo->format('d/m/Y'),
$stats->total_operations,
$stats->active_days,
$stats->first_operation,
$stats->last_operation
);
}
/
* Sample records per dare esempi concreti
*/
protected function getSampleRecords(Machine $machine, int $limit = 5): string
{
$records = MachineOperationLog::where('machine_id', $machine->id)
->orderBy('timestamp', 'desc')
->limit($limit)
->get();
if ($records->isEmpty()) {
return "Nessun dato disponibile.";
}
$sample = "ESEMPI OPERAZIONI RECENTI (ultimi {$limit} record):\n";
foreach ($records as $i => $record) {
$sample .= sprintf(
"%d. [%s] %s\n",
$i + 1,
$record->timestamp->format('Y-m-d H:i'),
json_encode(array_slice($record->raw_data, 0, 5), JSON_UNESCAPED_UNICODE)
);
}
return $sample;
}
/
* Regole di business specifiche azienda
*/
protected function getBusinessRules(Machine $machine): string
{
// Qui puoi aggiungere regole custom basate sul tipo macchina
$rules = [
"REGOLE DI BUSINESS:",
"- L'orario lavorativo standard Γ¨ 07:00-19:00",
"- La produzione media attesa Γ¨ definita nel profilo macchina",
"- Un 'avviamento' Γ¨ un setup macchina prima di una lavorazione",
];
// Aggiungi regole specifiche dalla configurazione profilo se disponibili
$profile = $machine->getActiveProfile();
if ($profile && isset($profile->business_rules)) {
$rules = array_merge($rules, $profile->business_rules);
}
return implode("\n", $rules);
}
}
`2.3 Query Understanding (Intent Detection)
Service:
QueryUnderstandingService`php
// app/Services/I40/QueryUnderstandingService.php
namespace App\Services\I40;class QueryUnderstandingService
{
/
* Analizza query utente ed estrae intent/entitΓ
*/
public function analyze(string $userQuery): array
{
// Step 1: Normalizza query
$normalized = $this->normalize($userQuery);
// Step 2: Detect intent
$intent = $this->detectIntent($normalized);
// Step 3: Extract entities
$entities = $this->extractEntities($normalized);
return [
'original' => $userQuery,
'normalized' => $normalized,
'intent' => $intent,
'entities' => $entities,
'confidence' => $this->calculateConfidence($intent, $entities),
];
}
/
* Normalizza query (lowercase, rimuovi punteggiatura, etc.)
*/
protected function normalize(string $query): string
{
$query = strtolower($query);
$query = preg_replace('/[^a-z0-9\sà èéìòù]/', '', $query);
return trim($query);
}
/
* Rileva intent principale della query
*/
protected function detectIntent(string $query): string
{
$patterns = [
'production_total' => ['produzione', 'prodotto', 'pezzi', 'quantitΓ ', 'quanti'],
'production_average' => ['media', 'mediamente', 'in media'],
'production_comparison' => ['confronto', 'rispetto', 'differenza', 'piΓΉ', 'meno'],
'trend_analysis' => ['trend', 'andamento', 'evoluzione', 'grafico'],
'operator_performance' => ['operatore', 'chi ha', 'migliore', 'peggiore'],
'time_analysis' => ['tempo', 'durata', 'velocitΓ ', 'quanto ci vuole'],
'anomaly_detection' => ['anomalia', 'problema', 'strano', 'perchΓ©'],
'prediction' => ['previsione', 'riusciremo', 'ce la facciamo', 'entro quando'],
'optimization' => ['ottimizza', 'migliora', 'aumenta', 'suggerisci'],
];
foreach ($patterns as $intent => $keywords) {
foreach ($keywords as $keyword) {
if (str_contains($query, $keyword)) {
return $intent;
}
}
}
return 'general_question';
}
/
* Estrae entitΓ dalla query (date, numeri, nomi, etc.)
*/
protected function extractEntities(string $query): array
{
$entities = [];
// Date temporali
if (preg_match('/ieri/', $query)) {
$entities['period'] = 'yesterday';
$entities['date_from'] = now()->subDay()->startOfDay();
$entities['date_to'] = now()->subDay()->endOfDay();
} elseif (preg_match('/oggi/', $query)) {
$entities['period'] = 'today';
$entities['date_from'] = now()->startOfDay();
$entities['date_to'] = now()->endOfDay();
} elseif (preg_match('/questa settimana/', $query)) {
$entities['period'] = 'this_week';
$entities['date_from'] = now()->startOfWeek();
$entities['date_to'] = now()->endOfWeek();
} elseif (preg_match('/ultimo mese|mese scorso/', $query)) {
$entities['period'] = 'last_month';
$entities['date_from'] = now()->subMonth()->startOfMonth();
$entities['date_to'] = now()->subMonth()->endOfMonth();
}
// Numeri
if (preg_match('/\d+/', $query, $matches)) {
$entities['numbers'] = array_map('intval', $matches);
}
// Metriche
$metrics = ['pezzi', 'tempo', 'durata', 'operazioni'];
foreach ($metrics as $metric) {
if (str_contains($query, $metric)) {
$entities['metric'] = $metric;
break;
}
}
return $entities;
}
/
* Calcola confidence score (0-1)
*/
protected function calculateConfidence(string $intent, array $entities): float
{
$score = 0.5; // Base
if ($intent !== 'general_question') $score += 0.2;
if (!empty($entities['period'])) $score += 0.15;
if (!empty($entities['metric'])) $score += 0.15;
return min($score, 1.0);
}
}
`---
π€ Fase 3: AI Integration
Durata: 2 settimane
Obiettivo: Integrare OpenAI GPT-4 e gestire conversazioni
3.1 OpenAI Service
Installation
`bash
composer require openai-php/laravel
php artisan vendor:publish --provider="OpenAI\Laravel\ServiceProvider"
`Service:
OpenAiService`php
// app/Services/I40/OpenAiService.php
namespace App\Services\I40;use OpenAI\Laravel\Facades\OpenAI;
class OpenAiService
{
/
* Genera risposta AI per query utente
*/
public function generateResponse(array $context, string $userQuery): array
{
$systemPrompt = $this->buildSystemPrompt($context);
$messages = $this->buildMessages($systemPrompt, $userQuery);
try {
$response = OpenAI::chat()->create([
'model' => config('ai.openai.model'),
'messages' => $messages,
'temperature' => config('ai.openai.temperature'),
'max_tokens' => config('ai.openai.max_tokens'),
'functions' => $this->getFunctionDefinitions(),
'function_call' => 'auto',
]);
$choice = $response->choices[0];
$tokensUsed = $response->usage->totalTokens;
// Se LLM vuole chiamare una funzione (es. generare SQL)
if (isset($choice->message->functionCall)) {
return $this->handleFunctionCall($choice->message->functionCall, $tokensUsed);
}
// Altrimenti risposta diretta
return [
'content' => $choice->message->content,
'tokens_used' => $tokensUsed,
'finish_reason' => $choice->finishReason,
];
} catch (\Exception $e) {
\Log::error('OpenAI API Error: ' . $e->getMessage());
throw new \Exception('Errore nel servizio AI. Riprova piΓΉ tardi.');
}
}
/
* Costruisce prompt di sistema (contesto + istruzioni)
*/
protected function buildSystemPrompt(array $context): string
{
return <<<PROMPT
Sei un assistente AI specializzato nell'analisi di dati di produzione industriale.
Il tuo compito Γ¨ aiutare gli operatori a comprendere e ottimizzare le prestazioni delle macchine.
COMPETENZE:
CONTESTO DATABASE:
{$context['schema']}
{$context['statistics']}
{$context['sample_data']}
{$context['business_rules']}
ISTRUZIONI:
1. Rispondi sempre in italiano professionale ma accessibile
2. Se serve generare SQL, usa la function "execute_sql_query"
3. Formatta numeri con separatore migliaia (es. 1.500)
4. Suggerisci visualizzazioni (grafici) quando appropriato
5. Se non sei sicuro, ammettilo e chiedi chiarimenti
6. Fornisci sempre contesto (es. "rispetto alla media settimanale di...")
PROMPT;
}
/
* Costruisce array messaggi per API
*/
protected function buildMessages(string $systemPrompt, string $userQuery): array
{
return [
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => $userQuery],
];
}
/
* Definisce funzioni che LLM puΓ² chiamare (Function Calling)
*/
protected function getFunctionDefinitions(): array
{
return [
[
'name' => 'execute_sql_query',
'description' => 'Esegue una query SQL sul database produzione e restituisce i risultati',
'parameters' => [
'type' => 'object',
'properties' => [
'sql' => [
'type' => 'string',
'description' => 'Query SQL SELECT da eseguire (solo SELECT, no INSERT/UPDATE/DELETE)',
],
'explanation' => [
'type' => 'string',
'description' => 'Spiegazione di cosa fa la query, per l\'utente',
],
],
'required' => ['sql', 'explanation'],
],
],
[
'name' => 'generate_chart',
'description' => 'Suggerisce un grafico per visualizzare i dati',
'parameters' => [
'type' => 'object',
'properties' => [
'chart_type' => [
'type' => 'string',
'enum' => ['line', 'bar', 'pie'],
'description' => 'Tipo di grafico',
],
'x_field' => [
'type' => 'string',
'description' => 'Campo per asse X',
],
'y_metric' => [
'type' => 'string',
'description' => 'Metrica per asse Y',
],
],
'required' => ['chart_type', 'x_field', 'y_metric'],
],
],
];
}
/
* Gestisce chiamata a funzione da parte di LLM
*/
protected function handleFunctionCall(object $functionCall, int $tokensUsed): array
{
$functionName = $functionCall->name;
$arguments = json_decode($functionCall->arguments, true);
if ($functionName === 'execute_sql_query') {
$sql = $arguments['sql'];
$explanation = $arguments['explanation'];
// Valida e esegui SQL
$results = $this->executeSafeSql($sql);
return [
'content' => $this->formatSqlResults($results, $explanation),
'tokens_used' => $tokensUsed,
'metadata' => [
'sql' => $sql,
'explanation' => $explanation,
'result_count' => count($results),
],
];
}
if ($functionName === 'generate_chart') {
return [
'content' => "Grafico suggerito generato.",
'tokens_used' => $tokensUsed,
'metadata' => [
'chart_config' => $arguments,
],
];
}
throw new \Exception('Funzione non supportata: ' . $functionName);
}
/
* Esegue SQL in modo sicuro (whitelist operazioni, prevent injection)
*/
protected function executeSafeSql(string $sql): array
{
// Validazione: solo SELECT
if (!preg_match('/^SELECT\s+/i', trim($sql))) {
throw new \Exception('Solo query SELECT sono permesse');
}
// Validazione: no operazioni pericolose
$forbidden = ['DROP', 'DELETE', 'UPDATE', 'INSERT', 'TRUNCATE', 'ALTER', 'CREATE'];
foreach ($forbidden as $keyword) {
if (stripos($sql, $keyword) !== false) {
throw new \Exception('Query non permessa: contiene ' . $keyword);
}
}
// Esegui con timeout
try {
$results = \DB::select($sql);
return json_decode(json_encode($results), true); // Convert to array
} catch (\Exception $e) {
\Log::error('SQL Execution Error: ' . $e->getMessage());
throw new \Exception('Errore esecuzione query: ' . $e->getMessage());
}
}
/
* Formatta risultati SQL per risposta user-friendly
*/
protected function formatSqlResults(array $results, string $explanation): string
{
if (empty($results)) {
return "Non ho trovato dati per questa query. " . $explanation;
}
$response = $explanation . "\n\n";
// Se singolo valore scalare (es. COUNT, SUM)
if (count($results) === 1 && count($results[0]) === 1) {
$value = array_values($results[0])[0];
$response .= "Risultato: " . number_format($value, 0, ',', '.') . "\n";
return $response;
}
// Se lista valori, mostra tabella semplice
$response .= "Risultati trovati: " . count($results) . "\n\n";
// Mostra primi 10 risultati
$displayResults = array_slice($results, 0, 10);
foreach ($displayResults as $i => $row) {
$response .= ($i + 1) . ". ";
$response .= implode(' | ', array_map(function($k, $v) {
return "$k: $v";
}, array_keys($row), $row));
$response .= "\n";
}
if (count($results) > 10) {
$response .= "\n... e altri " . (count($results) - 10) . " risultati.\n";
}
return $response;
}
}
`3.2 AI Chat Controller
`php
// app/Http/Controllers/Admin/I40/AiChatController.php
namespace App\Http\Controllers\Admin\I40;use App\Http\Controllers\Controller;
use App\Services\I40\{
AiContextService,
QueryUnderstandingService,
OpenAiService
};
use App\Models\I40\{AiConversation, AiMessage};
use Illuminate\Http\Request;
class AiChatController extends Controller
{
protected $contextService;
protected $queryService;
protected $openAiService;
public function __construct(
AiContextService $contextService,
QueryUnderstandingService $queryService,
OpenAiService $openAiService
) {
$this->contextService = $contextService;
$this->queryService = $queryService;
$this->openAiService = $openAiService;
}
/
* POST /ai/chat
* Nuova query utente
*/
public function chat(Request $request)
{
$validated = $request->validate([
'conversation_id' => 'nullable|exists:ai_conversations,id',
'machine_id' => 'required|exists:machines_i40,id',
'message' => 'required|string|max:500',
'date_from' => 'nullable|date',
'date_to' => 'nullable|date',
]);
// 1) Ottieni o crea conversazione
$conversation = $this->getOrCreateConversation(
$validated['conversation_id'] ?? null,
$validated['machine_id'],
auth()->id()
);
// 2) Salva messaggio utente
$userMessage = $conversation->messages()->create([
'role' => 'user',
'content' => $validated['message'],
]);
try {
// 3) Analizza query
$analysis = $this->queryService->analyze($validated['message']);
// 4) Prepara contesto
$context = $this->contextService->buildContext([
'machine_id' => $validated['machine_id'],
'date_from' => $validated['date_from'] ?? now()->subMonth(),
'date_to' => $validated['date_to'] ?? now(),
]);
// 5) Genera risposta AI
$aiResponse = $this->openAiService->generateResponse(
$context,
$validated['message']
);
// 6) Salva risposta AI
$assistantMessage = $conversation->messages()->create([
'role' => 'assistant',
'content' => $aiResponse['content'],
'tokens_used' => $aiResponse['tokens_used'],
'metadata' => $aiResponse['metadata'] ?? null,
]);
// 7) Auto-genera titolo conversazione se prima query
if ($conversation->messages()->count() === 2) {
$conversation->generateTitle();
}
return response()->json([
'success' => true,
'conversation_id' => $conversation->id,
'message' => [
'id' => $assistantMessage->id,
'role' => 'assistant',
'content' => $assistantMessage->content,
'metadata' => $assistantMessage->metadata,
'created_at' => $assistantMessage->created_at,
],
'analysis' => $analysis, // Per debug
]);
} catch (\Exception $e) {
\Log::error('AI Chat Error: ' . $e->getMessage());
// Risposta fallback
$errorMessage = $conversation->messages()->create([
'role' => 'assistant',
'content' => 'Mi dispiace, ho avuto un problema nel processare la tua richiesta. Potresti riformulare la domanda?',
'metadata' => ['error' => $e->getMessage()],
]);
return response()->json([
'success' => false,
'error' => $e->getMessage(),
'conversation_id' => $conversation->id,
'message' => [
'id' => $errorMessage->id,
'role' => 'assistant',
'content' => $errorMessage->content,
'created_at' => $errorMessage->created_at,
],
], 500);
}
}
/
* GET /ai/conversations
* Lista conversazioni utente
*/
public function conversations(Request $request)
{
$conversations = AiConversation::where('user_id', auth()->id())
->with(['messages' => function($q) {
$q->latest()->limit(1); // Solo ultimo messaggio
}])
->latest()
->paginate(20);
return response()->json($conversations);
}
/
* GET /ai/conversations/{id}
* Dettaglio conversazione (tutti i messaggi)
*/
public function show(AiConversation $conversation)
{
$this->authorize('view', $conversation);
$conversation->load('messages');
return response()->json($conversation);
}
/
* POST /ai/feedback
* Thumbs up/down su risposta AI
*/
public function feedback(Request $request)
{
$validated = $request->validate([
'message_id' => 'required|exists:ai_messages,id',
'rating' => 'required|in:up,down',
'comment' => 'nullable|string|max:500',
]);
$feedback = AiFeedback::updateOrCreate(
[
'message_id' => $validated['message_id'],
'user_id' => auth()->id(),
],
[
'rating' => $validated['rating'],
'comment' => $validated['comment'] ?? null,
]
);
return response()->json(['success' => true, 'feedback' => $feedback]);
}
/
* Helper: Ottieni o crea conversazione
*/
protected function getOrCreateConversation($conversationId, $machineId, $userId)
{
if ($conversationId) {
$conversation = AiConversation::find($conversationId);
if ($conversation && $conversation->user_id === $userId) {
return $conversation;
}
}
return AiConversation::create([
'user_id' => $userId,
'machine_id' => $machineId,
]);
}
}
`3.3 Routes
`php
// routes/i40.php (aggiungi)Route::prefix('ai')->name('ai.')->group(function () {
Route::post('/chat', [AiChatController::class, 'chat'])->name('chat');
Route::get('/conversations', [AiChatController::class, 'conversations'])->name('conversations');
Route::get('/conversations/{conversation}', [AiChatController::class, 'show'])->name('conversations.show');
Route::post('/feedback', [AiChatController::class, 'feedback'])->name('feedback');
})->middleware('role:admin|maintenance|super-admin');
`---
π¨ Fase 4: UI/UX
Durata: 1 settimana
Obiettivo: Interfaccia chat intuitiva nella vista Analisi
4.1 Vue Component (Consigliato)
Installazione Vue in Laravel (se non giΓ presente)
`bash
npm install vue@next vue-loader@next @vitejs/plugin-vue
</code>`<code></p><p><h4>Component: </code>AiChatWidget.vue<code></h4></p><p></code>`<code>vue
<!-- resources/js/components/I40/AiChatWidget.vue -->
<template>
<div class="ai-chat-widget" :class="{ 'minimized': minimized }">
<!-- Header -->
<div class="chat-header" @click="toggleMinimize">
<div class="d-flex align-items-center">
<div class="ai-avatar me-2">π€</div>
<div>
<strong>AI Assistant</strong>
<small class="d-block text-muted">Analisi Produzione</small>
</div>
</div>
<button class="btn btn-sm btn-link text-white">
<i :class="minimized ? 'bi-chevron-up' : 'bi-chevron-down'"></i>
</button>
</div>
<!-- Chat Body -->
<div v-show="!minimized" class="chat-body">
<!-- Messaggi -->
<div class="messages-container" ref="messagesContainer">
<div v-if="messages.length === 0" class="empty-state">
<div class="ai-avatar-large mb-3">π€</div>
<h5>Ciao! Sono il tuo assistente AI</h5>
<p class="text-muted">Chiedimi qualsiasi cosa sulla produzione di questa macchina.</p>
<!-- Suggerimenti iniziali -->
<div class="suggestions mt-3">
<small class="text-muted d-block mb-2">Prova a chiedere:</small>
<button
v-for="suggestion in initialSuggestions"
:key="suggestion"
class="btn btn-sm btn-outline-primary mb-2 me-2"
@click="sendMessage(suggestion)"
>
{{ suggestion }}
</button>
</div>
</div>
<!-- Lista messaggi -->
<div
v-for="message in messages"
:key="message.id"
class="message"
:class="message.role"
>
<div class="message-avatar">
{{ message.role === 'user' ? 'π€' : 'π€' }}
</div>
<div class="message-content">
<div class="message-text" v-html="formatMessage(message.content)"></div>
<!-- Metadata (SQL, Grafici, etc.) -->
<div v-if="message.metadata" class="message-metadata mt-2">
<!-- Mostra SQL eseguito -->
<div v-if="message.metadata.sql" class="sql-block">
<button
class="btn btn-xs btn-link"
@click="toggleSql(message.id)"
>
<i class="bi bi-code-square"></i> Mostra Query SQL
</button>
<pre v-show="expandedSql[message.id]" class="mt-2">{{ message.metadata.sql }}</pre>
</div>
<!-- Suggerisci grafico -->
<div v-if="message.metadata.chart_config" class="chart-suggestion">
<button
class="btn btn-sm btn-primary"
@click="openChart(message.metadata.chart_config)"
>
<i class="bi bi-bar-chart"></i> Visualizza Grafico
</button>
</div>
</div>
<!-- Feedback -->
<div v-if="message.role === 'assistant'" class="message-feedback mt-2">
<button
class="btn btn-xs"
:class="message.feedback === 'up' ? 'btn-success' : 'btn-outline-secondary'"
@click="sendFeedback(message.id, 'up')"
title="Risposta utile"
>
<i class="bi bi-hand-thumbs-up"></i>
</button>
<button
class="btn btn-xs ms-1"
:class="message.feedback === 'down' ? 'btn-danger' : 'btn-outline-secondary'"
@click="sendFeedback(message.id, 'down')"
title="Risposta non utile"
>
<i class="bi bi-hand-thumbs-down"></i>
</button>
</div>
<small class="text-muted d-block mt-1">{{ formatTime(message.created_at) }}</small>
</div>
</div>
<!-- Loading indicator -->
<div v-if="loading" class="message assistant">
<div class="message-avatar">π€</div>
<div class="message-content">
<div class="typing-indicator">
<span></span><span></span><span></span>
</div>
</div>
</div>
</div>
<!-- Input -->
<div class="chat-input">
<textarea
v-model="userInput"
@keydown.enter.prevent="sendMessage()"
placeholder="Chiedi qualcosa sulla produzione..."
rows="2"
:disabled="loading"
class="form-control"
></textarea>
<button
@click="sendMessage()"
:disabled="loading || !userInput.trim()"
class="btn btn-primary btn-send"
>
<i class="bi bi-send"></i>
</button>
</div>
</div>
</div>
</template></p><p><script>
export default {
name: 'AiChatWidget',
props: {
machineId: {
type: Number,
required: true
},
dateFrom: String,
dateTo: String,
},
data() {
return {
minimized: false,
messages: [],
userInput: '',
loading: false,
conversationId: null,
expandedSql: {},
initialSuggestions: [
"Qual Γ¨ stata la produzione di ieri?",
"Mostrami il trend di questa settimana",
"Chi Γ¨ l'operatore piΓΉ produttivo?",
],
};
},
methods: {
async sendMessage(text = null) {
const message = text || this.userInput.trim();
if (!message || this.loading) return;
// Aggiungi messaggio utente alla UI
this.messages.push({
id: Date.now(),
role: 'user',
content: message,
created_at: new Date().toISOString(),
});
this.userInput = '';
this.loading = true;
this.scrollToBottom();
try {
const response = await fetch('/admin/i40/ai/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify({
conversation_id: this.conversationId,
machine_id: this.machineId,
message: message,
date_from: this.dateFrom,
date_to: this.dateTo,
}),
});
const data = await response.json();
if (data.success) {
this.conversationId = data.conversation_id;
this.messages.push(data.message);
} else {
this.messages.push({
id: Date.now(),
role: 'assistant',
content: 'β ' + (data.error || 'Errore nel processare la richiesta.'),
created_at: new Date().toISOString(),
});
}
} catch (error) {
console.error('Chat error:', error);
this.messages.push({
id: Date.now(),
role: 'assistant',
content: 'β Errore di connessione. Riprova.',
created_at: new Date().toISOString(),
});
} finally {
this.loading = false;
this.scrollToBottom();
}
},
async sendFeedback(messageId, rating) {
try {
await fetch('/admin/i40/ai/feedback', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify({ message_id: messageId, rating }),
});
// Aggiorna UI
const message = this.messages.find(m => m.id === messageId);
if (message) message.feedback = rating;
} catch (error) {
console.error('Feedback error:', error);
}
},
toggleMinimize() {
this.minimized = !this.minimized;
},
toggleSql(messageId) {
this.expandedSql[messageId] = !this.expandedSql[messageId];
},
openChart(chartConfig) {
this.$emit('open-chart', chartConfig);
},
formatMessage(content) {
// Converti markdown-like syntax in HTML
return content
.replace(/\<em>\</em>(.<em>?)\</em>\*/g, '<strong>$1</strong>')
.replace(/\n/g, '<br>');
},
formatTime(timestamp) {
const date = new Date(timestamp);
return date.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' });
},
scrollToBottom() {
this.$nextTick(() => {
const container = this.$refs.messagesContainer;
if (container) {
container.scrollTop = container.scrollHeight;
}
});
},
},
};
</script></p><p><style scoped>
.ai-chat-widget {
position: fixed;
bottom: 20px;
right: 20px;
width: 400px;
max-height: 600px;
background: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
display: flex;
flex-direction: column;
z-index: 1050;
transition: all 0.3s ease;
}</p><p>.ai-chat-widget.minimized {
max-height: 60px;
}</p><p>.chat-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 12px 16px;
border-radius: 12px 12px 0 0;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
}</p><p>.ai-avatar {
width: 32px;
height: 32px;
background: rgba(255,255,255,0.2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}</p><p>.ai-avatar-large {
width: 64px;
height: 64px;
background: #f0f0f0;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
margin: 0 auto;
}</p><p>.chat-body {
display: flex;
flex-direction: column;
height: 540px;
}</p><p>.messages-container {
flex: 1;
overflow-y: auto;
padding: 16px;
}</p><p>.empty-state {
text-align: center;
padding: 40px 20px;
}</p><p>.suggestions button {
font-size: 0.85rem;
}</p><p>.message {
display: flex;
margin-bottom: 16px;
align-items: flex-start;
}</p><p>.message.user {
flex-direction: row-reverse;
}</p><p>.message-avatar {
width: 32px;
height: 32px;
background: #f0f0f0;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}</p><p>.message.user .message-avatar {
background: #667eea;
color: white;
}</p><p>.message-content {
max-width: 75%;
margin: 0 8px;
}</p><p>.message.user .message-content {
background: #667eea;
color: white;
border-radius: 12px 12px 0 12px;
padding: 10px 14px;
}</p><p>.message.assistant .message-content {
background: #f0f0f0;
border-radius: 12px 12px 12px 0;
padding: 10px 14px;
}</p><p>.message-text {
line-height: 1.5;
}</p><p>.sql-block pre {
background: #2d2d2d;
color: #f8f8f2;
padding: 10px;
border-radius: 6px;
font-size: 0.85rem;
overflow-x: auto;
}</p><p>.typing-indicator {
display: flex;
gap: 4px;
padding: 10px 0;
}</p><p>.typing-indicator span {
width: 8px;
height: 8px;
background: #999;
border-radius: 50%;
animation: typing 1.4s infinite;
}</p><p>.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}</p><p>.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}</p><p>@keyframes typing {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-10px); }
}</p><p>.chat-input {
display: flex;
padding: 12px;
border-top: 1px solid #e0e0e0;
gap: 8px;
}</p><p>.chat-input textarea {
flex: 1;
resize: none;
border-radius: 8px;
}</p><p>.btn-send {
width: 44px;
height: 44px;
border-radius: 8px;
}</p><p>.message-feedback .btn {
padding: 2px 8px;
font-size: 0.85rem;
}</p><p>/<em> Responsive </em>/
@media (max-width: 768px) {
.ai-chat-widget {
width: calc(100% - 40px);
max-width: 400px;
}
}
</style>4.2 Integrazione in
analyze.blade.php`blade
<!-- resources/views/admin/i40/operations/analyze.blade.php --><!-- Aggiungi prima di @endpush -->
@push('scripts')
<script>
// Inizializza Vue component AI Chat
import { createApp } from 'vue';
import AiChatWidget from '@/components/I40/AiChatWidget.vue';
const app = createApp({});
app.component('ai-chat-widget', AiChatWidget);
app.mount('#ai-chat-container');
</script>
@endpush
<!-- Aggiungi nel body della view -->
<div id="ai-chat-container">
<ai-chat-widget
:machine-id="{{ $selectedMachine->id }}"
date-from="{{ $dateFrom->format('Y-m-d') }}"
date-to="{{ $dateTo->format('Y-m-d') }}"
@open-chart="openChartFromAi"
></ai-chat-widget>
</div>
`---
π Fase 5: Advanced Features
Durata: Continua (post-MVP)
Obiettivo: FunzionalitΓ avanzate per migliorare UX e performance
5.1 Cache Intelligente
Implementazione
`php
// app/Services/I40/AiCacheService.php
namespace App\Services\I40;class AiCacheService
{
/
* Ottieni risposta da cache se disponibile
*/
public function get(string $query, int $machineId, array $filters = []): ?array
{
$hash = $this->generateHash($query, $machineId, $filters);
$cached = \DB::table('ai_cache')
->where('query_hash', $hash)
->where('expires_at', '>', now())
->first();
if ($cached) {
// Incrementa hit counter
\DB::table('ai_cache')
->where('id', $cached->id)
->increment('hits');
return json_decode($cached->response_data, true);
}
return null;
}
/
* Salva risposta in cache
*/
public function set(string $query, int $machineId, array $filters, array $response, int $ttl = null): void
{
$hash = $this->generateHash($query, $machineId, $filters);
$ttl = $ttl ?? config('ai.cache.ttl', 3600);
\DB::table('ai_cache')->updateOrInsert(
['query_hash' => $hash],
[
'machine_id' => $machineId,
'date_range' => $this->getDateRange($filters),
'response_data' => json_encode($response),
'expires_at' => now()->addSeconds($ttl),
'created_at' => now(),
]
);
}
/
* Genera hash univoco per query
*/
protected function generateHash(string $query, int $machineId, array $filters): string
{
// Normalizza query (lowercase, rimuovi spazi multipli)
$normalized = strtolower(trim(preg_replace('/\s+/', ' ', $query)));
// Combina con parametri
$key = $normalized . '|' . $machineId . '|' . json_encode($filters);
return hash('sha256', $key);
}
/
* Estrae date range dai filtri
*/
protected function getDateRange(array $filters): ?string
{
$from = $filters['date_from'] ?? null;
$to = $filters['date_to'] ?? null;
if ($from && $to) {
return "{$from}_{$to}";
}
return null;
}
/
* Pulisci cache scaduta
*/
public function cleanExpired(): int
{
return \DB::table('ai_cache')
->where('expires_at', '<', now())
->delete();
}
/
* Invalida cache per macchina (dopo nuovi dati)
*/
public function invalidateForMachine(int $machineId): int
{
return \DB::table('ai_cache')
->where('machine_id', $machineId)
->delete();
}
}
`Scheduled Job (Pulizia Automatica)
`php
// app/Console/Kernel.php
protected function schedule(Schedule $schedule)
{
// Pulisci cache AI scaduta ogni ora
$schedule->call(function () {
app(AiCacheService::class)->cleanExpired();
})->hourly();
}
`5.2 Voice Input (Speech-to-Text)
Frontend: Web Speech API
`javascript
// Aggiungi al Vue component
data() {
return {
// ...
isRecording: false,
recognition: null,
};
},
mounted() {
this.initSpeechRecognition();
},
methods: {
initSpeechRecognition() {
if (!('webkitSpeechRecognition' in window)) {
console.warn('Speech recognition not supported');
return;
}
this.recognition = new webkitSpeechRecognition();
this.recognition.lang = 'it-IT';
this.recognition.continuous = false;
this.recognition.interimResults = false;
this.recognition.onresult = (event) => {
const transcript = event.results[0][0].transcript;
this.userInput = transcript;
this.isRecording = false;
};
this.recognition.onerror = (event) => {
console.error('Speech recognition error:', event.error);
this.isRecording = false;
};
this.recognition.onend = () => {
this.isRecording = false;
};
},
toggleRecording() {
if (!this.recognition) return;
if (this.isRecording) {
this.recognition.stop();
this.isRecording = false;
} else {
this.recognition.start();
this.isRecording = true;
}
},
}
``vue
<!-- Template: Aggiungi bottone microfono -->
<button
@click="toggleRecording"
:class="{ 'recording': isRecording }"
class="btn btn-outline-secondary btn-voice"
title="Usa il microfono"
>
<i class="bi" :class="isRecording ? 'bi-mic-fill' : 'bi-mic'"></i>
</button>
``css
/ Style /
.btn-voice.recording {
animation: pulse 1.5s infinite;
background: #dc3545;
color: white;
border-color: #dc3545;
}@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
`5.3 Export Conversazioni
Backend: Export PDF
`php
// app/Services/I40/ConversationExportService.php
namespace App\Services\I40;use Barryvdh\DomPDF\Facade\Pdf;
class ConversationExportService
{
/
* Esporta conversazione in PDF
*/
public function exportToPdf(AiConversation $conversation): string
{
$data = [
'conversation' => $conversation,
'messages' => $conversation->messages,
'machine' => $conversation->machine,
'user' => $conversation->user,
];
$pdf = Pdf::loadView('admin.i40.ai.export-pdf', $data);
return $pdf->download("conversazione_{$conversation->id}.pdf");
}
/
* Esporta in formato JSON
*/
public function exportToJson(AiConversation $conversation): array
{
return [
'conversation_id' => $conversation->id,
'title' => $conversation->title,
'machine' => [
'id' => $conversation->machine->id,
'name' => $conversation->machine->name,
],
'created_at' => $conversation->created_at->toIso8601String(),
'messages' => $conversation->messages->map(function($msg) {
return [
'role' => $msg->role,
'content' => $msg->content,
'metadata' => $msg->metadata,
'created_at' => $msg->created_at->toIso8601String(),
];
})->toArray(),
];
}
}
`Route Export
`php
Route::get('/ai/conversations/{conversation}/export', [AiChatController::class, 'export'])
->name('ai.conversations.export');
``php
// Controller method
public function export(AiConversation $conversation, Request $request)
{
$this->authorize('view', $conversation);
$format = $request->input('format', 'pdf'); // pdf, json
$exportService = app(ConversationExportService::class);
if ($format === 'json') {
$data = $exportService->exportToJson($conversation);
return response()->json($data);
}
return $exportService->exportToPdf($conversation);
}
`5.4 Suggested Responses (Follow-up Questions)
Service: Response Suggestions
`php
// app/Services/I40/ResponseSuggestionsService.php
namespace App\Services\I40;class ResponseSuggestionsService
{
/
* Genera follow-up suggestions basate sul contesto
*/
public function generate(AiMessage $lastMessage, array $context): array
{
$intent = $context['intent'] ?? 'general';
$suggestions = $this->getBaselineSuggestions($intent);
// Personalizza basandosi sulla risposta AI
if ($lastMessage->metadata && isset($lastMessage->metadata['sql'])) {
$suggestions[] = "Mostra gli stessi dati in un grafico";
$suggestions[] = "Confronta con il periodo precedente";
}
return array_slice($suggestions, 0, 3); // Max 3 suggestions
}
/
* Suggestions baseline per intent
*/
protected function getBaselineSuggestions(string $intent): array
{
$map = [
'production_total' => [
"Confronta con ieri",
"Mostrami il trend settimanale",
"Quale operatore ha prodotto di piΓΉ?",
],
'operator_performance' => [
"Analizza la velocitΓ media",
"Ci sono giorni piΓΉ produttivi?",
"Suggeriscimi come migliorare",
],
'trend_analysis' => [
"Ci sono anomalie nei dati?",
"Prevedi la produzione di domani",
"Esporta questi dati",
],
];
return $map[$intent] ?? [
"Mostrami le statistiche",
"Analizza il periodo corrente",
"Come posso ottimizzare?",
];
}
}
`Integrazione Vue Component
`vue
<!-- Dopo ogni messaggio AI, mostra suggestions -->
<div v-if="message.role === 'assistant' && message.suggestions" class="follow-up-suggestions mt-2">
<small class="text-muted d-block mb-1">Potresti chiedere:</small>
<button
v-for="suggestion in message.suggestions"
:key="suggestion"
class="btn btn-xs btn-outline-secondary me-1 mb-1"
@click="sendMessage(suggestion)"
>
{{ suggestion }}
</button>
</div>
`5.5 Alert Automation
Tabella:
ai_alerts`sql
CREATE TABLE ai_alerts (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
machine_id BIGINT UNSIGNED NULL,
alert_type ENUM('threshold', 'anomaly', 'prediction') NOT NULL,
condition_config JSON NOT NULL, -- { field: 'TotalPieces', operator: '<', value: 1000, period: 'daily' }
notification_channels JSON, -- ['email', 'slack', 'in_app']
is_active BOOLEAN DEFAULT TRUE,
last_triggered_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_active (is_active, machine_id)
);
`Service: Alert Monitoring
`php
// app/Services/I40/AlertMonitoringService.php
namespace App\Services\I40;class AlertMonitoringService
{
/
* Controlla tutti gli alert attivi (eseguito ogni ora via Scheduler)
*/
public function checkAlerts(): void
{
$alerts = \DB::table('ai_alerts')
->where('is_active', true)
->get();
foreach ($alerts as $alert) {
$this->evaluateAlert($alert);
}
}
/
* Valuta singolo alert
*/
protected function evaluateAlert(object $alert): void
{
$condition = json_decode($alert->condition_config, true);
$result = $this->executeCondition($condition, $alert->machine_id);
if ($result['triggered']) {
$this->triggerAlert($alert, $result);
}
}
/
* Esegue condizione alert
*/
protected function executeCondition(array $condition, ?int $machineId): array
{
$field = $condition['field'];
$operator = $condition['operator']; // <, >, =, !=
$threshold = $condition['value'];
$period = $condition['period'] ?? 'daily'; // daily, weekly, monthly
// Calcola date range
$dateFrom = match($period) {
'daily' => now()->startOfDay(),
'weekly' => now()->startOfWeek(),
'monthly' => now()->startOfMonth(),
};
// Query valore attuale
$query = MachineOperationLog::query();
if ($machineId) {
$query->where('machine_id', $machineId);
}
$value = $query
->whereBetween('timestamp', [$dateFrom, now()])
->selectRaw("SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data, '$.{$field}')) AS DECIMAL(15,2))) as total")
->value('total') ?? 0;
// Valuta condizione
$triggered = match($operator) {
'<' => $value < $threshold,
'>' => $value > $threshold,
'=' => $value == $threshold,
'!=' => $value != $threshold,
default => false,
};
return [
'triggered' => $triggered,
'current_value' => $value,
'threshold' => $threshold,
'field' => $field,
];
}
/
* Trigger notifica alert
*/
protected function triggerAlert(object $alert, array $result): void
{
// Aggiorna last_triggered_at
\DB::table('ai_alerts')
->where('id', $alert->id)
->update(['last_triggered_at' => now()]);
// Invia notifiche
$channels = json_decode($alert->notification_channels, true) ?? ['in_app'];
foreach ($channels as $channel) {
$this->sendNotification($channel, $alert, $result);
}
}
/
* Invia notifica su canale specifico
*/
protected function sendNotification(string $channel, object $alert, array $result): void
{
$message = sprintf(
"β οΈ Alert: %s Γ¨ %s (soglia: %s, valore attuale: %s)",
$result['field'],
$alert->alert_type,
number_format($result['threshold']),
number_format($result['current_value'])
);
if ($channel === 'email') {
$user = User::find($alert->user_id);
\Mail::to($user)->send(new AlertTriggeredMail($message));
} elseif ($channel === 'slack') {
// Slack webhook
\Http::post(config('services.slack.webhook_url'), [
'text' => $message,
]);
} elseif ($channel === 'in_app') {
// Laravel notification system
$user = User::find($alert->user_id);
$user->notify(new AlertTriggered($message));
}
}
}
`Scheduler (Cron)
`php
// app/Console/Kernel.php
protected function schedule(Schedule $schedule)
{
// Controlla alert ogni ora
$schedule->call(function () {
app(AlertMonitoringService::class)->checkAlerts();
})->hourly();
}
`---
π¬ Esempi Query AI
Categoria: Produzione Totale
Esempio 1
Query Utente: "Qual Γ¨ stata la produzione di ieri?"Intent:
production_total
EntitΓ : period=yesterdaySQL Generato:
`sql
SELECT
SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data, '$.TotalPieces')) AS DECIMAL(15,2))) as total
FROM machine_operations_log
WHERE machine_id = 5
AND DATE(timestamp) = CURDATE() - INTERVAL 1 DAY
`Risposta AI:
> "Ieri hai prodotto 1.850 pezzi, leggermente superiore alla media giornaliera di 1.750 pezzi. Ottimo lavoro! π"
Follow-up Suggestions:
---
Esempio 2
Query Utente: "Confronta la produzione di questa settimana con la scorsa"Intent:
production_comparison
EntitΓ : period=this_week, comparison=last_weekSQL Generato:
`sql
SELECT
WEEK(timestamp) as week_num,
SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data, '$.TotalPieces')) AS DECIMAL(15,2))) as total
FROM machine_operations_log
WHERE machine_id = 5
AND timestamp >= DATE_SUB(NOW(), INTERVAL 2 WEEK)
GROUP BY week_num
ORDER BY week_num
`Risposta AI:
> "Settimana corrente: 8.400 pezzi
> Settimana scorsa: 9.100 pezzi
>
> La produzione Γ¨ diminuita del 7,7% (-700 pezzi). Possibili cause:
> - Lunedì è stato festivo
> - Martedì c'è stato un fermo macchina di 2 ore
>
> Vuoi analizzare i fermi macchina in dettaglio?"
Chart Suggested: Line chart (giorni Γ pezzi prodotti, 2 settimane)
---
Categoria: Performance Operatori
Esempio 3
Query Utente: "Chi Γ¨ l'operatore piΓΉ produttivo questo mese?"Intent:
operator_performance
EntitΓ : period=this_month, metric=productivitySQL Generato:
`sql
SELECT
JSON_UNQUOTE(JSON_EXTRACT(raw_data, '$.MarkerName')) as operator,
SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data, '$.TotalPieces')) AS DECIMAL(15,2))) as total_pieces,
COUNT(*) as operations,
AVG(CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data, '$.CutTime')) AS DECIMAL(15,2))) as avg_time
FROM machine_operations_log
WHERE machine_id = 5
AND timestamp >= DATE_FORMAT(NOW(), '%Y-%m-01')
GROUP BY operator
ORDER BY total_pieces DESC
LIMIT 5
`Risposta AI:
> "π Top 5 Operatori del Mese:
>
> 1. Giovanni Rossi - 12.500 pezzi (45 operazioni, 2.5h media)
> 2. Marco Bianchi - 11.800 pezzi (42 operazioni, 2.8h media)
> 3. Luca Verdi - 10.200 pezzi (38 operazioni, 3.1h media)
> 4. Paolo Neri - 9.500 pezzi (35 operazioni, 3.0h media)
> 5. Anna Gialli - 8.900 pezzi (33 operazioni, 3.2h media)
>
> Giovanni Rossi Γ¨ il piΓΉ produttivo sia per volume totale che per velocitΓ media. Complimenti! π"
Chart Suggested: Bar chart (operatori Γ pezzi totali)
---
Categoria: Analisi Temporale
Esempio 4
Query Utente: "Mostrami il trend di produzione dell'ultimo mese"Intent:
trend_analysis
EntitΓ : period=last_month, visualization=trendSQL Generato:
`sql
SELECT
DATE(timestamp) as date,
SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data, '$.TotalPieces')) AS DECIMAL(15,2))) as daily_total
FROM machine_operations_log
WHERE machine_id = 5
AND timestamp >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY date
ORDER BY date
`Risposta AI:
> "π Trend Produzione - Ultimi 30 Giorni
>
> - Media giornaliera: 1.750 pezzi
> - Picco massimo: 2.300 pezzi (15 ottobre)
> - Minimo: 1.200 pezzi (7 ottobre - sabato)
> - Trend generale: In crescita del +5% rispetto ai 30 giorni precedenti
>
> Osservazioni:
> - I lunedì sono mediamente -10% rispetto agli altri giorni
> - I giovedì hanno il picco settimanale (+15%)
>
> Vuoi approfondire i giovedì per capire il motivo del picco?"
Chart Suggested: Line chart (giorno Γ pezzi prodotti, 30 giorni)
---
Categoria: Anomalie
Esempio 5
Query Utente: "Perché ieri abbiamo prodotto così poco?"Intent:
anomaly_detection
EntitΓ : period=yesterday, issue=low_production</p><p><strong>SQL Generato:</strong>
</code>`<code>sql
-- Query 1: Produzione ieri
SELECT
SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data, '$.TotalPieces')) AS DECIMAL(15,2))) as total,
COUNT(*) as operations,
MIN(timestamp) as first_op,
MAX(timestamp) as last_op
FROM machine_operations_log
WHERE machine_id = 5
AND DATE(timestamp) = CURDATE() - INTERVAL 1 DAY</p><p>-- Query 2: Media ultimi 30 giorni (escl. weekend)
SELECT AVG(daily_total) as avg_production
FROM (
SELECT DATE(timestamp) as date, SUM(...) as daily_total
FROM machine_operations_log
WHERE machine_id = 5
AND timestamp >= DATE_SUB(NOW(), INTERVAL 30 DAY)
AND DAYOFWEEK(timestamp) NOT IN (1, 7)
GROUP BY date
) as daily_stats
</code>`<code></p><p><strong>Risposta AI:</strong>
> "π <strong>Analisi Produzione Ieri (18 Ottobre)</strong>
>
> Ieri hai prodotto solo <strong>1.200 pezzi</strong>, il <strong>31% in meno</strong> rispetto alla media di 1.750 pezzi.
>
> <strong>Cause identificate:</strong>
> 1. β° <strong>Orario ridotto:</strong> Prima operazione alle 10:30 invece che 8:00 (ritardo 2.5h)
> 2. β οΈ <strong>Fermo macchina:</strong> Dalle 14:15 alle 16:00 nessuna operazione registrata
> 3. π€ <strong>Operatore nuovo:</strong> Solo 3 operazioni da parte di "Stagista Mario" (vs 8-10 normali)
>
> <strong>Raccomandazione:</strong> Se il fermo Γ¨ stato per manutenzione, Γ¨ normale. Altrimenti, verifica il motivo del ritardo iniziale."</p><p><strong>Follow-up Suggestions:</strong>
<li>"Analizza i fermi macchina del mese"</li>
<li>"Mostrami le performance degli stagisti"</li>
<li>"Confronta con lo stesso giorno del mese scorso"</li></p><p>---</p><p><h3>Categoria: Previsioni</h3></p><p><h4>Esempio 6</h4>
<strong>Query Utente:</strong> "Riusciremo a completare 5000 pezzi entro venerdì?"</p><p><strong>Intent:</strong>
EntitΓ : target=5000, deadline=friday, unit=piecesSQL Generato:
`sql
-- Query 1: Pezzi giΓ prodotti questa settimana
SELECT
SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data, '$.TotalPieces')) AS DECIMAL(15,2))) as current_total
FROM machine_operations_log
WHERE machine_id = 5
AND timestamp >= DATE_SUB(NOW(), INTERVAL WEEKDAY(NOW()) DAY)-- Query 2: Media produzione giornaliera (ultimi 30 giorni)
SELECT AVG(daily_total) as avg_daily
FROM (
SELECT DATE(timestamp), SUM(...) as daily_total
FROM machine_operations_log
WHERE machine_id = 5
AND timestamp >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY DATE(timestamp)
) as stats
`</p><p><strong>Risposta AI:</strong> > "π <strong>Previsione Completamento Ordine</strong> > > <strong>Situazione attuale (MercoledΓ¬):</strong> > - Prodotti finora: 2.800 pezzi > - Mancanti: 2.200 pezzi > - Giorni rimanenti: 2 (GiovedΓ¬ + VenerdΓ¬) > > <strong>Proiezione:</strong> > - Media giornaliera: 1.750 pezzi/giorno > - Produzione attesa: 1.750 Γ 2 = <strong>3.500 pezzi</strong> > > β <strong>SΓ¬, ce la farete!</strong> Con un margine di +1.300 pezzi (26%). > > <strong>Scenario worst-case (se produzione -20%):</strong> > - 1.400 Γ 2 = 2.800 pezzi > - Mancherebbero ancora 400 pezzi β Servirebbe qualche ora sabato mattina > > Vuoi impostare un alert per monitorare l'avanzamento?"</p><p>---</p><p><h3>Categoria: Ottimizzazione</h3></p><p><h4>Esempio 7</h4> <strong>Query Utente:</strong> "Come posso aumentare la produttivitΓ del 10%?"</p><p><strong>Intent:</strong>
EntitΓ : target=+10%, metric=productivity</p><p><strong>SQL Generato:</strong>
</code>`<code>sql
-- Analisi pattern produttivi
SELECT
DAYNAME(timestamp) as day_of_week,
HOUR(timestamp) as hour_of_day,
JSON_UNQUOTE(JSON_EXTRACT(raw_data, '$.MarkerName')) as operator,
AVG(CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data, '$.TotalPieces')) AS DECIMAL(15,2)) /
CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data, '$.CutTime')) AS DECIMAL(15,2))) as pieces_per_hour
FROM machine_operations_log
WHERE machine_id = 5
AND timestamp >= DATE_SUB(NOW(), INTERVAL 3 MONTH)
GROUP BY day_of_week, hour_of_day, operator
HAVING pieces_per_hour > 0
ORDER BY pieces_per_hour DESC
LIMIT 20
</code>`<code></p><p><strong>Risposta AI:</strong>
> "π― <strong>Raccomandazioni per +10% ProduttivitΓ </strong>
>
> Basandomi sui dati degli ultimi 3 mesi, ho identificato <strong>5 opportunitΓ </strong>:
>
> <strong>1. Ottimizza Orari (Impatto: +5%)</strong>
> - π
Le operazioni tra 8:00-10:00 sono <strong>18% piΓΉ veloci</strong>
> - π Le operazioni dopo le 17:00 sono <strong>12% piΓΉ lente</strong>
> - <strong>Azione:</strong> Concentra lavorazioni complesse al mattino
>
> <strong>2. Best Practices da Giovanni Rossi (Impatto: +8%)</strong>
> - Sua resa: <strong>450 pezzi/ora</strong> vs media 380 pezzi/ora
> - Differenza chiave: Setup piΓΉ rapido (15 min vs 25 min)
> - <strong>Azione:</strong> Organizza training con Giovanni per altri operatori
>
> <strong>3. Riduci Fermi Macchina (Impatto: +4%)</strong>
> - Attualmente: <strong>45 minuti/giorno</strong> di fermi non pianificati
> - Causa principale: Cambio materiale (65% dei fermi)
> - <strong>Azione:</strong> Pre-staging materiali vicino alla macchina
>
> <strong>4. Giovedì come Riferimento (Impatto: +6%)</strong>
> - Giovedì è il giorno più produttivo (+15% vs media)
> - Motivo: Meno interruzioni, focus continuativo
> - <strong>Azione:</strong> Replica modello giovedì anche altri giorni
>
> <strong>5. Manutenzione Preventiva (Impatto: +3%)</strong>
> - I giorni dopo manutenzione hanno +12% velocitΓ
> - Attualmente: Manutenzione ogni 3 settimane
> - <strong>Azione:</strong> Passa a manutenzione settimanale leggera
>
> π <strong>Totale potenziale:</strong> +26% (ben oltre il target del 10%!)
>
> Vuoi un piano d'azione dettagliato per implementare questi suggerimenti?"</p><p>---</p><p><h2>π° Costi e Considerazioni</h2></p><p><h3>Stima Costi OpenAI API</h3></p><p><h4>Modello Pricing (Ottobre 2025)</h4></p><p>| Modello | Input (1M tokens) | Output (1M tokens) |
|---------|-------------------|-------------------|
| GPT-4 Turbo | $10.00 | $30.00 |
| GPT-4 | $30.00 | $60.00 |
| GPT-3.5 Turbo | $0.50 | $1.50 |</p><p><h4>Stima Conversazione Tipica</h4></p><p><strong>Scenario:</strong> Utente chiede "Qual Γ¨ stata la produzione di ieri?"</p><p><li><strong>Input tokens:</strong> </li>
- System prompt (schema + context): ~800 tokens
- User message: ~15 tokens
- <strong>Totale input:</strong> ~815 tokens</p><p><li><strong>Output tokens:</strong></li>
- Function call (SQL): ~100 tokens
- Risposta formattata: ~150 tokens
- <strong>Totale output:</strong> ~250 tokens</p><p><strong>Costo per conversazione:</strong>
<li>Input: 815 tokens Γ $10 / 1M = <strong>$0.00815</strong></li>
<li>Output: 250 tokens Γ $30 / 1M = <strong>$0.0075</strong></li>
<li><strong>Totale: ~$0.016 per conversazione</strong></li></p><p><h4>Proiezione Mensile</h4></p><p><strong>Scenario Conservativo:</strong>
<li>3 macchine monitorate</li>
<li>5 utenti attivi</li>
<li>20 conversazioni/utente/mese</li>
<li>Media 3 messaggi per conversazione</li></p><p><strong>Calcolo:</strong>
<li>Totale conversazioni: 5 utenti Γ 20 = <strong>100 conversazioni/mese</strong></li>
<li>Totale messaggi: 100 Γ 3 = <strong>300 messaggi/mese</strong></li>
<li>Costo: 300 Γ $0.016 = <strong>$4.80/mese</strong></li></p><p><strong>Con cache (riduzione 40%):</strong>
<li>Costo effettivo: <strong>~$3/mese</strong></li></p><p><h4>Scenario Intensivo (10 macchine, 20 utenti)</h4></p><p><li>Conversazioni: 20 Γ 30 = <strong>600/mese</strong></li>
<li>Messaggi: 600 Γ 4 = <strong>2.400/mese</strong></li>
<li>Costo: 2.400 Γ $0.016 = <strong>$38.40/mese</strong></li>
<li><strong>Con cache: ~$23/mese</strong></li></p><p><h3>ROI Analysis</h3></p><p><h4>Benefici Quantificabili</h4></p><p><strong>1. Risparmio Tempo Analisi Dati</strong>
<li>Tempo medio analisi manuale: 30 minuti/query</li>
<li>Con AI: 2 minuti/query</li>
<li>Risparmio: 28 minuti Γ costo orario operatore</li></p><p><strong>Esempio:</strong>
<li>50 query/mese</li>
<li>Costo operatore: β¬25/ora</li>
<li>Risparmio: 50 Γ (28/60) Γ β¬25 = <strong>β¬583/mese</strong></li></p><p><strong>2. Decisioni PiΓΉ Rapide</strong>
<li>Identificazione problemi in real-time vs fine giornata</li>
<li>Impatto: Riduzione downtime macchina</li>
<li>Stima conservativa: 2 ore/mese di downtime evitato</li>
<li>Valore: β¬200-500/ora (costo downtime produzione)</li>
<li><strong>Risparmio: β¬400-1.000/mese</strong></li></p><p><strong>3. Ottimizzazione Produzione</strong>
<li>Anche solo +2% efficienza da suggerimenti AI</li>
<li>Su produzione β¬100.000/mese</li>
<li><strong>Valore: β¬2.000/mese</strong></li></p><p><strong>ROI Totale:</strong>
<li>Benefici: β¬583 + β¬700 + β¬2.000 = <strong>β¬3.283/mese</strong></li>
<li>Costi: ~β¬23/mese (API) + ~β¬100/mese (sviluppo/manutenzione)</li>
<li><strong>ROI: β¬3.283 - β¬123 = β¬3.160/mese netto</strong></li>
<li><strong>Payback: < 2 settimane</strong></li></p><p><h3>Privacy e GDPR</h3></p><p><h4>Considerazioni</h4></p><p><strong>Dati Inviati a OpenAI:</strong>
<li>Schema database (nomi colonne, tipi)</li>
<li>Sample records (max 5, anonimizzati)</li>
<li>Query utente</li>
<li><strong>NON inviati:</strong> Dati personali sensibili, informazioni clienti</li></p><p><strong>Compliance:</strong>
<li>β
OpenAI Γ¨ GDPR-compliant</li>
<li>β
Dati non usati per training (se API aziendale)</li>
<li>β
Log conversazioni su server proprietario</li></p><p><strong>Best Practices:</strong>
1. Anonimizza sample data prima di invio (rimuovi nomi clienti, riferimenti ordini)
2. Usa Azure OpenAI se serve data residency EU
3. Cripta conversazioni nel database
4. Implementa data retention policy (es. cancella dopo 90 giorni)</p><p><h4>Azure OpenAI (Alternativa Enterprise)</h4></p><p><strong>Vantaggi:</strong>
<li>Data residency EU</li>
<li>SLA garantito</li>
<li>Nessun utilizzo dati per training</li>
<li>Support aziendale</li></p><p><strong>Costi:</strong>
<li>Simili a OpenAI standard</li>
<li>Minimum commitment: $100/mese</li></p><p>---</p><p><h2>π§ͺ Testing & Quality Assurance</h2></p><p><h3>Unit Tests</h3></p><p><h4>Test: Query Understanding Service</h4></p><p>php
// tests/Unit/QueryUnderstandingServiceTest.php
namespace Tests\Unit\Services\I40;use Tests\TestCase;
use App\Services\I40\QueryUnderstandingService;
class QueryUnderstandingServiceTest extends TestCase
{
protected $service;
protected function setUp(): void
{
parent::setUp();
$this->service = app(QueryUnderstandingService::class);
}
/ @test */
public function it_detects_production_total_intent()
{
$result = $this->service->analyze("Qual Γ¨ stata la produzione di ieri?");
$this->assertEquals('production_total', $result['intent']);
$this->assertEquals('yesterday', $result['entities']['period']);
$this->assertGreaterThan(0.7, $result['confidence']);
}
/ @test */
public function it_extracts_date_entities()
{
$result = $this->service->analyze("Mostrami la produzione di questa settimana");
$this->assertEquals('this_week', $result['entities']['period']);
$this->assertNotNull($result['entities']['date_from']);
$this->assertNotNull($result['entities']['date_to']);
}
/ @test */
public function it_handles_comparison_queries()
{
$result = $this->service->analyze("Confronta con la settimana scorsa");
$this->assertEquals('production_comparison', $result['intent']);
}
}
`Test: OpenAI Service (Mocked)
`php
// tests/Unit/OpenAiServiceTest.php
namespace Tests\Unit\Services\I40;use Tests\TestCase;
use App\Services\I40\OpenAiService;
use OpenAI\Laravel\Facades\OpenAI;
class OpenAiServiceTest extends TestCase
{
/ @test */
public function it_generates_sql_query_via_function_calling()
{
// Mock OpenAI response
OpenAI::fake([
OpenAI::response([
'choices' => [
[
'message' => [
'function_call' => [
'name' => 'execute_sql_query',
'arguments' => json_encode([
'sql' => 'SELECT COUNT(*) FROM machine_operations_log WHERE machine_id=5',
'explanation' => 'Conteggio operazioni macchina 5'
]),
],
],
'finish_reason' => 'function_call',
],
],
'usage' => ['total_tokens' => 500],
]),
]);
$service = app(OpenAiService::class);
$context = ['schema' => 'test schema', 'statistics' => '', 'sample_data' => '', 'business_rules' => ''];
$response = $service->generateResponse($context, "Quante operazioni ha fatto la macchina?");
$this->assertArrayHasKey('metadata', $response);
$this->assertArrayHasKey('sql', $response['metadata']);
$this->assertStringContainsString('SELECT', $response['metadata']['sql']);
}
/ @test */
public function it_prevents_dangerous_sql()
{
$service = app(OpenAiService::class);
$this->expectException(\Exception::class);
$this->expectExceptionMessage('Query non permessa');
// Tenta di eseguire SQL pericoloso
$reflection = new \ReflectionClass($service);
$method = $reflection->getMethod('executeSafeSql');
$method->setAccessible(true);
$method->invoke($service, "DROP TABLE users");
}
}
`Integration Tests
Test: AI Chat Flow Completo
`php
// tests/Feature/AiChatFlowTest.php
namespace Tests\Feature;use Tests\TestCase;
use App\Models\User;
use App\Models\I40\Machine;
use Illuminate\Foundation\Testing\RefreshDatabase;
class AiChatFlowTest extends TestCase
{
use RefreshDatabase;
/ @test */
public function user_can_start_conversation_with_ai()
{
$user = User::factory()->create();
$machine = Machine::factory()->create();
$this->actingAs($user);
$response = $this->postJson('/admin/i40/ai/chat', [
'machine_id' => $machine->id,
'message' => 'Qual Γ¨ stata la produzione di ieri?',
]);
$response->assertStatus(200)
->assertJsonStructure([
'success',
'conversation_id',
'message' => ['id', 'role', 'content', 'created_at'],
]);
$this->assertDatabaseHas('ai_conversations', [
'user_id' => $user->id,
'machine_id' => $machine->id,
]);
$this->assertDatabaseHas('ai_messages', [
'role' => 'user',
'content' => 'Qual Γ¨ stata la produzione di ieri?',
]);
}
/ @test */
public function user_can_provide_feedback_on_ai_response()
{
$user = User::factory()->create();
$message = AiMessage::factory()->create(['role' => 'assistant']);
$this->actingAs($user);
$response = $this->postJson('/admin/i40/ai/feedback', [
'message_id' => $message->id,
'rating' => 'up',
'comment' => 'Molto utile!',
]);
$response->assertStatus(200);
$this->assertDatabaseHas('ai_feedback', [
'message_id' => $message->id,
'user_id' => $user->id,
'rating' => 'up',
]);
}
}
`Performance Tests
`php
// tests/Performance/AiResponseTimeTest.php
namespace Tests\Performance;use Tests\TestCase;
class AiResponseTimeTest extends TestCase
{
/ @test */
public function ai_response_time_is_under_5_seconds()
{
$start = microtime(true);
// Esegui query AI
$this->postJson('/admin/i40/ai/chat', [
'machine_id' => 1,
'message' => 'Test query',
]);
$duration = microtime(true) - $start;
$this->assertLessThan(5.0, $duration, "Response time was {$duration}s");
}
}
`---
π Monitoring & Analytics
Metriche da Tracciare
1. Usage Metrics
`php
// Dashboard Analytics Service
class AiAnalyticsService
{
public function getDashboardMetrics(Carbon $from, Carbon $to): array
{
return [
// Utilizzo
'total_conversations' => AiConversation::whereBetween('created_at', [$from, $to])->count(),
'total_messages' => AiMessage::whereBetween('created_at', [$from, $to])->count(),
'active_users' => AiConversation::whereBetween('created_at', [$from, $to])
->distinct('user_id')->count(),
// Performance
'avg_response_time' => AiMessage::where('role', 'assistant')
->whereBetween('created_at', [$from, $to])
->avg('response_time_ms'),
// Quality
'positive_feedback' => AiFeedback::where('rating', 'up')
->whereBetween('created_at', [$from, $to])->count(),
'negative_feedback' => AiFeedback::where('rating', 'down')
->whereBetween('created_at', [$from, $to])->count(),
'satisfaction_rate' => $this->calculateSatisfactionRate($from, $to),
// Costi
'total_tokens' => AiMessage::whereBetween('created_at', [$from, $to])
->sum('tokens_used'),
'estimated_cost' => $this->estimateCost($from, $to),
// Cache
'cache_hit_rate' => $this->calculateCacheHitRate($from, $to),
];
}
protected function calculateSatisfactionRate(Carbon $from, Carbon $to): float
{
$positive = AiFeedback::where('rating', 'up')
->whereBetween('created_at', [$from, $to])->count();
$negative = AiFeedback::where('rating', 'down')
->whereBetween('created_at', [$from, $to])->count();
$total = $positive + $negative;
return $total > 0 ? ($positive / $total) * 100 : 0;
}
}
`2. Quality Monitoring
Logging Eventi Critici:
`php
// Event: Low Confidence Response
if ($analysis['confidence'] < 0.5) {
\Log::warning('AI Low Confidence Response', [
'conversation_id' => $conversation->id,
'query' => $userMessage,
'confidence' => $analysis['confidence'],
]);
}// Event: SQL Execution Error
\Log::error('AI SQL Execution Failed', [
'conversation_id' => $conversation->id,
'sql' => $sql,
'error' => $exception->getMessage(),
]);
// Event: High Token Usage
if ($tokensUsed > 2000) {
\Log::info('AI High Token Usage', [
'conversation_id' => $conversation->id,
'tokens' => $tokensUsed,
]);
}
`Dashboard Admin (Blade View)
`blade
<!-- resources/views/admin/i40/ai/dashboard.blade.php -->
<div class="container-fluid">
<h3>AI Assistant Analytics</h3>
<div class="row">
<!-- Usage Stats -->
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5>{{ $metrics['total_conversations'] }}</h5>
<small>Conversazioni (30gg)</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5>{{ $metrics['active_users'] }}</h5>
<small>Utenti Attivi</small>
</div>
</div>
</div>
<!-- Quality Stats -->
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5>{{ number_format($metrics['satisfaction_rate'], 1) }}%</h5>
<small>Soddisfazione</small>
</div>
</div>
</div>
<!-- Cost Stats -->
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5>${{ number_format($metrics['estimated_cost'], 2) }}</h5>
<small>Costo Stimato</small>
</div>
</div>
</div>
</div>
<!-- Query Performance Chart -->
<div class="card mt-4">
<div class="card-body">
<h5>Query piΓΉ Frequenti</h5>
<canvas id="topQueriesChart"></canvas>
</div>
</div>
</div>
`---
π Continuous Improvement
Fine-Tuning Strategy
1. Raccolta Dati per Training
`php
// Salva query + risposta + feedback per futuro fine-tuning
class FineTuningDataCollector
{
public function collect(AiMessage $userMessage, AiMessage $aiResponse, ?AiFeedback $feedback)
{
if ($feedback && $feedback->rating === 'up') {
// Salva coppia query-risposta positiva
\Storage::append('ai-training-data.jsonl', json_encode([
'messages' => [
['role' => 'user', 'content' => $userMessage->content],
['role' => 'assistant', 'content' => $aiResponse->content],
],
'metadata' => [
'confidence' => $aiResponse->metadata['confidence'] ?? null,
'feedback' => 'positive',
],
]));
}
}
}
`2. Periodic Review
Scheduler mensile:
`php
protected function schedule(Schedule $schedule)
{
// Review AI performance mensile
$schedule->call(function () {
$analytics = app(AiAnalyticsService::class);
$report = $analytics->generateMonthlyReport();
// Invia report a admin
\Mail::to(config('ai.admin_email'))
->send(new MonthlyAiReportMail($report));
})->monthly();
}
`---
π Training & Documentation
User Guide (Per Operatori)
`markdown <h1>Guida AI Assistant - Analisi Produzione</h1></p><p><h2>Come Fare Domande Efficaci</h2></p><p><h3>β Buone Domande</h3> <li>"Qual Γ¨ stata la produzione di ieri?"</li> <li>"Confronta questa settimana con la scorsa"</li> <li>"Chi Γ¨ l'operatore piΓΉ veloce?"</li> <li>"Mostrami il trend dell'ultimo mese"</li></p><p><h3>β Domande Poco Chiare</h3> <li>"Dimmi qualcosa" (troppo vaga)</li> <li>"Ieri" (manca il contesto: produzione? tempo? operatore?)</li> <li>"PerchΓ©?" (senza riferimento a cosa)</li></p><p><h2>Esempi Pratici</h2></p><p><h3>Scenario 1: Controllo Giornaliero</h3> <strong>Query:</strong> "Qual Γ¨ stata la produzione di oggi?" β L'AI ti dirΓ il totale pezzi prodotti oggi e lo confronterΓ con la media.</p><p><h3>Scenario 2: Identificare Problemi</h3> <strong>Query:</strong> "PerchΓ© oggi abbiamo prodotto meno?" β L'AI analizzerΓ fermi macchina, ritardi, operatori e ti darΓ le cause.</p><p><h3>Scenario 3: Pianificazione</h3> <strong>Query:</strong> "Ce la facciamo a finire 3000 pezzi entro domani?" β L'AI calcolerΓ la proiezione basata sul ritmo attuale.</p><p><h2>Tips & Tricks</h2> <li>π‘ Usa il microfono per query vocali veloci</li> <li>π Dai feedback (pollice su/giΓΉ) per migliorare le risposte</li> <li>π Chiedi sempre "Mostrami un grafico" per visualizzare i dati</li> <li>π Usa i suggerimenti automatici per approfondire</li>
---
β
Checklist Implementazione
Fase 1: Foundation β
.env con API key OpenAI
Fase 2: Knowledge Base β
DatabaseSchemaService
AiContextService
QueryUnderstandingService
Fase 3: AI Integration β
openai-php/laravel
OpenAiService
AiChatController
Fase 4: UI/UX β
AiChatWidget
analyze.blade.php
Fase 5: Advanced Features (Opzionale)
AiCacheService
Testing & QA
Deployment
---
π Risorse e Riferimenti
Documentazione Tecnica
Best Practices AI
Community & Support
openai-api, </code>laravel<code></li></p><p>---</p><p><h2>π― Next Steps</h2></p><p><h3>Settimana 1-2: Kickoff</h3> 1. β Review roadmap con team 2. β Setup ambiente sviluppo 3. β Ottenere API key OpenAI 4. β Creare database tables</p><p><h3>Settimana 3-4: Core Development</h3> 1. β Implementare Knowledge Base services 2. β Testare estrazione schema 3. β Prime integrazioni OpenAI</p><p><h3>Settimana 5-6: AI Integration</h3> 1. β Controller completo 2. β SQL execution sicura 3. β Testing query reali 4. β Ottimizzazione prompt</p><p><h3>Settimana 7: UI/UX</h3> 1. β Vue component 2. β Integrazione view 3. β Testing utente 4. β Refinement UX</p><p><h3>Settimana 8: Testing & Launch</h3> 1. β Testing completo 2. β Documentazione 3. β Training team 4. β Soft launch (utenti pilota) 5. β Feedback & iterate</p><p>---</p><p><h2>π Note Finali</h2></p><p><h3>PerchΓ© Questo Approccio?</h3></p><p>1. <strong>Graduale:</strong> Parti con MVP funzionale (Fasi 1-4), poi aggiungi features avanzate 2. <strong>Scalabile:</strong> Architettura pronta per crescere (piΓΉ macchine, piΓΉ utenti) 3. <strong>Cost-Effective:</strong> Cache e ottimizzazioni per minimizzare costi API 4. <strong>User-Centric:</strong> Focus su domande reali degli operatori, non su tech per il gusto della tech 5. <strong>Maintainable:</strong> Codice ben strutturato, testato, documentato</p><p><h3>Rischi e Mitigazioni</h3></p><p>| Rischio | ProbabilitΓ | Impatto | Mitigazione | |---------|-------------|---------|-------------| | Costi API alti | Media | Medio | Cache aggressiva, limiti utente | | Risposte imprecise | Alta (iniziale) | Alto | Feedback loop, fine-tuning | | Latency alta | Bassa | Medio | Cache, async processing | | Privacy concerns | Bassa | Alto | Azure OpenAI, data anonymization | | User adoption | Media | Alto | Training, UX intuitiva, quick wins |</p><p><h3>Success Metrics (KPI)</h3></p><p><strong>Mese 1-2 (Post-Launch):</strong> <li>[ ] 80% utenti target provano l'AI almeno 1 volta</li> <li>[ ] Satisfaction rate > 70%</li> <li>[ ] Avg response time < 5s</li> <li>[ ] 30% query risolte senza supporto umano</li></p><p><strong>Mese 3-6 (MaturitΓ ):</strong> <li>[ ] 60% utenti usano AI settimanalmente</li> <li>[ ] Satisfaction rate > 85%</li> <li>[ ] 50% query risolte autonomamente</li> <li>[ ] ROI positivo (risparmi > costi)</li></ul></p><p>---</p><p><strong>π Documento Completo Salvato In:</strong>
Ultima Revisione: 20 Ottobre 2025 Versione: 1.0 Final Autore: AI Agent + User Collaboration Status: β Production Ready
---
π Ready to Build!
Questo documento fornisce tutto il necessario per implementare un AI Assistant completo per l'analisi produzione. La roadmap Γ¨ strutturata per essere seguita passo-passo, con codice funzionante, best practices e considerazioni pratiche.
Prossimi Step Consigliati: 1. Review documento con team tecnico 2. Setup ambiente sviluppo (OpenAI API key) 3. Inizia con Fase 1 (Foundation) - 2 settimane 4. Procedi sequenzialmente attraverso le fasi 5. Itera basandosi su feedback reale utenti
Buona implementazione! π
Analisi Codice
Blocco 1
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β FRONTEND (Vue/React Component) β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Chat UI: β β
β β β’ Input utente (textarea + voice) β β
β β β’ Storico conversazione β β
β β β’ Suggerimenti query β β
β β β’ Visualizzazione grafici inline β β
β β β’ Export risposte (PDF/Excel) β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
ββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββ
β WebSocket / AJAX
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β BACKEND (Laravel API) β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β AI Controller: β β
β β β’ POST /ai/chat (nuova query) β β
β β β’ GET /ai/history (conversazioni passate) β β
β β β’ POST /ai/feedback (thumbs up/down) β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β AI Service Layer: β β
β β β’ Query Understanding (NLP) β β
β β β’ Context Builder (prepara dati per LLM) β β
β β β’ Response Generator (chiama LLM API) β β
β β β’ SQL Generator (converte linguaggio β query) β β
β β β’ Cache Layer (evita chiamate duplicate) β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
ββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββ
β
ββββββββββββββββββββΌβββββββββββββββββββ
β β β
ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ
β LLM API β β Vector DB β β MySQL DB β
β (OpenAI/ β β (Embeddings)β β (Raw Data) β
β Anthropic) β β - Pinecone β β - machine_ β
β β β - Weaviate β β operationsβ
β β β - Qdrant β β _log β
ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ
Blocco 2
1. UTENTE: "Qual Γ¨ stata la produzione di ieri?"
β
2. FRONTEND: Invia POST /ai/chat con { query: "...", machine_id: 5, context: {...} }
β
3. BACKEND (AI Service):
a) Query Understanding
β Estrae intent: "produzione_totale"
β Estrae entitΓ : periodo="ieri", metrica="pezzi"
β Estrae filtri: machine_id=5
b) Context Builder
β Recupera profilo macchina (colonne, aggregatori)
β Recupera ultimi 30 giorni di dati (per contesto)
β Prepara prompt per LLM:
"Sei un assistente AI per analisi produzione.
Database schema: [columns...]
Ultimi dati: [sample rows...]
Query utente: 'Qual Γ¨ stata la produzione di ieri?'
Genera SQL query e spiega il risultato."
c) LLM API Call
β Invia prompt a OpenAI GPT-4 / Claude
β Riceve risposta:
{
"sql": "SELECT SUM(JSON_EXTRACT(...)) FROM ... WHERE DATE(timestamp)=CURDATE()-1",
"explanation": "Ho calcolato la somma dei pezzi prodotti ieri...",
"confidence": 0.95
}
d) SQL Execution
β Valida SQL (whitelist operazioni, prevent injection)
β Esegue query su DB
β Ottiene risultato: 1500 pezzi
e) Response Generation
β LLM formatta risposta user-friendly:
"Ieri hai prodotto 1.500 pezzi, in linea con la media settimanale di 1.450."
β Genera grafico suggerito (se rilevante)
β Aggiunge follow-up suggestions
β
4. FRONTEND: Mostra risposta + grafico + suggestions
Blocco 3 php
// Laravel
composer require openai-php/laravel
// Vector DB (opzionale per ora)
// composer require pinecone/pinecone-php
Blocco 4
SETTIMANA 1-2: FASE 1 - Foundation
SETTIMANA 3-4: FASE 2 - Knowledge Base
SETTIMANA 5-6: FASE 3 - AI Integration
SETTIMANA 7: FASE 4 - UI/UX
SETTIMANA 8: FASE 5 - Testing & Refinement
Blocco 5 sql
CREATE TABLE ai_conversations (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
machine_id BIGINT UNSIGNED NULL, -- Contesto macchina (opzionale)
title VARCHAR(255), -- Auto-generato dal primo messaggio
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user (user_id),
INDEX idx_machine (machine_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
Blocco 6 sql
CREATE TABLE ai_messages (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
conversation_id BIGINT UNSIGNED NOT NULL,
role ENUM('user', 'assistant', 'system') NOT NULL,
content TEXT NOT NULL,
metadata JSON, -- { sql: "...", confidence: 0.95, execution_time: 1.2, chart_config: {...} }
tokens_used INT UNSIGNED, -- Per tracking costi
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_conversation (conversation_id),
FOREIGN KEY (conversation_id) REFERENCES ai_conversations(id) ON DELETE CASCADE
);
Blocco 7 sql
CREATE TABLE ai_feedback (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
message_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
rating ENUM('up', 'down') NOT NULL,
comment TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (message_id) REFERENCES ai_messages(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
Blocco 8 sql
CREATE TABLE ai_cache (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
query_hash VARCHAR(64) UNIQUE NOT NULL, -- MD5/SHA256 della query normalizzata
machine_id BIGINT UNSIGNED NULL,
date_range VARCHAR(50), -- "2025-10-01_2025-10-20"
response_data JSON NOT NULL,
hits INT UNSIGNED DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP,
INDEX idx_hash (query_hash),
INDEX idx_expires (expires_at)
);
Blocco 9 bash
php artisan make:migration create_ai_assistant_tables
Blocco 10 php
// database/migrations/*_create_ai_assistant_tables.php
public function up()
{
Schema::create('ai_conversations', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->foreignId('machine_id')->nullable()->constrained('machines_i40')->onDelete('set null');
$table->string('title')->nullable();
$table->timestamps();
$table->index('user_id');
$table->index('machine_id');
});
Schema::create('ai_messages', function (Blueprint $table) {
$table->id();
$table->foreignId('conversation_id')->constrained('ai_conversations')->onDelete('cascade');
$table->enum('role', ['user', 'assistant', 'system']);
$table->text('content');
$table->json('metadata')->nullable();
$table->unsignedInteger('tokens_used')->nullable();
$table->timestamp('created_at')->useCurrent();
$table->index('conversation_id');
});
Schema::create('ai_feedback', function (Blueprint $table) {
$table->id();
$table->foreignId('message_id')->constrained('ai_messages')->onDelete('cascade');
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->enum('rating', ['up', 'down']);
$table->text('comment')->nullable();
$table->timestamp('created_at')->useCurrent();
});
Schema::create('ai_cache', function (Blueprint $table) {
$table->id();
$table->string('query_hash', 64)->unique();
$table->foreignId('machine_id')->nullable()->constrained('machines_i40')->onDelete('cascade');
$table->string('date_range', 50)->nullable();
$table->json('response_data');
$table->unsignedInteger('hits')->default(0);
$table->timestamp('created_at')->useCurrent();
$table->timestamp('expires_at')->nullable();
$table->index('query_hash');
$table->index('expires_at');
});
}
Blocco 11 bash
php artisan make:model I40/AiConversation
php artisan make:model I40/AiMessage
php artisan make:model I40/AiFeedback
Blocco 12 php
// app/Models/I40/AiConversation.php
class AiConversation extends Model
{
protected $fillable = ['user_id', 'machine_id', 'title'];
public function messages()
{
return $this->hasMany(AiMessage::class, 'conversation_id');
}
public function user()
{
return $this->belongsTo(User::class);
}
public function machine()
{
return $this->belongsTo(Machine::class);
}
// Auto-genera titolo dal primo messaggio
public function generateTitle()
{
$firstMessage = $this->messages()->where('role', 'user')->first();
if ($firstMessage) {
$this->title = Str::limit($firstMessage->content, 50);
$this->save();
}
}
}
Blocco 13 php
// app/Models/I40/AiMessage.php
class AiMessage extends Model
{
protected $fillable = ['conversation_id', 'role', 'content', 'metadata', 'tokens_used'];
protected $casts = ['metadata' => 'array'];
public $timestamps = false;
public function conversation()
{
return $this->belongsTo(AiConversation::class, 'conversation_id');
}
public function feedback()
{
return $this->hasOne(AiFeedback::class, 'message_id');
}
}
Blocco 14 bash
# .env aggiungi
OPENAI_API_KEY=sk-proj-...
OPENAI_MODEL=gpt-4-turbo-preview
OPENAI_MAX_TOKENS=1000
AI_CACHE_TTL=3600 # 1 ora
AI_ENABLED=true
Blocco 15 php
// config/ai.php
return [
'enabled' => env('AI_ENABLED', true),
'openai' => [
'api_key' => env('OPENAI_API_KEY'),
'model' => env('OPENAI_MODEL', 'gpt-4-turbo-preview'),
'max_tokens' => env('OPENAI_MAX_TOKENS', 1000),
'temperature' => 0.3, // Basso per risposte deterministiche
],
'cache' => [
'enabled' => true,
'ttl' => env('AI_CACHE_TTL', 3600), // secondi
],
'limits' => [
'max_messages_per_conversation' => 50,
'max_conversations_per_user' => 100,
'max_query_length' => 500, // caratteri
],
];
Blocco 16 php
// app/Services/I40/DatabaseSchemaService.php
namespace App\Services\I40;
class DatabaseSchemaService
{
/**
* Estrae schema leggibile per LLM dal profilo macchina
*/
public function getSchemaForMachine(Machine $machine): array
{
$profile = $machine->getActiveProfile();
if (!$profile) return [];
$columns = $profile->columns ?? [];
$columnNames = $profile->column_names ?? [];
$schema = [
'table' => 'machine_operations_log',
'description' => "Log operazioni macchina: {$machine->name}",
'columns' => []
];
// Colonne standard
$schema['columns'][] = [
'name' => 'id',
'type' => 'integer',
'description' => 'ID univoco operazione'
];
$schema['columns'][] = [
'name' => 'timestamp',
'type' => 'datetime',
'description' => 'Data e ora operazione',
'is_primary_timestamp' => true
];
// Colonne dinamiche da profilo
foreach ($columns as $index => $col) {
$csvName = $col['csv_name'] ?? $col['name'];
$label = $columnNames[$index] ?? $col['label'] ?? $csvName;
$schema['columns'][] = [
'name' => $csvName,
'type' => $col['type'] ?? 'string',
'description' => $label,
'is_filterable' => $col['is_filterable'] ?? false,
'is_aggregatable' => $col['is_aggregatable'] ?? false,
'aggregators' => $col['aggregators'] ?? [],
'sample_values' => $this->getSampleValues($machine->id, $csvName, 3)
];
}
return $schema;
}
/**
* Ottiene valori di esempio per una colonna
*/
protected function getSampleValues(int $machineId, string $columnName, int $limit = 3): array
{
return \DB::table('machine_operations_log')
->where('machine_id', $machineId)
->selectRaw("DISTINCT JSON_UNQUOTE(JSON_EXTRACT(raw_data, '$.{$columnName}')) as val")
->whereNotNull(\DB::raw("JSON_EXTRACT(raw_data, '$.{$columnName}')"))
->limit($limit)
->pluck('val')
->toArray();
}
/**
* Genera descrizione testuale dello schema per prompt LLM
*/
public function generateSchemaPrompt(Machine $machine): string
{
$schema = $this->getSchemaForMachine($machine);
$prompt = "DATABASE SCHEMA PER MACCHINA: {$machine->name}\n\n";
$prompt .= "Tabella: {$schema['table']}\n";
$prompt .= "Descrizione: {$schema['description']}\n\n";
$prompt .= "COLONNE DISPONIBILI:\n";
foreach ($schema['columns'] as $col) {
$prompt .= "- {$col['name']} ({$col['type']}): {$col['description']}\n";
if (!empty($col['aggregators'])) {
$prompt .= " β Aggregabile: " . implode(', ', $col['aggregators']) . "\n";
}
if (!empty($col['sample_values'])) {
$prompt .= " β Esempi: " . implode(', ', array_slice($col['sample_values'], 0, 3)) . "\n";
}
}
return $prompt;
}
}
Blocco 17 php
// app/Services/I40/AiContextService.php
namespace App\Services\I40;
class AiContextService
{
protected $schemaService;
public function __construct(DatabaseSchemaService $schemaService)
{
$this->schemaService = $schemaService;
}
/**
* Prepara contesto completo per LLM
*/
public function buildContext(array $params): array
{
$machineId = $params['machine_id'] ?? null;
$dateFrom = $params['date_from'] ?? now()->subMonth();
$dateTo = $params['date_to'] ?? now();
$machine = Machine::find($machineId);
if (!$machine) {
throw new \Exception('Macchina non trovata');
}
return [
'schema' => $this->schemaService->generateSchemaPrompt($machine),
'statistics' => $this->getRecentStatistics($machine, $dateFrom, $dateTo),
'sample_data' => $this->getSampleRecords($machine, 5),
'business_rules' => $this->getBusinessRules($machine),
];
}
/**
* Statistiche recenti per dare contesto temporale
*/
protected function getRecentStatistics(Machine $machine, $dateFrom, $dateTo): string
{
$stats = MachineOperationLog::query()
->where('machine_id', $machine->id)
->whereBetween('timestamp', [$dateFrom, $dateTo])
->selectRaw('
COUNT(*) as total_operations,
COUNT(DISTINCT DATE(timestamp)) as active_days,
MIN(timestamp) as first_operation,
MAX(timestamp) as last_operation
')
->first();
return sprintf(
"STATISTICHE PERIODO %s - %s:\n" .
"- Operazioni totali: %d\n" .
"- Giorni attivi: %d\n" .
"- Prima operazione: %s\n" .
"- Ultima operazione: %s",
$dateFrom->format('d/m/Y'),
$dateTo->format('d/m/Y'),
$stats->total_operations,
$stats->active_days,
$stats->first_operation,
$stats->last_operation
);
}
/**
* Sample records per dare esempi concreti
*/
protected function getSampleRecords(Machine $machine, int $limit = 5): string
{
$records = MachineOperationLog::where('machine_id', $machine->id)
->orderBy('timestamp', 'desc')
->limit($limit)
->get();
if ($records->isEmpty()) {
return "Nessun dato disponibile.";
}
$sample = "ESEMPI OPERAZIONI RECENTI (ultimi {$limit} record):\n";
foreach ($records as $i => $record) {
$sample .= sprintf(
"%d. [%s] %s\n",
$i + 1,
$record->timestamp->format('Y-m-d H:i'),
json_encode(array_slice($record->raw_data, 0, 5), JSON_UNESCAPED_UNICODE)
);
}
return $sample;
}
/**
* Regole di business specifiche azienda
*/
protected function getBusinessRules(Machine $machine): string
{
// Qui puoi aggiungere regole custom basate sul tipo macchina
$rules = [
"REGOLE DI BUSINESS:",
"- L'orario lavorativo standard Γ¨ 07:00-19:00",
"- La produzione media attesa Γ¨ definita nel profilo macchina",
"- Un 'avviamento' Γ¨ un setup macchina prima di una lavorazione",
];
// Aggiungi regole specifiche dalla configurazione profilo se disponibili
$profile = $machine->getActiveProfile();
if ($profile && isset($profile->business_rules)) {
$rules = array_merge($rules, $profile->business_rules);
}
return implode("\n", $rules);
}
}
Blocco 18 php
// app/Services/I40/QueryUnderstandingService.php
namespace App\Services\I40;
class QueryUnderstandingService
{
/**
* Analizza query utente ed estrae intent/entitΓ
*/
public function analyze(string $userQuery): array
{
// Step 1: Normalizza query
$normalized = $this->normalize($userQuery);
// Step 2: Detect intent
$intent = $this->detectIntent($normalized);
// Step 3: Extract entities
$entities = $this->extractEntities($normalized);
return [
'original' => $userQuery,
'normalized' => $normalized,
'intent' => $intent,
'entities' => $entities,
'confidence' => $this->calculateConfidence($intent, $entities),
];
}
/**
* Normalizza query (lowercase, rimuovi punteggiatura, etc.)
*/
protected function normalize(string $query): string
{
$query = strtolower($query);
$query = preg_replace('/[^a-z0-9\sà èéìòù]/', '', $query);
return trim($query);
}
/**
* Rileva intent principale della query
*/
protected function detectIntent(string $query): string
{
$patterns = [
'production_total' => ['produzione', 'prodotto', 'pezzi', 'quantitΓ ', 'quanti'],
'production_average' => ['media', 'mediamente', 'in media'],
'production_comparison' => ['confronto', 'rispetto', 'differenza', 'piΓΉ', 'meno'],
'trend_analysis' => ['trend', 'andamento', 'evoluzione', 'grafico'],
'operator_performance' => ['operatore', 'chi ha', 'migliore', 'peggiore'],
'time_analysis' => ['tempo', 'durata', 'velocitΓ ', 'quanto ci vuole'],
'anomaly_detection' => ['anomalia', 'problema', 'strano', 'perchΓ©'],
'prediction' => ['previsione', 'riusciremo', 'ce la facciamo', 'entro quando'],
'optimization' => ['ottimizza', 'migliora', 'aumenta', 'suggerisci'],
];
foreach ($patterns as $intent => $keywords) {
foreach ($keywords as $keyword) {
if (str_contains($query, $keyword)) {
return $intent;
}
}
}
return 'general_question';
}
/**
* Estrae entitΓ dalla query (date, numeri, nomi, etc.)
*/
protected function extractEntities(string $query): array
{
$entities = [];
// Date temporali
if (preg_match('/ieri/', $query)) {
$entities['period'] = 'yesterday';
$entities['date_from'] = now()->subDay()->startOfDay();
$entities['date_to'] = now()->subDay()->endOfDay();
} elseif (preg_match('/oggi/', $query)) {
$entities['period'] = 'today';
$entities['date_from'] = now()->startOfDay();
$entities['date_to'] = now()->endOfDay();
} elseif (preg_match('/questa settimana/', $query)) {
$entities['period'] = 'this_week';
$entities['date_from'] = now()->startOfWeek();
$entities['date_to'] = now()->endOfWeek();
} elseif (preg_match('/ultimo mese|mese scorso/', $query)) {
$entities['period'] = 'last_month';
$entities['date_from'] = now()->subMonth()->startOfMonth();
$entities['date_to'] = now()->subMonth()->endOfMonth();
}
// Numeri
if (preg_match('/\d+/', $query, $matches)) {
$entities['numbers'] = array_map('intval', $matches);
}
// Metriche
$metrics = ['pezzi', 'tempo', 'durata', 'operazioni'];
foreach ($metrics as $metric) {
if (str_contains($query, $metric)) {
$entities['metric'] = $metric;
break;
}
}
return $entities;
}
/**
* Calcola confidence score (0-1)
*/
protected function calculateConfidence(string $intent, array $entities): float
{
$score = 0.5; // Base
if ($intent !== 'general_question') $score += 0.2;
if (!empty($entities['period'])) $score += 0.15;
if (!empty($entities['metric'])) $score += 0.15;
return min($score, 1.0);
}
}
Blocco 19 bash
composer require openai-php/laravel
php artisan vendor:publish --provider="OpenAI\Laravel\ServiceProvider"
Blocco 20 php
// app/Services/I40/OpenAiService.php
namespace App\Services\I40;
use OpenAI\Laravel\Facades\OpenAI;
class OpenAiService
{
/**
* Genera risposta AI per query utente
*/
public function generateResponse(array $context, string $userQuery): array
{
$systemPrompt = $this->buildSystemPrompt($context);
$messages = $this->buildMessages($systemPrompt, $userQuery);
try {
$response = OpenAI::chat()->create([
'model' => config('ai.openai.model'),
'messages' => $messages,
'temperature' => config('ai.openai.temperature'),
'max_tokens' => config('ai.openai.max_tokens'),
'functions' => $this->getFunctionDefinitions(),
'function_call' => 'auto',
]);
$choice = $response->choices[0];
$tokensUsed = $response->usage->totalTokens;
// Se LLM vuole chiamare una funzione (es. generare SQL)
if (isset($choice->message->functionCall)) {
return $this->handleFunctionCall($choice->message->functionCall, $tokensUsed);
}
// Altrimenti risposta diretta
return [
'content' => $choice->message->content,
'tokens_used' => $tokensUsed,
'finish_reason' => $choice->finishReason,
];
} catch (\Exception $e) {
\Log::error('OpenAI API Error: ' . $e->getMessage());
throw new \Exception('Errore nel servizio AI. Riprova piΓΉ tardi.');
}
}
/**
* Costruisce prompt di sistema (contesto + istruzioni)
*/
protected function buildSystemPrompt(array $context): string
{
return <<<PROMPT
Sei un assistente AI specializzato nell'analisi di dati di produzione industriale.
Il tuo compito Γ¨ aiutare gli operatori a comprendere e ottimizzare le prestazioni delle macchine.
COMPETENZE:
- Analisi dati temporali (trend, medie, confronti)
- Generazione query SQL per estrarre informazioni
- Identificazione anomalie e pattern
- Suggerimenti per ottimizzazione produzione
CONTESTO DATABASE:
{$context['schema']}
{$context['statistics']}
{$context['sample_data']}
{$context['business_rules']}
ISTRUZIONI:
1. Rispondi sempre in italiano professionale ma accessibile
2. Se serve generare SQL, usa la function "execute_sql_query"
3. Formatta numeri con separatore migliaia (es. 1.500)
4. Suggerisci visualizzazioni (grafici) quando appropriato
5. Se non sei sicuro, ammettilo e chiedi chiarimenti
6. Fornisci sempre contesto (es. "rispetto alla media settimanale di...")
PROMPT;
}
/**
* Costruisce array messaggi per API
*/
protected function buildMessages(string $systemPrompt, string $userQuery): array
{
return [
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => $userQuery],
];
}
/**
* Definisce funzioni che LLM puΓ² chiamare (Function Calling)
*/
protected function getFunctionDefinitions(): array
{
return [
[
'name' => 'execute_sql_query',
'description' => 'Esegue una query SQL sul database produzione e restituisce i risultati',
'parameters' => [
'type' => 'object',
'properties' => [
'sql' => [
'type' => 'string',
'description' => 'Query SQL SELECT da eseguire (solo SELECT, no INSERT/UPDATE/DELETE)',
],
'explanation' => [
'type' => 'string',
'description' => 'Spiegazione di cosa fa la query, per l\'utente',
],
],
'required' => ['sql', 'explanation'],
],
],
[
'name' => 'generate_chart',
'description' => 'Suggerisce un grafico per visualizzare i dati',
'parameters' => [
'type' => 'object',
'properties' => [
'chart_type' => [
'type' => 'string',
'enum' => ['line', 'bar', 'pie'],
'description' => 'Tipo di grafico',
],
'x_field' => [
'type' => 'string',
'description' => 'Campo per asse X',
],
'y_metric' => [
'type' => 'string',
'description' => 'Metrica per asse Y',
],
],
'required' => ['chart_type', 'x_field', 'y_metric'],
],
],
];
}
/**
* Gestisce chiamata a funzione da parte di LLM
*/
protected function handleFunctionCall(object $functionCall, int $tokensUsed): array
{
$functionName = $functionCall->name;
$arguments = json_decode($functionCall->arguments, true);
if ($functionName === 'execute_sql_query') {
$sql = $arguments['sql'];
$explanation = $arguments['explanation'];
// Valida e esegui SQL
$results = $this->executeSafeSql($sql);
return [
'content' => $this->formatSqlResults($results, $explanation),
'tokens_used' => $tokensUsed,
'metadata' => [
'sql' => $sql,
'explanation' => $explanation,
'result_count' => count($results),
],
];
}
if ($functionName === 'generate_chart') {
return [
'content' => "Grafico suggerito generato.",
'tokens_used' => $tokensUsed,
'metadata' => [
'chart_config' => $arguments,
],
];
}
throw new \Exception('Funzione non supportata: ' . $functionName);
}
/**
* Esegue SQL in modo sicuro (whitelist operazioni, prevent injection)
*/
protected function executeSafeSql(string $sql): array
{
// Validazione: solo SELECT
if (!preg_match('/^SELECT\s+/i', trim($sql))) {
throw new \Exception('Solo query SELECT sono permesse');
}
// Validazione: no operazioni pericolose
$forbidden = ['DROP', 'DELETE', 'UPDATE', 'INSERT', 'TRUNCATE', 'ALTER', 'CREATE'];
foreach ($forbidden as $keyword) {
if (stripos($sql, $keyword) !== false) {
throw new \Exception('Query non permessa: contiene ' . $keyword);
}
}
// Esegui con timeout
try {
$results = \DB::select($sql);
return json_decode(json_encode($results), true); // Convert to array
} catch (\Exception $e) {
\Log::error('SQL Execution Error: ' . $e->getMessage());
throw new \Exception('Errore esecuzione query: ' . $e->getMessage());
}
}
/**
* Formatta risultati SQL per risposta user-friendly
*/
protected function formatSqlResults(array $results, string $explanation): string
{
if (empty($results)) {
return "Non ho trovato dati per questa query. " . $explanation;
}
$response = $explanation . "\n\n";
// Se singolo valore scalare (es. COUNT, SUM)
if (count($results) === 1 && count($results[0]) === 1) {
$value = array_values($results[0])[0];
$response .= "**Risultato:** " . number_format($value, 0, ',', '.') . "\n";
return $response;
}
// Se lista valori, mostra tabella semplice
$response .= "**Risultati trovati:** " . count($results) . "\n\n";
// Mostra primi 10 risultati
$displayResults = array_slice($results, 0, 10);
foreach ($displayResults as $i => $row) {
$response .= ($i + 1) . ". ";
$response .= implode(' | ', array_map(function($k, $v) {
return "$k: $v";
}, array_keys($row), $row));
$response .= "\n";
}
if (count($results) > 10) {
$response .= "\n... e altri " . (count($results) - 10) . " risultati.\n";
}
return $response;
}
}
Blocco 21 php
// app/Http/Controllers/Admin/I40/AiChatController.php
namespace App\Http\Controllers\Admin\I40;
use App\Http\Controllers\Controller;
use App\Services\I40\{
AiContextService,
QueryUnderstandingService,
OpenAiService
};
use App\Models\I40\{AiConversation, AiMessage};
use Illuminate\Http\Request;
class AiChatController extends Controller
{
protected $contextService;
protected $queryService;
protected $openAiService;
public function __construct(
AiContextService $contextService,
QueryUnderstandingService $queryService,
OpenAiService $openAiService
) {
$this->contextService = $contextService;
$this->queryService = $queryService;
$this->openAiService = $openAiService;
}
/**
* POST /ai/chat
* Nuova query utente
*/
public function chat(Request $request)
{
$validated = $request->validate([
'conversation_id' => 'nullable|exists:ai_conversations,id',
'machine_id' => 'required|exists:machines_i40,id',
'message' => 'required|string|max:500',
'date_from' => 'nullable|date',
'date_to' => 'nullable|date',
]);
// 1) Ottieni o crea conversazione
$conversation = $this->getOrCreateConversation(
$validated['conversation_id'] ?? null,
$validated['machine_id'],
auth()->id()
);
// 2) Salva messaggio utente
$userMessage = $conversation->messages()->create([
'role' => 'user',
'content' => $validated['message'],
]);
try {
// 3) Analizza query
$analysis = $this->queryService->analyze($validated['message']);
// 4) Prepara contesto
$context = $this->contextService->buildContext([
'machine_id' => $validated['machine_id'],
'date_from' => $validated['date_from'] ?? now()->subMonth(),
'date_to' => $validated['date_to'] ?? now(),
]);
// 5) Genera risposta AI
$aiResponse = $this->openAiService->generateResponse(
$context,
$validated['message']
);
// 6) Salva risposta AI
$assistantMessage = $conversation->messages()->create([
'role' => 'assistant',
'content' => $aiResponse['content'],
'tokens_used' => $aiResponse['tokens_used'],
'metadata' => $aiResponse['metadata'] ?? null,
]);
// 7) Auto-genera titolo conversazione se prima query
if ($conversation->messages()->count() === 2) {
$conversation->generateTitle();
}
return response()->json([
'success' => true,
'conversation_id' => $conversation->id,
'message' => [
'id' => $assistantMessage->id,
'role' => 'assistant',
'content' => $assistantMessage->content,
'metadata' => $assistantMessage->metadata,
'created_at' => $assistantMessage->created_at,
],
'analysis' => $analysis, // Per debug
]);
} catch (\Exception $e) {
\Log::error('AI Chat Error: ' . $e->getMessage());
// Risposta fallback
$errorMessage = $conversation->messages()->create([
'role' => 'assistant',
'content' => 'Mi dispiace, ho avuto un problema nel processare la tua richiesta. Potresti riformulare la domanda?',
'metadata' => ['error' => $e->getMessage()],
]);
return response()->json([
'success' => false,
'error' => $e->getMessage(),
'conversation_id' => $conversation->id,
'message' => [
'id' => $errorMessage->id,
'role' => 'assistant',
'content' => $errorMessage->content,
'created_at' => $errorMessage->created_at,
],
], 500);
}
}
/**
* GET /ai/conversations
* Lista conversazioni utente
*/
public function conversations(Request $request)
{
$conversations = AiConversation::where('user_id', auth()->id())
->with(['messages' => function($q) {
$q->latest()->limit(1); // Solo ultimo messaggio
}])
->latest()
->paginate(20);
return response()->json($conversations);
}
/**
* GET /ai/conversations/{id}
* Dettaglio conversazione (tutti i messaggi)
*/
public function show(AiConversation $conversation)
{
$this->authorize('view', $conversation);
$conversation->load('messages');
return response()->json($conversation);
}
/**
* POST /ai/feedback
* Thumbs up/down su risposta AI
*/
public function feedback(Request $request)
{
$validated = $request->validate([
'message_id' => 'required|exists:ai_messages,id',
'rating' => 'required|in:up,down',
'comment' => 'nullable|string|max:500',
]);
$feedback = AiFeedback::updateOrCreate(
[
'message_id' => $validated['message_id'],
'user_id' => auth()->id(),
],
[
'rating' => $validated['rating'],
'comment' => $validated['comment'] ?? null,
]
);
return response()->json(['success' => true, 'feedback' => $feedback]);
}
/**
* Helper: Ottieni o crea conversazione
*/
protected function getOrCreateConversation($conversationId, $machineId, $userId)
{
if ($conversationId) {
$conversation = AiConversation::find($conversationId);
if ($conversation && $conversation->user_id === $userId) {
return $conversation;
}
}
return AiConversation::create([
'user_id' => $userId,
'machine_id' => $machineId,
]);
}
}
Blocco 22 php
// routes/i40.php (aggiungi)
Route::prefix('ai')->name('ai.')->group(function () {
Route::post('/chat', [AiChatController::class, 'chat'])->name('chat');
Route::get('/conversations', [AiChatController::class, 'conversations'])->name('conversations');
Route::get('/conversations/{conversation}', [AiChatController::class, 'show'])->name('conversations.show');
Route::post('/feedback', [AiChatController::class, 'feedback'])->name('feedback');
})->middleware('role:admin|maintenance|super-admin');
Blocco 23 bash
npm install vue@next vue-loader@next @vitejs/plugin-vue
Blocco 24 vue
<!-- resources/js/components/I40/AiChatWidget.vue -->
<template>
<div class="ai-chat-widget" :class="{ 'minimized': minimized }">
<!-- Header -->
<div class="chat-header" @click="toggleMinimize">
<div class="d-flex align-items-center">
<div class="ai-avatar me-2">π€</div>
<div>
<strong>AI Assistant</strong>
<small class="d-block text-muted">Analisi Produzione</small>
</div>
</div>
<button class="btn btn-sm btn-link text-white">
<i :class="minimized ? 'bi-chevron-up' : 'bi-chevron-down'"></i>
</button>
</div>
<!-- Chat Body -->
<div v-show="!minimized" class="chat-body">
<!-- Messaggi -->
<div class="messages-container" ref="messagesContainer">
<div v-if="messages.length === 0" class="empty-state">
<div class="ai-avatar-large mb-3">π€</div>
<h5>Ciao! Sono il tuo assistente AI</h5>
<p class="text-muted">Chiedimi qualsiasi cosa sulla produzione di questa macchina.</p>
<!-- Suggerimenti iniziali -->
<div class="suggestions mt-3">
<small class="text-muted d-block mb-2">Prova a chiedere:</small>
<button
v-for="suggestion in initialSuggestions"
:key="suggestion"
class="btn btn-sm btn-outline-primary mb-2 me-2"
@click="sendMessage(suggestion)"
>
{{ suggestion }}
</button>
</div>
</div>
<!-- Lista messaggi -->
<div
v-for="message in messages"
:key="message.id"
class="message"
:class="message.role"
>
<div class="message-avatar">
{{ message.role === 'user' ? 'π€' : 'π€' }}
</div>
<div class="message-content">
<div class="message-text" v-html="formatMessage(message.content)"></div>
<!-- Metadata (SQL, Grafici, etc.) -->
<div v-if="message.metadata" class="message-metadata mt-2">
<!-- Mostra SQL eseguito -->
<div v-if="message.metadata.sql" class="sql-block">
<button
class="btn btn-xs btn-link"
@click="toggleSql(message.id)"
>
<i class="bi bi-code-square"></i> Mostra Query SQL
</button>
<pre v-show="expandedSql[message.id]" class="mt-2">{{ message.metadata.sql }}</pre>
</div>
<!-- Suggerisci grafico -->
<div v-if="message.metadata.chart_config" class="chart-suggestion">
<button
class="btn btn-sm btn-primary"
@click="openChart(message.metadata.chart_config)"
>
<i class="bi bi-bar-chart"></i> Visualizza Grafico
</button>
</div>
</div>
<!-- Feedback -->
<div v-if="message.role === 'assistant'" class="message-feedback mt-2">
<button
class="btn btn-xs"
:class="message.feedback === 'up' ? 'btn-success' : 'btn-outline-secondary'"
@click="sendFeedback(message.id, 'up')"
title="Risposta utile"
>
<i class="bi bi-hand-thumbs-up"></i>
</button>
<button
class="btn btn-xs ms-1"
:class="message.feedback === 'down' ? 'btn-danger' : 'btn-outline-secondary'"
@click="sendFeedback(message.id, 'down')"
title="Risposta non utile"
>
<i class="bi bi-hand-thumbs-down"></i>
</button>
</div>
<small class="text-muted d-block mt-1">{{ formatTime(message.created_at) }}</small>
</div>
</div>
<!-- Loading indicator -->
<div v-if="loading" class="message assistant">
<div class="message-avatar">π€</div>
<div class="message-content">
<div class="typing-indicator">
<span></span><span></span><span></span>
</div>
</div>
</div>
</div>
<!-- Input -->
<div class="chat-input">
<textarea
v-model="userInput"
@keydown.enter.prevent="sendMessage()"
placeholder="Chiedi qualcosa sulla produzione..."
rows="2"
:disabled="loading"
class="form-control"
></textarea>
<button
@click="sendMessage()"
:disabled="loading || !userInput.trim()"
class="btn btn-primary btn-send"
>
<i class="bi bi-send"></i>
</button>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'AiChatWidget',
props: {
machineId: {
type: Number,
required: true
},
dateFrom: String,
dateTo: String,
},
data() {
return {
minimized: false,
messages: [],
userInput: '',
loading: false,
conversationId: null,
expandedSql: {},
initialSuggestions: [
"Qual Γ¨ stata la produzione di ieri?",
"Mostrami il trend di questa settimana",
"Chi Γ¨ l'operatore piΓΉ produttivo?",
],
};
},
methods: {
async sendMessage(text = null) {
const message = text || this.userInput.trim();
if (!message || this.loading) return;
// Aggiungi messaggio utente alla UI
this.messages.push({
id: Date.now(),
role: 'user',
content: message,
created_at: new Date().toISOString(),
});
this.userInput = '';
this.loading = true;
this.scrollToBottom();
try {
const response = await fetch('/admin/i40/ai/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify({
conversation_id: this.conversationId,
machine_id: this.machineId,
message: message,
date_from: this.dateFrom,
date_to: this.dateTo,
}),
});
const data = await response.json();
if (data.success) {
this.conversationId = data.conversation_id;
this.messages.push(data.message);
} else {
this.messages.push({
id: Date.now(),
role: 'assistant',
content: 'β ' + (data.error || 'Errore nel processare la richiesta.'),
created_at: new Date().toISOString(),
});
}
} catch (error) {
console.error('Chat error:', error);
this.messages.push({
id: Date.now(),
role: 'assistant',
content: 'β Errore di connessione. Riprova.',
created_at: new Date().toISOString(),
});
} finally {
this.loading = false;
this.scrollToBottom();
}
},
async sendFeedback(messageId, rating) {
try {
await fetch('/admin/i40/ai/feedback', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify({ message_id: messageId, rating }),
});
// Aggiorna UI
const message = this.messages.find(m => m.id === messageId);
if (message) message.feedback = rating;
} catch (error) {
console.error('Feedback error:', error);
}
},
toggleMinimize() {
this.minimized = !this.minimized;
},
toggleSql(messageId) {
this.expandedSql[messageId] = !this.expandedSql[messageId];
},
openChart(chartConfig) {
this.$emit('open-chart', chartConfig);
},
formatMessage(content) {
// Converti markdown-like syntax in HTML
return content
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\n/g, '<br>');
},
formatTime(timestamp) {
const date = new Date(timestamp);
return date.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' });
},
scrollToBottom() {
this.$nextTick(() => {
const container = this.$refs.messagesContainer;
if (container) {
container.scrollTop = container.scrollHeight;
}
});
},
},
};
</script>
<style scoped>
.ai-chat-widget {
position: fixed;
bottom: 20px;
right: 20px;
width: 400px;
max-height: 600px;
background: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
display: flex;
flex-direction: column;
z-index: 1050;
transition: all 0.3s ease;
}
.ai-chat-widget.minimized {
max-height: 60px;
}
.chat-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 12px 16px;
border-radius: 12px 12px 0 0;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
}
.ai-avatar {
width: 32px;
height: 32px;
background: rgba(255,255,255,0.2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.ai-avatar-large {
width: 64px;
height: 64px;
background: #f0f0f0;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
margin: 0 auto;
}
.chat-body {
display: flex;
flex-direction: column;
height: 540px;
}
.messages-container {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.empty-state {
text-align: center;
padding: 40px 20px;
}
.suggestions button {
font-size: 0.85rem;
}
.message {
display: flex;
margin-bottom: 16px;
align-items: flex-start;
}
.message.user {
flex-direction: row-reverse;
}
.message-avatar {
width: 32px;
height: 32px;
background: #f0f0f0;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.message.user .message-avatar {
background: #667eea;
color: white;
}
.message-content {
max-width: 75%;
margin: 0 8px;
}
.message.user .message-content {
background: #667eea;
color: white;
border-radius: 12px 12px 0 12px;
padding: 10px 14px;
}
.message.assistant .message-content {
background: #f0f0f0;
border-radius: 12px 12px 12px 0;
padding: 10px 14px;
}
.message-text {
line-height: 1.5;
}
.sql-block pre {
background: #2d2d2d;
color: #f8f8f2;
padding: 10px;
border-radius: 6px;
font-size: 0.85rem;
overflow-x: auto;
}
.typing-indicator {
display: flex;
gap: 4px;
padding: 10px 0;
}
.typing-indicator span {
width: 8px;
height: 8px;
background: #999;
border-radius: 50%;
animation: typing 1.4s infinite;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-10px); }
}
.chat-input {
display: flex;
padding: 12px;
border-top: 1px solid #e0e0e0;
gap: 8px;
}
.chat-input textarea {
flex: 1;
resize: none;
border-radius: 8px;
}
.btn-send {
width: 44px;
height: 44px;
border-radius: 8px;
}
.message-feedback .btn {
padding: 2px 8px;
font-size: 0.85rem;
}
/* Responsive */
@media (max-width: 768px) {
.ai-chat-widget {
width: calc(100% - 40px);
max-width: 400px;
}
}
</style>
Blocco 25 blade
<!-- resources/views/admin/i40/operations/analyze.blade.php -->
<!-- Aggiungi prima di @endpush -->
@push('scripts')
<script>
// Inizializza Vue component AI Chat
import { createApp } from 'vue';
import AiChatWidget from '@/components/I40/AiChatWidget.vue';
const app = createApp({});
app.component('ai-chat-widget', AiChatWidget);
app.mount('#ai-chat-container');
</script>
@endpush
<!-- Aggiungi nel body della view -->
<div id="ai-chat-container">
<ai-chat-widget
:machine-id="{{ $selectedMachine->id }}"
date-from="{{ $dateFrom->format('Y-m-d') }}"
date-to="{{ $dateTo->format('Y-m-d') }}"
@open-chart="openChartFromAi"
></ai-chat-widget>
</div>
Blocco 26 php
// app/Services/I40/AiCacheService.php
namespace App\Services\I40;
class AiCacheService
{
/**
* Ottieni risposta da cache se disponibile
*/
public function get(string $query, int $machineId, array $filters = []): ?array
{
$hash = $this->generateHash($query, $machineId, $filters);
$cached = \DB::table('ai_cache')
->where('query_hash', $hash)
->where('expires_at', '>', now())
->first();
if ($cached) {
// Incrementa hit counter
\DB::table('ai_cache')
->where('id', $cached->id)
->increment('hits');
return json_decode($cached->response_data, true);
}
return null;
}
/**
* Salva risposta in cache
*/
public function set(string $query, int $machineId, array $filters, array $response, int $ttl = null): void
{
$hash = $this->generateHash($query, $machineId, $filters);
$ttl = $ttl ?? config('ai.cache.ttl', 3600);
\DB::table('ai_cache')->updateOrInsert(
['query_hash' => $hash],
[
'machine_id' => $machineId,
'date_range' => $this->getDateRange($filters),
'response_data' => json_encode($response),
'expires_at' => now()->addSeconds($ttl),
'created_at' => now(),
]
);
}
/**
* Genera hash univoco per query
*/
protected function generateHash(string $query, int $machineId, array $filters): string
{
// Normalizza query (lowercase, rimuovi spazi multipli)
$normalized = strtolower(trim(preg_replace('/\s+/', ' ', $query)));
// Combina con parametri
$key = $normalized . '|' . $machineId . '|' . json_encode($filters);
return hash('sha256', $key);
}
/**
* Estrae date range dai filtri
*/
protected function getDateRange(array $filters): ?string
{
$from = $filters['date_from'] ?? null;
$to = $filters['date_to'] ?? null;
if ($from && $to) {
return "{$from}_{$to}";
}
return null;
}
/**
* Pulisci cache scaduta
*/
public function cleanExpired(): int
{
return \DB::table('ai_cache')
->where('expires_at', '<', now())
->delete();
}
/**
* Invalida cache per macchina (dopo nuovi dati)
*/
public function invalidateForMachine(int $machineId): int
{
return \DB::table('ai_cache')
->where('machine_id', $machineId)
->delete();
}
}
Blocco 27 php
// app/Console/Kernel.php
protected function schedule(Schedule $schedule)
{
// Pulisci cache AI scaduta ogni ora
$schedule->call(function () {
app(AiCacheService::class)->cleanExpired();
})->hourly();
}
Blocco 28 javascript
// Aggiungi al Vue component
data() {
return {
// ...
isRecording: false,
recognition: null,
};
},
mounted() {
this.initSpeechRecognition();
},
methods: {
initSpeechRecognition() {
if (!('webkitSpeechRecognition' in window)) {
console.warn('Speech recognition not supported');
return;
}
this.recognition = new webkitSpeechRecognition();
this.recognition.lang = 'it-IT';
this.recognition.continuous = false;
this.recognition.interimResults = false;
this.recognition.onresult = (event) => {
const transcript = event.results[0][0].transcript;
this.userInput = transcript;
this.isRecording = false;
};
this.recognition.onerror = (event) => {
console.error('Speech recognition error:', event.error);
this.isRecording = false;
};
this.recognition.onend = () => {
this.isRecording = false;
};
},
toggleRecording() {
if (!this.recognition) return;
if (this.isRecording) {
this.recognition.stop();
this.isRecording = false;
} else {
this.recognition.start();
this.isRecording = true;
}
},
}
Blocco 29 vue
<!-- Template: Aggiungi bottone microfono -->
<button
@click="toggleRecording"
:class="{ 'recording': isRecording }"
class="btn btn-outline-secondary btn-voice"
title="Usa il microfono"
>
<i class="bi" :class="isRecording ? 'bi-mic-fill' : 'bi-mic'"></i>
</button>
Blocco 30 css
/* Style */
.btn-voice.recording {
animation: pulse 1.5s infinite;
background: #dc3545;
color: white;
border-color: #dc3545;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
Blocco 31 php
// app/Services/I40/ConversationExportService.php
namespace App\Services\I40;
use Barryvdh\DomPDF\Facade\Pdf;
class ConversationExportService
{
/**
* Esporta conversazione in PDF
*/
public function exportToPdf(AiConversation $conversation): string
{
$data = [
'conversation' => $conversation,
'messages' => $conversation->messages,
'machine' => $conversation->machine,
'user' => $conversation->user,
];
$pdf = Pdf::loadView('admin.i40.ai.export-pdf', $data);
return $pdf->download("conversazione_{$conversation->id}.pdf");
}
/**
* Esporta in formato JSON
*/
public function exportToJson(AiConversation $conversation): array
{
return [
'conversation_id' => $conversation->id,
'title' => $conversation->title,
'machine' => [
'id' => $conversation->machine->id,
'name' => $conversation->machine->name,
],
'created_at' => $conversation->created_at->toIso8601String(),
'messages' => $conversation->messages->map(function($msg) {
return [
'role' => $msg->role,
'content' => $msg->content,
'metadata' => $msg->metadata,
'created_at' => $msg->created_at->toIso8601String(),
];
})->toArray(),
];
}
}
Blocco 32 php
Route::get('/ai/conversations/{conversation}/export', [AiChatController::class, 'export'])
->name('ai.conversations.export');
Blocco 33 php
// Controller method
public function export(AiConversation $conversation, Request $request)
{
$this->authorize('view', $conversation);
$format = $request->input('format', 'pdf'); // pdf, json
$exportService = app(ConversationExportService::class);
if ($format === 'json') {
$data = $exportService->exportToJson($conversation);
return response()->json($data);
}
return $exportService->exportToPdf($conversation);
}
Blocco 34 php
// app/Services/I40/ResponseSuggestionsService.php
namespace App\Services\I40;
class ResponseSuggestionsService
{
/**
* Genera follow-up suggestions basate sul contesto
*/
public function generate(AiMessage $lastMessage, array $context): array
{
$intent = $context['intent'] ?? 'general';
$suggestions = $this->getBaselineSuggestions($intent);
// Personalizza basandosi sulla risposta AI
if ($lastMessage->metadata && isset($lastMessage->metadata['sql'])) {
$suggestions[] = "Mostra gli stessi dati in un grafico";
$suggestions[] = "Confronta con il periodo precedente";
}
return array_slice($suggestions, 0, 3); // Max 3 suggestions
}
/**
* Suggestions baseline per intent
*/
protected function getBaselineSuggestions(string $intent): array
{
$map = [
'production_total' => [
"Confronta con ieri",
"Mostrami il trend settimanale",
"Quale operatore ha prodotto di piΓΉ?",
],
'operator_performance' => [
"Analizza la velocitΓ media",
"Ci sono giorni piΓΉ produttivi?",
"Suggeriscimi come migliorare",
],
'trend_analysis' => [
"Ci sono anomalie nei dati?",
"Prevedi la produzione di domani",
"Esporta questi dati",
],
];
return $map[$intent] ?? [
"Mostrami le statistiche",
"Analizza il periodo corrente",
"Come posso ottimizzare?",
];
}
}
Blocco 35 vue
<!-- Dopo ogni messaggio AI, mostra suggestions -->
<div v-if="message.role === 'assistant' && message.suggestions" class="follow-up-suggestions mt-2">
<small class="text-muted d-block mb-1">Potresti chiedere:</small>
<button
v-for="suggestion in message.suggestions"
:key="suggestion"
class="btn btn-xs btn-outline-secondary me-1 mb-1"
@click="sendMessage(suggestion)"
>
{{ suggestion }}
</button>
</div>
Blocco 36 sql
CREATE TABLE ai_alerts (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
machine_id BIGINT UNSIGNED NULL,
alert_type ENUM('threshold', 'anomaly', 'prediction') NOT NULL,
condition_config JSON NOT NULL, -- { field: 'TotalPieces', operator: '<', value: 1000, period: 'daily' }
notification_channels JSON, -- ['email', 'slack', 'in_app']
is_active BOOLEAN DEFAULT TRUE,
last_triggered_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_active (is_active, machine_id)
);
Blocco 37 php
// app/Services/I40/AlertMonitoringService.php
namespace App\Services\I40;
class AlertMonitoringService
{
/**
* Controlla tutti gli alert attivi (eseguito ogni ora via Scheduler)
*/
public function checkAlerts(): void
{
$alerts = \DB::table('ai_alerts')
->where('is_active', true)
->get();
foreach ($alerts as $alert) {
$this->evaluateAlert($alert);
}
}
/**
* Valuta singolo alert
*/
protected function evaluateAlert(object $alert): void
{
$condition = json_decode($alert->condition_config, true);
$result = $this->executeCondition($condition, $alert->machine_id);
if ($result['triggered']) {
$this->triggerAlert($alert, $result);
}
}
/**
* Esegue condizione alert
*/
protected function executeCondition(array $condition, ?int $machineId): array
{
$field = $condition['field'];
$operator = $condition['operator']; // <, >, =, !=
$threshold = $condition['value'];
$period = $condition['period'] ?? 'daily'; // daily, weekly, monthly
// Calcola date range
$dateFrom = match($period) {
'daily' => now()->startOfDay(),
'weekly' => now()->startOfWeek(),
'monthly' => now()->startOfMonth(),
};
// Query valore attuale
$query = MachineOperationLog::query();
if ($machineId) {
$query->where('machine_id', $machineId);
}
$value = $query
->whereBetween('timestamp', [$dateFrom, now()])
->selectRaw("SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data, '$.{$field}')) AS DECIMAL(15,2))) as total")
->value('total') ?? 0;
// Valuta condizione
$triggered = match($operator) {
'<' => $value < $threshold,
'>' => $value > $threshold,
'=' => $value == $threshold,
'!=' => $value != $threshold,
default => false,
};
return [
'triggered' => $triggered,
'current_value' => $value,
'threshold' => $threshold,
'field' => $field,
];
}
/**
* Trigger notifica alert
*/
protected function triggerAlert(object $alert, array $result): void
{
// Aggiorna last_triggered_at
\DB::table('ai_alerts')
->where('id', $alert->id)
->update(['last_triggered_at' => now()]);
// Invia notifiche
$channels = json_decode($alert->notification_channels, true) ?? ['in_app'];
foreach ($channels as $channel) {
$this->sendNotification($channel, $alert, $result);
}
}
/**
* Invia notifica su canale specifico
*/
protected function sendNotification(string $channel, object $alert, array $result): void
{
$message = sprintf(
"β οΈ Alert: %s Γ¨ %s (soglia: %s, valore attuale: %s)",
$result['field'],
$alert->alert_type,
number_format($result['threshold']),
number_format($result['current_value'])
);
if ($channel === 'email') {
$user = User::find($alert->user_id);
\Mail::to($user)->send(new AlertTriggeredMail($message));
} elseif ($channel === 'slack') {
// Slack webhook
\Http::post(config('services.slack.webhook_url'), [
'text' => $message,
]);
} elseif ($channel === 'in_app') {
// Laravel notification system
$user = User::find($alert->user_id);
$user->notify(new AlertTriggered($message));
}
}
}
Blocco 38 php
// app/Console/Kernel.php
protected function schedule(Schedule $schedule)
{
// Controlla alert ogni ora
$schedule->call(function () {
app(AlertMonitoringService::class)->checkAlerts();
})->hourly();
}
Blocco 39 sql
SELECT
SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data, '$.TotalPieces')) AS DECIMAL(15,2))) as total
FROM machine_operations_log
WHERE machine_id = 5
AND DATE(timestamp) = CURDATE() - INTERVAL 1 DAY
Blocco 40 sql
SELECT
WEEK(timestamp) as week_num,
SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data, '$.TotalPieces')) AS DECIMAL(15,2))) as total
FROM machine_operations_log
WHERE machine_id = 5
AND timestamp >= DATE_SUB(NOW(), INTERVAL 2 WEEK)
GROUP BY week_num
ORDER BY week_num
Blocco 41 sql
SELECT
JSON_UNQUOTE(JSON_EXTRACT(raw_data, '$.MarkerName')) as operator,
SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data, '$.TotalPieces')) AS DECIMAL(15,2))) as total_pieces,
COUNT(*) as operations,
AVG(CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data, '$.CutTime')) AS DECIMAL(15,2))) as avg_time
FROM machine_operations_log
WHERE machine_id = 5
AND timestamp >= DATE_FORMAT(NOW(), '%Y-%m-01')
GROUP BY operator
ORDER BY total_pieces DESC
LIMIT 5
Blocco 42 sql
SELECT
DATE(timestamp) as date,
SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data, '$.TotalPieces')) AS DECIMAL(15,2))) as daily_total
FROM machine_operations_log
WHERE machine_id = 5
AND timestamp >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY date
ORDER BY date
Blocco 43 sql
-- Query 1: Produzione ieri
SELECT
SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data, '$.TotalPieces')) AS DECIMAL(15,2))) as total,
COUNT(*) as operations,
MIN(timestamp) as first_op,
MAX(timestamp) as last_op
FROM machine_operations_log
WHERE machine_id = 5
AND DATE(timestamp) = CURDATE() - INTERVAL 1 DAY
-- Query 2: Media ultimi 30 giorni (escl. weekend)
SELECT AVG(daily_total) as avg_production
FROM (
SELECT DATE(timestamp) as date, SUM(...) as daily_total
FROM machine_operations_log
WHERE machine_id = 5
AND timestamp >= DATE_SUB(NOW(), INTERVAL 30 DAY)
AND DAYOFWEEK(timestamp) NOT IN (1, 7)
GROUP BY date
) as daily_stats
Blocco 44 sql
-- Query 1: Pezzi giΓ prodotti questa settimana
SELECT
SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data, '$.TotalPieces')) AS DECIMAL(15,2))) as current_total
FROM machine_operations_log
WHERE machine_id = 5
AND timestamp >= DATE_SUB(NOW(), INTERVAL WEEKDAY(NOW()) DAY)
-- Query 2: Media produzione giornaliera (ultimi 30 giorni)
SELECT AVG(daily_total) as avg_daily
FROM (
SELECT DATE(timestamp), SUM(...) as daily_total
FROM machine_operations_log
WHERE machine_id = 5
AND timestamp >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY DATE(timestamp)
) as stats
Blocco 45 sql
-- Analisi pattern produttivi
SELECT
DAYNAME(timestamp) as day_of_week,
HOUR(timestamp) as hour_of_day,
JSON_UNQUOTE(JSON_EXTRACT(raw_data, '$.MarkerName')) as operator,
AVG(CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data, '$.TotalPieces')) AS DECIMAL(15,2)) /
CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data, '$.CutTime')) AS DECIMAL(15,2))) as pieces_per_hour
FROM machine_operations_log
WHERE machine_id = 5
AND timestamp >= DATE_SUB(NOW(), INTERVAL 3 MONTH)
GROUP BY day_of_week, hour_of_day, operator
HAVING pieces_per_hour > 0
ORDER BY pieces_per_hour DESC
LIMIT 20
Blocco 46 php
// tests/Unit/QueryUnderstandingServiceTest.php
namespace Tests\Unit\Services\I40;
use Tests\TestCase;
use App\Services\I40\QueryUnderstandingService;
class QueryUnderstandingServiceTest extends TestCase
{
protected $service;
protected function setUp(): void
{
parent::setUp();
$this->service = app(QueryUnderstandingService::class);
}
/** @test */
public function it_detects_production_total_intent()
{
$result = $this->service->analyze("Qual Γ¨ stata la produzione di ieri?");
$this->assertEquals('production_total', $result['intent']);
$this->assertEquals('yesterday', $result['entities']['period']);
$this->assertGreaterThan(0.7, $result['confidence']);
}
/** @test */
public function it_extracts_date_entities()
{
$result = $this->service->analyze("Mostrami la produzione di questa settimana");
$this->assertEquals('this_week', $result['entities']['period']);
$this->assertNotNull($result['entities']['date_from']);
$this->assertNotNull($result['entities']['date_to']);
}
/** @test */
public function it_handles_comparison_queries()
{
$result = $this->service->analyze("Confronta con la settimana scorsa");
$this->assertEquals('production_comparison', $result['intent']);
}
}
Blocco 47 php
// tests/Unit/OpenAiServiceTest.php
namespace Tests\Unit\Services\I40;
use Tests\TestCase;
use App\Services\I40\OpenAiService;
use OpenAI\Laravel\Facades\OpenAI;
class OpenAiServiceTest extends TestCase
{
/** @test */
public function it_generates_sql_query_via_function_calling()
{
// Mock OpenAI response
OpenAI::fake([
OpenAI::response([
'choices' => [
[
'message' => [
'function_call' => [
'name' => 'execute_sql_query',
'arguments' => json_encode([
'sql' => 'SELECT COUNT(*) FROM machine_operations_log WHERE machine_id=5',
'explanation' => 'Conteggio operazioni macchina 5'
]),
],
],
'finish_reason' => 'function_call',
],
],
'usage' => ['total_tokens' => 500],
]),
]);
$service = app(OpenAiService::class);
$context = ['schema' => 'test schema', 'statistics' => '', 'sample_data' => '', 'business_rules' => ''];
$response = $service->generateResponse($context, "Quante operazioni ha fatto la macchina?");
$this->assertArrayHasKey('metadata', $response);
$this->assertArrayHasKey('sql', $response['metadata']);
$this->assertStringContainsString('SELECT', $response['metadata']['sql']);
}
/** @test */
public function it_prevents_dangerous_sql()
{
$service = app(OpenAiService::class);
$this->expectException(\Exception::class);
$this->expectExceptionMessage('Query non permessa');
// Tenta di eseguire SQL pericoloso
$reflection = new \ReflectionClass($service);
$method = $reflection->getMethod('executeSafeSql');
$method->setAccessible(true);
$method->invoke($service, "DROP TABLE users");
}
}
Blocco 48 php
// tests/Feature/AiChatFlowTest.php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\User;
use App\Models\I40\Machine;
use Illuminate\Foundation\Testing\RefreshDatabase;
class AiChatFlowTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function user_can_start_conversation_with_ai()
{
$user = User::factory()->create();
$machine = Machine::factory()->create();
$this->actingAs($user);
$response = $this->postJson('/admin/i40/ai/chat', [
'machine_id' => $machine->id,
'message' => 'Qual Γ¨ stata la produzione di ieri?',
]);
$response->assertStatus(200)
->assertJsonStructure([
'success',
'conversation_id',
'message' => ['id', 'role', 'content', 'created_at'],
]);
$this->assertDatabaseHas('ai_conversations', [
'user_id' => $user->id,
'machine_id' => $machine->id,
]);
$this->assertDatabaseHas('ai_messages', [
'role' => 'user',
'content' => 'Qual Γ¨ stata la produzione di ieri?',
]);
}
/** @test */
public function user_can_provide_feedback_on_ai_response()
{
$user = User::factory()->create();
$message = AiMessage::factory()->create(['role' => 'assistant']);
$this->actingAs($user);
$response = $this->postJson('/admin/i40/ai/feedback', [
'message_id' => $message->id,
'rating' => 'up',
'comment' => 'Molto utile!',
]);
$response->assertStatus(200);
$this->assertDatabaseHas('ai_feedback', [
'message_id' => $message->id,
'user_id' => $user->id,
'rating' => 'up',
]);
}
}
Blocco 49 php
// tests/Performance/AiResponseTimeTest.php
namespace Tests\Performance;
use Tests\TestCase;
class AiResponseTimeTest extends TestCase
{
/** @test */
public function ai_response_time_is_under_5_seconds()
{
$start = microtime(true);
// Esegui query AI
$this->postJson('/admin/i40/ai/chat', [
'machine_id' => 1,
'message' => 'Test query',
]);
$duration = microtime(true) - $start;
$this->assertLessThan(5.0, $duration, "Response time was {$duration}s");
}
}
Blocco 50 php
// Dashboard Analytics Service
class AiAnalyticsService
{
public function getDashboardMetrics(Carbon $from, Carbon $to): array
{
return [
// Utilizzo
'total_conversations' => AiConversation::whereBetween('created_at', [$from, $to])->count(),
'total_messages' => AiMessage::whereBetween('created_at', [$from, $to])->count(),
'active_users' => AiConversation::whereBetween('created_at', [$from, $to])
->distinct('user_id')->count(),
// Performance
'avg_response_time' => AiMessage::where('role', 'assistant')
->whereBetween('created_at', [$from, $to])
->avg('response_time_ms'),
// Quality
'positive_feedback' => AiFeedback::where('rating', 'up')
->whereBetween('created_at', [$from, $to])->count(),
'negative_feedback' => AiFeedback::where('rating', 'down')
->whereBetween('created_at', [$from, $to])->count(),
'satisfaction_rate' => $this->calculateSatisfactionRate($from, $to),
// Costi
'total_tokens' => AiMessage::whereBetween('created_at', [$from, $to])
->sum('tokens_used'),
'estimated_cost' => $this->estimateCost($from, $to),
// Cache
'cache_hit_rate' => $this->calculateCacheHitRate($from, $to),
];
}
protected function calculateSatisfactionRate(Carbon $from, Carbon $to): float
{
$positive = AiFeedback::where('rating', 'up')
->whereBetween('created_at', [$from, $to])->count();
$negative = AiFeedback::where('rating', 'down')
->whereBetween('created_at', [$from, $to])->count();
$total = $positive + $negative;
return $total > 0 ? ($positive / $total) * 100 : 0;
}
}
Blocco 51 php
// Event: Low Confidence Response
if ($analysis['confidence'] < 0.5) {
\Log::warning('AI Low Confidence Response', [
'conversation_id' => $conversation->id,
'query' => $userMessage,
'confidence' => $analysis['confidence'],
]);
}
// Event: SQL Execution Error
\Log::error('AI SQL Execution Failed', [
'conversation_id' => $conversation->id,
'sql' => $sql,
'error' => $exception->getMessage(),
]);
// Event: High Token Usage
if ($tokensUsed > 2000) {
\Log::info('AI High Token Usage', [
'conversation_id' => $conversation->id,
'tokens' => $tokensUsed,
]);
}
Blocco 52 blade
<!-- resources/views/admin/i40/ai/dashboard.blade.php -->
<div class="container-fluid">
<h3>AI Assistant Analytics</h3>
<div class="row">
<!-- Usage Stats -->
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5>{{ $metrics['total_conversations'] }}</h5>
<small>Conversazioni (30gg)</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5>{{ $metrics['active_users'] }}</h5>
<small>Utenti Attivi</small>
</div>
</div>
</div>
<!-- Quality Stats -->
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5>{{ number_format($metrics['satisfaction_rate'], 1) }}%</h5>
<small>Soddisfazione</small>
</div>
</div>
</div>
<!-- Cost Stats -->
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5>${{ number_format($metrics['estimated_cost'], 2) }}</h5>
<small>Costo Stimato</small>
</div>
</div>
</div>
</div>
<!-- Query Performance Chart -->
<div class="card mt-4">
<div class="card-body">
<h5>Query piΓΉ Frequenti</h5>
<canvas id="topQueriesChart"></canvas>
</div>
</div>
</div>
Blocco 53 php
// Salva query + risposta + feedback per futuro fine-tuning
class FineTuningDataCollector
{
public function collect(AiMessage $userMessage, AiMessage $aiResponse, ?AiFeedback $feedback)
{
if ($feedback && $feedback->rating === 'up') {
// Salva coppia query-risposta positiva
\Storage::append('ai-training-data.jsonl', json_encode([
'messages' => [
['role' => 'user', 'content' => $userMessage->content],
['role' => 'assistant', 'content' => $aiResponse->content],
],
'metadata' => [
'confidence' => $aiResponse->metadata['confidence'] ?? null,
'feedback' => 'positive',
],
]));
}
}
}
Blocco 54 php
protected function schedule(Schedule $schedule)
{
// Review AI performance mensile
$schedule->call(function () {
$analytics = app(AiAnalyticsService::class);
$report = $analytics->generateMonthlyReport();
// Invia report a admin
\Mail::to(config('ai.admin_email'))
->send(new MonthlyAiReportMail($report));
})->monthly();
}
Blocco 55 markdown
# Guida AI Assistant - Analisi Produzione
## Come Fare Domande Efficaci
### β
Buone Domande
- "Qual Γ¨ stata la produzione di ieri?"
- "Confronta questa settimana con la scorsa"
- "Chi Γ¨ l'operatore piΓΉ veloce?"
- "Mostrami il trend dell'ultimo mese"
### β Domande Poco Chiare
- "Dimmi qualcosa" (troppo vaga)
- "Ieri" (manca il contesto: produzione? tempo? operatore?)
- "PerchΓ©?" (senza riferimento a cosa)
## Esempi Pratici
### Scenario 1: Controllo Giornaliero
**Query:** "Qual Γ¨ stata la produzione di oggi?"
β L'AI ti dirΓ il totale pezzi prodotti oggi e lo confronterΓ con la media.
### Scenario 2: Identificare Problemi
**Query:** "PerchΓ© oggi abbiamo prodotto meno?"
β L'AI analizzerΓ fermi macchina, ritardi, operatori e ti darΓ le cause.
### Scenario 3: Pianificazione
**Query:** "Ce la facciamo a finire 3000 pezzi entro domani?"
β L'AI calcolerΓ la proiezione basata sul ritmo attuale.
## Tips & Tricks
- π‘ Usa il microfono per query vocali veloci
- π Dai feedback (pollice su/giΓΉ) per migliorare le risposte
- π Chiedi sempre "Mostrami un grafico" per visualizzare i dati
- π Usa i suggerimenti automatici per approfondire