Newer Older
f16725e 3 months ago History
607 lines | 16.65kb
Bogdan Timofte authored 3 months ago
1
package PredictionEngine;
2

            
3
use strict;
4
use warnings;
5
use DBI;
6
use HTTP::Tiny;
7
use JSON::XS;
8
use Math::Round;
9
use Config::Simple;
10
use Time::Piece;
11

            
12
=head1 NAME
13

            
14
PredictionEngine - AI-powered HDD failure prediction for autoSMART
15

            
16
=head1 DESCRIPTION
17

            
18
This module integrates with OpenAI's API to analyze SMART data trends and predict
19
HDD failures. It processes historical SMART data, generates feature vectors,
20
and uses GPT models for intelligent failure prediction.
21

            
22
=head1 SYNOPSIS
23

            
24
    use PredictionEngine;
25

            
26
    my $predictor = PredictionEngine->new(
27
        db_config     => '/path/to/database.conf',
28
        openai_config => '/path/to/openai.conf'
29
    );
30

            
31
    # Predict failure for specific drive
32
    my $prediction = $predictor->predict_failure('/dev/sda');
33

            
34
    # Analyze all drives
35
    my $results = $predictor->analyze_all_drives();
36

            
37
=cut
38

            
39
sub new {
40
    my ($class, %args) = @_;
41

            
42
    my $self = {
43
        db_config     => $args{db_config} || '/etc/autosmart/database.conf',
44
        openai_config => $args{openai_config} || '/etc/autosmart/openai.conf',
45
        debug         => $args{debug} || 0,
46
        db_handle     => undef,
47
        openai_key    => '',
48
        model         => 'gpt-4',
49
        http_client   => HTTP::Tiny->new(timeout => 30),
50
    };
51

            
52
    bless $self, $class;
53
    $self->_load_config();
54
    $self->_connect_database();
55

            
56
    return $self;
57
}
58

            
59
=head2 _load_config
60

            
61
Load OpenAI configuration
62

            
63
=cut
64

            
65
sub _load_config {
66
    my $self = shift;
67

            
68
    my $cfg = Config::Simple->new($self->{openai_config})
69
        or die "Cannot load OpenAI config: $self->{openai_config}";
70

            
71
    $self->{openai_key} = $cfg->param('openai.api_key')
72
        or die "OpenAI API key not configured";
73

            
74
    $self->{model}      = $cfg->param('openai.model') || 'gpt-4';
75
    $self->{max_tokens} = $cfg->param('openai.max_tokens') || 1000;
76
    $self->{temperature} = $cfg->param('openai.temperature') || 0.3;
77

            
78
    $self->_log("OpenAI configuration loaded (model: $self->{model})");
79
}
80

            
81
=head2 _connect_database
82

            
83
Establish PostgreSQL database connection
84

            
85
=cut
86

            
87
sub _connect_database {
88
    my $self = shift;
89

            
90
    my $cfg = Config::Simple->new($self->{db_config})
91
        or die "Cannot load database config: $self->{db_config}";
92

            
93
    my $dsn = sprintf("DBI:Pg:database=%s;host=%s;port=%s",
94
        $cfg->param('database.database'),
95
        $cfg->param('database.host'),
96
        $cfg->param('database.port')
97
    );
98

            
99
    $self->{db_handle} = DBI->connect(
100
        $dsn,
101
        $cfg->param('database.username'),
102
        $cfg->param('database.password'),
103
        {
104
            RaiseError => 1,
105
            AutoCommit => 1,
106
            pg_enable_utf8 => 1
107
        }
108
    ) or die "Database connection failed: $DBI::errstr";
109

            
110
    $self->_log("Database connection established");
111
}
112

            
113
=head2 get_drive_smart_history
114

            
115
Retrieve SMART data history for a drive
116

            
117
=cut
118

            
119
sub get_drive_smart_history {
120
    my ($self, $device_path, $days_back) = @_;
121

            
122
    $days_back ||= 90;  # Default 3 months
123

            
124
    my $sql = q{
125
        SELECT
126
            sr.timestamp,
127
            sr.temperature,
128
            sr.parameters_json,
129
            hi.model_name,
130
            hi.serial_number,
131
            hi.size_gb
132
        FROM smart_readings sr
133
        JOIN hdd_inventory hi ON sr.device_path = hi.device_path
134
        WHERE sr.device_path = ?
135
        AND sr.timestamp >= NOW() - INTERVAL ? DAY
136
        ORDER BY sr.timestamp ASC
137
    };
138

            
139
    my $sth = $self->{db_handle}->prepare($sql);
140
    $sth->execute($device_path, $days_back);
141

            
142
    my @history = ();
143
    while (my $row = $sth->fetchrow_hashref()) {
144
        $row->{parameters} = decode_json($row->{parameters_json});
145
        delete $row->{parameters_json};
146
        push @history, $row;
147
    }
148

            
149
    return \@history;
150
}
151

            
152
=head2 analyze_smart_trends
153

            
154
Analyze SMART parameter trends for patterns
155

            
156
=cut
157

            
158
sub analyze_smart_trends {
159
    my ($self, $history) = @_;
160

            
161
    return {} unless @$history >= 5;  # Need minimum data points
162

            
163
    my $trends = {};
164
    my $critical_params = [
165
        'Reallocated_Sector_Ct',
166
        'Spin_Retry_Count',
167
        'Reallocated_Event_Count',
168
        'Current_Pending_Sector',
169
        'Offline_Uncorrectable',
170
        'UDMA_CRC_Error_Count',
171
        'Raw_Read_Error_Rate'
172
    ];
173

            
174
    # Analyze each critical parameter
175
    foreach my $param_name (@$critical_params) {
176
        my @values = ();
177
        my @timestamps = ();
178

            
179
        # Extract values for this parameter
180
        foreach my $reading (@$history) {
181
            next unless exists $reading->{parameters}->{$param_name};
182

            
183
            push @values, $reading->{parameters}->{$param_name}->{raw_value};
184
            push @timestamps, $reading->{timestamp};
185
        }
186

            
187
        next unless @values >= 3;
188

            
189
        # Calculate trend statistics
190
        my $trend_analysis = $self->_calculate_trend_stats(\@values, \@timestamps);
191

            
192
        $trends->{$param_name} = {
193
            current_value => $values[-1],
194
            min_value     => $trend_analysis->{min},
195
            max_value     => $trend_analysis->{max},
196
            slope         => $trend_analysis->{slope},
197
            volatility    => $trend_analysis->{volatility},
198
            data_points   => scalar(@values),
199
            concerning    => $self->_is_trend_concerning($param_name, $trend_analysis),
200
        };
201
    }
202

            
203
    # Analyze temperature trends
204
    my @temperatures = map { $_->{temperature} } @$history;
205
    if (@temperatures >= 3) {
206
        my @temp_timestamps = map { $_->{timestamp} } @$history;
207
        my $temp_stats = $self->_calculate_trend_stats(\@temperatures, \@temp_timestamps);
208

            
209
        $trends->{temperature} = {
210
            current_temp  => $temperatures[-1],
211
            avg_temp      => $temp_stats->{mean},
212
            max_temp      => $temp_stats->{max},
213
            slope         => $temp_stats->{slope},
214
            concerning    => ($temp_stats->{max} > 60 || $temp_stats->{slope} > 0.1),
215
        };
216
    }
217

            
218
    return $trends;
219
}
220

            
221
=head2 _calculate_trend_stats
222

            
223
Calculate statistical metrics for trend analysis
224

            
225
=cut
226

            
227
sub _calculate_trend_stats {
228
    my ($self, $values, $timestamps) = @_;
229

            
230
    return {} unless @$values >= 2;
231

            
232
    # Basic statistics
233
    my $sum = 0;
234
    my $min = $values->[0];
235
    my $max = $values->[0];
236

            
237
    foreach my $val (@$values) {
238
        $sum += $val;
239
        $min = $val if $val < $min;
240
        $max = $val if $val > $max;
241
    }
242

            
243
    my $mean = $sum / @$values;
244

            
245
    # Calculate variance
246
    my $variance = 0;
247
    foreach my $val (@$values) {
248
        $variance += ($val - $mean) ** 2;
249
    }
250
    $variance /= (@$values - 1) if @$values > 1;
251

            
252
    # Simple linear regression for slope
253
    my $slope = 0;
254
    if (@$values >= 2) {
255
        my $n = @$values;
256
        my $sum_x = 0;
257
        my $sum_y = 0;
258
        my $sum_xy = 0;
259
        my $sum_x2 = 0;
260

            
261
        for my $i (0..$#$values) {
262
            my $x = $i;  # Use index as x (time progression)
263
            my $y = $values->[$i];
264

            
265
            $sum_x += $x;
266
            $sum_y += $y;
267
            $sum_xy += $x * $y;
268
            $sum_x2 += $x * $x;
269
        }
270

            
271
        my $denominator = $n * $sum_x2 - $sum_x * $sum_x;
272
        if ($denominator != 0) {
273
            $slope = ($n * $sum_xy - $sum_x * $sum_y) / $denominator;
274
        }
275
    }
276

            
277
    return {
278
        min        => $min,
279
        max        => $max,
280
        mean       => $mean,
281
        variance   => $variance,
282
        volatility => sqrt($variance),
283
        slope      => $slope,
284
    };
285
}
286

            
287
=head2 _is_trend_concerning
288

            
289
Determine if a SMART parameter trend is concerning
290

            
291
=cut
292

            
293
sub _is_trend_concerning {
294
    my ($self, $param_name, $stats) = @_;
295

            
296
    # Critical parameters that should never increase
297
    my $critical_increasing = {
298
        'Reallocated_Sector_Ct'     => 0,
299
        'Reallocated_Event_Count'   => 0,
300
        'Current_Pending_Sector'    => 0,
301
        'Offline_Uncorrectable'     => 0,
302
        'Spin_Retry_Count'          => 10,
303
    };
304

            
305
    if (exists $critical_increasing->{$param_name}) {
306
        my $threshold = $critical_increasing->{$param_name};
307

            
308
        return 1 if $stats->{max} > $threshold;
309
        return 1 if $stats->{slope} > 0.1 && $stats->{max} > 0;
310
    }
311

            
312
    # High volatility is concerning
313
    return 1 if $stats->{volatility} > ($stats->{mean} * 0.5) && $stats->{mean} > 0;
314

            
315
    return 0;
316
}
317

            
318
=head2 predict_failure
319

            
320
Generate AI-powered failure prediction for a drive
321

            
322
=cut
323

            
324
sub predict_failure {
325
    my ($self, $device_path, $days_back) = @_;
326

            
327
    $days_back ||= 90;
328

            
329
    # Get SMART history
330
    my $history = $self->get_drive_smart_history($device_path, $days_back);
331

            
332
    unless (@$history >= 5) {
333
        return {
334
            device_path => $device_path,
335
            prediction  => 'insufficient_data',
336
            confidence  => 0,
337
            risk_level  => 'unknown',
338
            message     => 'Insufficient historical data for prediction'
339
        };
340
    }
341

            
342
    # Analyze trends
343
    my $trends = $self->analyze_smart_trends($history);
344

            
345
    # Generate AI prompt
346
    my $prompt = $self->_generate_prediction_prompt($device_path, $history, $trends);
347

            
348
    # Call OpenAI API
349
    my $ai_response = $self->_call_openai_api($prompt);
350

            
351
    # Parse and store prediction
352
    my $prediction = $self->_parse_prediction_response($ai_response, $device_path);
353

            
354
    # Store prediction in database
355
    $self->_store_prediction($prediction);
356

            
357
    return $prediction;
358
}
359

            
360
=head2 _generate_prediction_prompt
361

            
362
Generate detailed prompt for OpenAI API
363

            
364
=cut
365

            
366
sub _generate_prediction_prompt {
367
    my ($self, $device_path, $history, $trends) = @_;
368

            
369
    my $drive_info = $history->[0];  # Basic drive info from first record
370

            
371
    my $prompt = "You are an expert HDD failure prediction system analyzing SMART data.\n\n";
372

            
373
    $prompt .= "DRIVE INFORMATION:\n";
374
    $prompt .= "- Device: $device_path\n";
375
    $prompt .= "- Model: " . ($drive_info->{model_name} || 'Unknown') . "\n";
376
    $prompt .= "- Serial: " . ($drive_info->{serial_number} || 'Unknown') . "\n";
377
    $prompt .= "- Size: " . ($drive_info->{size_gb} || 'Unknown') . " GB\n";
378
    $prompt .= "- Data Points: " . scalar(@$history) . " readings\n\n";
379

            
380
    $prompt .= "CRITICAL SMART PARAMETER ANALYSIS:\n";
381

            
382
    foreach my $param_name (sort keys %$trends) {
383
        next if $param_name eq 'temperature';
384

            
385
        my $trend = $trends->{$param_name};
386
        $prompt .= "- $param_name:\n";
387
        $prompt .= "  * Current: $trend->{current_value}\n";
388
        $prompt .= "  * Range: $trend->{min_value} - $trend->{max_value}\n";
389
        $prompt .= "  * Slope: " . sprintf("%.4f", $trend->{slope}) . "\n";
390
        $prompt .= "  * Volatility: " . sprintf("%.2f", $trend->{volatility}) . "\n";
391
        $prompt .= "  * Concerning: " . ($trend->{concerning} ? 'YES' : 'No') . "\n";
392
    }
393

            
394
    if (exists $trends->{temperature}) {
395
        my $temp = $trends->{temperature};
396
        $prompt .= "\nTEMPERATURE ANALYSIS:\n";
397
        $prompt .= "- Current: $temp->{current_temp}°C\n";
398
        $prompt .= "- Average: " . sprintf("%.1f", $temp->{avg_temp}) . "°C\n";
399
        $prompt .= "- Maximum: $temp->{max_temp}°C\n";
400
        $prompt .= "- Trend: " . sprintf("%.3f", $temp->{slope}) . "°C per reading\n";
401
    }
402

            
403
    $prompt .= "\nPLEASE ANALYZE THIS DATA AND PROVIDE:\n";
404
    $prompt .= "1. Overall failure risk assessment (LOW/MODERATE/HIGH/CRITICAL)\n";
405
    $prompt .= "2. Confidence level (0-100%)\n";
406
    $prompt .= "3. Estimated time to failure (if applicable)\n";
407
    $prompt .= "4. Key concerning indicators\n";
408
    $prompt .= "5. Recommended actions\n\n";
409

            
410
    $prompt .= "Format your response as JSON with fields: risk_level, confidence, time_to_failure_days, concerns, recommendations, reasoning\n";
411

            
412
    return $prompt;
413
}
414

            
415
=head2 _call_openai_api
416

            
417
Make API call to OpenAI
418

            
419
=cut
420

            
421
sub _call_openai_api {
422
    my ($self, $prompt) = @_;
423

            
424
    my $payload = {
425
        model => $self->{model},
426
        messages => [
427
            {
428
                role => 'system',
429
                content => 'You are an expert HDD failure prediction system with deep knowledge of SMART parameters and drive reliability patterns.'
430
            },
431
            {
432
                role => 'user',
433
                content => $prompt
434
            }
435
        ],
436
        max_tokens => $self->{max_tokens},
437
        temperature => $self->{temperature},
438
    };
439

            
440
    my $response = $self->{http_client}->post(
441
        'https://api.openai.com/v1/chat/completions',
442
        {
443
            headers => {
444
                'Authorization' => "Bearer $self->{openai_key}",
445
                'Content-Type'  => 'application/json',
446
            },
447
            content => encode_json($payload)
448
        }
449
    );
450

            
451
    unless ($response->{success}) {
452
        die "OpenAI API call failed: $response->{status} $response->{reason}";
453
    }
454

            
455
    my $result = decode_json($response->{content});
456

            
457
    return $result->{choices}->[0]->{message}->{content};
458
}
459

            
460
=head2 _parse_prediction_response
461

            
462
Parse OpenAI response into structured prediction
463

            
464
=cut
465

            
466
sub _parse_prediction_response {
467
    my ($self, $ai_response, $device_path) = @_;
468

            
469
    my $prediction = {
470
        device_path => $device_path,
471
        timestamp   => time(),
472
        prediction  => 'unknown',
473
        confidence  => 0,
474
        risk_level  => 'unknown',
475
        message     => $ai_response,
476
    };
477

            
478
    # Try to parse JSON response
479
    eval {
480
        my $parsed = decode_json($ai_response);
481

            
482
        $prediction->{risk_level} = lc($parsed->{risk_level}) if $parsed->{risk_level};
483
        $prediction->{confidence} = $parsed->{confidence} if defined $parsed->{confidence};
484
        $prediction->{time_to_failure_days} = $parsed->{time_to_failure_days} if $parsed->{time_to_failure_days};
485
        $prediction->{concerns} = $parsed->{concerns} if $parsed->{concerns};
486
        $prediction->{recommendations} = $parsed->{recommendations} if $parsed->{recommendations};
487
        $prediction->{reasoning} = $parsed->{reasoning} if $parsed->{reasoning};
488

            
489
        $prediction->{prediction} = 'success';
490
    };
491

            
492
    if ($@) {
493
        $self->_log("Failed to parse AI response as JSON, using raw text");
494
        $prediction->{prediction} = 'text_response';
495

            
496
        # Try to extract basic info from text
497
        if ($ai_response =~ /risk.*?:.*?(low|moderate|high|critical)/i) {
498
            $prediction->{risk_level} = lc($1);
499
        }
500

            
501
        if ($ai_response =~ /confidence.*?:.*?(\d+)/i) {
502
            $prediction->{confidence} = $1;
503
        }
504
    }
505

            
506
    return $prediction;
507
}
508

            
509
=head2 _store_prediction
510

            
511
Store prediction results in database
512

            
513
=cut
514

            
515
sub _store_prediction {
516
    my ($self, $prediction) = @_;
517

            
518
    my $sql = q{
519
        INSERT INTO predictions
520
        (device_path, timestamp, risk_level, confidence, time_to_failure_days,
521
         concerns, recommendations, reasoning, raw_response)
522
        VALUES (?, to_timestamp(?), ?, ?, ?, ?, ?, ?, ?)
523
    };
524

            
525
    $self->{db_handle}->do($sql,
526
        undef,
527
        $prediction->{device_path},
528
        $prediction->{timestamp},
529
        $prediction->{risk_level},
530
        $prediction->{confidence},
531
        $prediction->{time_to_failure_days},
532
        $prediction->{concerns},
533
        $prediction->{recommendations},
534
        $prediction->{reasoning},
535
        $prediction->{message}
536
    );
537
}
538

            
539
=head2 analyze_all_drives
540

            
541
Run predictions for all active drives
542

            
543
=cut
544

            
545
sub analyze_all_drives {
546
    my $self = shift;
547

            
548
    my $sql = q{
549
        SELECT device_path, model_name, serial_number
550
        FROM hdd_inventory
551
        WHERE status = 'active'
552
        ORDER BY device_path
553
    };
554

            
555
    my $sth = $self->{db_handle}->prepare($sql);
556
    $sth->execute();
557

            
558
    my @results = ();
559

            
560
    while (my $row = $sth->fetchrow_hashref()) {
561
        my $prediction = $self->predict_failure($row->{device_path});
562
        push @results, $prediction;
563

            
564
        # Rate limiting - small delay between API calls
565
        sleep(1);
566
    }
567

            
568
    return \@results;
569
}
570

            
571
=head2 _log
572

            
573
Internal logging method
574

            
575
=cut
576

            
577
sub _log {
578
    my ($self, $message) = @_;
579

            
580
    my $timestamp = scalar(localtime());
581
    print "[$timestamp] PredictionEngine: $message\n" if $self->{debug};
582
}
583

            
584
=head2 DESTROY
585

            
586
Cleanup database connection
587

            
588
=cut
589

            
590
sub DESTROY {
591
    my $self = shift;
592
    $self->{db_handle}->disconnect() if $self->{db_handle};
593
}
594

            
595
1;
596

            
597
__END__
598

            
599
=head1 AUTHOR
600

            
601
AutoSMART Development Team
602

            
603
=head1 LICENSE
604

            
605
This software is part of the autoSMART project.
606

            
607
=cut