svn commit: r1933706 - spamassassin/trunk/lib/Mail/SpamAssassin/Plugin

[email protected] Sat, 02 May 2026 12:58:03 -0000
Newsgroups gmane.mail.spam.spamassassin.cvs
Message-ID <177772668333.1601567.17238445622877228500@svn03-he-fi>
Author: gbechis
Date: Sat May  2 12:58:03 2026
New Revision: 1933706

Log:
improve caching
improve html handling, from fkento@
add more fine grained rules, from fkento@

Modified:
   spamassassin/trunk/lib/Mail/SpamAssassin/Plugin/NeuralNetwork.pm

Modified: spamassassin/trunk/lib/Mail/SpamAssassin/Plugin/NeuralNetwork.pm
==============================================================================
--- spamassassin/trunk/lib/Mail/SpamAssassin/Plugin/NeuralNetwork.pm	Sat May  2 12:45:04 2026	(r1933705)
+++ spamassassin/trunk/lib/Mail/SpamAssassin/Plugin/NeuralNetwork.pm	Sat May  2 12:58:03 2026	(r1933706)
@@ -44,7 +44,7 @@ use strict;
 use warnings;
 use re 'taint';
 
-my $VERSION = 0.8.2;
+my $VERSION = 0.8.3;
 
 use AI::FANN qw(:all);
 use Storable qw(store retrieve);
@@ -83,6 +83,7 @@ sub new {
   $self->set_config($mailsa->{conf});
   $self->register_eval_rule("check_neuralnetwork_spam", $Mail::SpamAssassin::Conf::TYPE_BODY_EVALS);
   $self->register_eval_rule("check_neuralnetwork_ham", $Mail::SpamAssassin::Conf::TYPE_BODY_EVALS);
+  $self->register_eval_rule("check_neuralnetwork", $Mail::SpamAssassin::Conf::TYPE_BODY_EVALS);
 
   return $self;
 }
@@ -207,6 +208,44 @@ prediction to run.
 
 =back
 
+=head1 EVAL RULES
+
+=over 4
+
+=item check_neuralnetwork_spam()
+
+Body eval rule. Returns true when the neural network prediction score exceeds
+C<neuralnetwork_spam_threshold> (default 0.6). Used by the built-in C<NN_SPAM>
+rule (score +1.0).
+
+=item check_neuralnetwork_ham()
+
+Body eval rule. Returns true when the neural network prediction score is below
+C<neuralnetwork_ham_threshold> (default 0.4). Used by the built-in C<NN_HAM>
+rule (score -1.0).
+
+=item check_neuralnetwork(low, high)
+
+Body eval rule accepting two optional floating-point arguments. Returns true
+when the raw prediction score falls within the inclusive range C<[low, high]>.
+Defaults: C<low = 0.0>, C<high = 1.0>.
+
+Use this rule to define finer-grained confidence tiers.
+
+  body      NN_CONFIDENT_SPAM  eval:check_neuralnetwork(0.75, 1.0)
+  describe  NN_CONFIDENT_SPAM  Email classified as spam with high confidence by Neural Network
+  score     NN_CONFIDENT_SPAM  2.0
+
+  body      NN_PROBABLE_SPAM   eval:check_neuralnetwork(0.55, 0.75)
+  describe  NN_PROBABLE_SPAM   Email classified as probable spam by Neural Network
+  score     NN_PROBABLE_SPAM   1.0
+
+  body      NN_PROBABLE_HAM    eval:check_neuralnetwork(0.0, 0.4)
+  describe  NN_PROBABLE_HAM    Email classified as ham by Neural Network
+  score     NN_PROBABLE_HAM    -1.0
+
+=back
+
 =cut
 
   push(@cmds, {
@@ -474,7 +513,7 @@ sub _text_to_features {
     if ($train == 0) {
       if ( ($vocabulary{_spam_count} < $min_spam) || ($vocabulary{_ham_count} < $min_ham) ) {
         dbg("Insufficient spam/ham data for prediction: spam=".$vocabulary{_spam_count}.", ham=".$vocabulary{_ham_count});
-        return ([], 0);
+        return ([], 0, []);
       }
     }
 
@@ -696,6 +735,24 @@ sub learn_message {
     $locker->safe_unlock($dataset_path);
     return;
   }
+  # Defer model creation until minimum training corpus is built
+  unless (defined $self->{neural_model}) {
+    my $min_spam = $conf->{neuralnetwork_min_spam_count};
+    my $min_ham  = $conf->{neuralnetwork_min_ham_count};
+    my $vocab = $self->{_last_train_vocab} // {};
+    my $sc = $vocab->{_spam_count} || 0;
+    my $hc = $vocab->{_ham_count}  || 0;
+    if ($sc < $min_spam || $hc < $min_ham) {
+      dbg("Deferring model creation: spam=$sc/$min_spam ham=$hc/$min_ham (vocabulary updated)");
+      # Record message as learned to prevent re-learning.
+      if (defined $msg && defined $msgid && length($msgid) > 0) {
+        $self->_save_msgid_to_neural_seen($msgid, $isspam);
+      }
+      $locker->safe_unlock($dataset_path);
+      return 0;
+    }
+  }
+
   # Two-layer hidden sizing: layer1 ~10% of inputs (clamped 32..512),
   # layer2 half of layer1 (min 16).
   my $num_hidden1 = int($num_input / 10);
@@ -1066,6 +1123,15 @@ sub check_neuralnetwork_ham {
   return $pms->{neuralnetwork_ham};
 }
 
+sub check_neuralnetwork {
+  my ($self, $pms, $low, $high) = @_;
+
+  _check_neuralnetwork($self, $pms);
+  my $pred = $pms->{neuralnetwork_prediction};
+  return 0 unless defined $pred;
+  return ($pred >= $low && $pred <= $high) ? 1 : 0;
+}
+
 # Compute chi-squared score measuring how discriminative a vocabulary term
 # is between spam and ham.  Returns 0 when there is insufficient data.
 sub _chi2_score {
@@ -1314,6 +1380,9 @@ sub _check_neuralnetwork {
   my $model_mtime   = (stat($dataset_path))[9] // 0;
   my $model_changed = $model_mtime > ($self->{_neural_model_load_time} // 0);
 
+  my $locker = $self->{main}->{locker};
+  my $network;
+
   if (!defined $self->{neural_model} || $model_expired || $model_changed) {
     if ($model_expired) {
       dbg("Model cache expired (age: ${model_age}s, ttl: ${ttl}s), reloading");
@@ -1321,7 +1390,6 @@ sub _check_neuralnetwork {
       dbg("Model file changed on disk, reloading");
     }
 
-    my $locker = $self->{main}->{locker};
     my $got_lock = 0;
     eval {
       $got_lock = $locker->safe_lock($dataset_path,
@@ -1331,7 +1399,8 @@ sub _check_neuralnetwork {
 
     my $file_mode = 0666 & ~umask();
     eval {
-      $self->{neural_model} = AI::FANN->new_from_file($dataset_path);
+      my $loaded = AI::FANN->new_from_file($dataset_path);
+      $self->{neural_model}           = $loaded;
       $self->{_neural_model_load_time} = time();
       1;
     } or do {
@@ -1376,9 +1445,15 @@ sub _check_neuralnetwork {
       }
     };
 
+    $network = $self->{neural_model};
     $locker->safe_unlock($dataset_path) if $got_lock;
+  } else {
+    my $got_snap_lock = 0;
+    eval { $got_snap_lock = $locker->safe_lock($dataset_path,
+        $self->{main}->{conf}->{neuralnetwork_lock_timeout}); 1 };
+    $network = $self->{neural_model};
+    $locker->safe_unlock($dataset_path) if $got_snap_lock;
   }
-  my $network = $self->{neural_model};
 
   my $stored_vocab_ref = $self->_load_model_vocab($nn_data_dir);
 
@@ -1779,6 +1854,33 @@ sub _extract_features_from_message {
   return \@tokens;
 }
 
+my %_HTML_ENTITIES = (
+  # core XML/HTML escapes
+  amp   => '&',   lt    => '<',   gt    => '>',
+  quot  => '"',   apos  => "'",   nbsp  => ' ',
+  # symbols
+  euro  => 'eur', pound => 'gbp', yen   => 'jpy',
+  cent  => 'cent', curren => ' ',
+  # dashes and quotation marks
+  mdash => '-',   ndash => '-',   minus => '-',   horbar => '-',
+  laquo => ' ',   raquo => ' ',
+  ldquo => '"',   rdquo => '"',   bdquo => '"',
+  lsquo => "'",   rsquo => "'",   sbquo => "'",
+  # other punctuation / typography
+  hellip => '...',
+  # accented letters
+  agrave => 'a',  aacute => 'a',  acirc  => 'a',  atilde => 'a',
+  auml   => 'a',  aring  => 'a',  aelig  => 'ae',
+  egrave => 'e',  eacute => 'e',  ecirc  => 'e',  euml   => 'e',
+  igrave => 'i',  iacute => 'i',  icirc  => 'i',  iuml   => 'i',
+  ograve => 'o',  oacute => 'o',  ocirc  => 'o',  otilde => 'o',
+  ouml   => 'o',  oslash => 'o',  oelig  => 'oe',
+  ugrave => 'u',  uacute => 'u',  ucirc  => 'u',  uuml   => 'u',
+  yacute => 'y',  yuml   => 'y',
+  ntilde => 'n',  ccedil => 'c',  szlig  => 'ss',
+  eth    => 'd',  thorn  => 'th',
+);
+
 sub _tokenize_text {
   my ($self, $conf, $text) = @_;
   return () unless defined $text;
@@ -1798,8 +1900,11 @@ sub _tokenize_text {
   $text =~ s/\-{2,}//g;
   # Remove tokens that could be a date
   $text =~ s/\b\d+(?:\-|\/)\d+(?:\-|\/)\d+\b//g;
-  # replace HTML entities and punctuation with spaces
-  $text =~ s/&[a-z#0-9]+;/ /g;
+  # Decode HTML entities to their text equivalents, then strip residue
+  $text =~ s/&([a-zA-Z]+);/exists $_HTML_ENTITIES{lc $1} ? $_HTML_ENTITIES{lc $1} : ' '/ge;
+  $text =~ s/&#x([0-9a-fA-F]{1,6});/chr(hex($1))/ge;
+  $text =~ s/&#([0-9]{1,7});/chr($1)/ge;
+  $text =~ s/&/ /g;
   $text =~ s{[^\p{L}\p{N}\-]}{ }g;
   my @tokens = grep { length($_) >= $min_word_len && length($_) <= $max_word_len } split /\s+/, $text;
   @tokens = grep { $_ !~ /^\d+$/ } @tokens;         # drop pure numbers
@@ -1900,11 +2005,19 @@ sub _save_model_vocab_to_sql {
       "UPDATE neural_vocabulary SET model_position = NULL WHERE username = ?",
       undef, lc($username)
     );
-    my $sth = $self->{dbh}->prepare(
-      "UPDATE neural_vocabulary SET model_position = ? WHERE username = ? AND keyword = ?"
-    );
+    my $upsert_sql;
+    if ($self->{main}->{conf}->{neuralnetwork_dsn} =~ /^dbi:(?:mysql|MariaDB)/i) {
+      $upsert_sql = "INSERT INTO neural_vocabulary (username, keyword, model_position)
+                     VALUES (?, ?, ?)
+                     ON DUPLICATE KEY UPDATE model_position = VALUES(model_position)";
+    } else {
+      $upsert_sql = "INSERT INTO neural_vocabulary (username, keyword, model_position)
+                     VALUES (?, ?, ?)
+                     ON CONFLICT (username, keyword) DO UPDATE SET model_position = excluded.model_position";
+    }
+    my $sth = $self->{dbh}->prepare($upsert_sql);
     for my $i (0 .. $#$vocab_keys_ref) {
-      $sth->execute($i, lc($username), $vocab_keys_ref->[$i]);
+      $sth->execute(lc($username), $vocab_keys_ref->[$i], $i);
     }
     $self->{dbh}->commit();
     dbg("Saved model vocabulary (" . scalar(@$vocab_keys_ref) . " terms) to SQL for user: $username");
@@ -1960,10 +2073,10 @@ sub _save_model_vocab {
 sub _load_model_vocab {
   my ($self, $nn_data_dir) = @_;
 
-  my $model_t = $self->{_neural_model_load_time} || 0;
-  if (defined $self->{_model_vocab_cache}
-      && ($self->{_model_vocab_cache_t} || 0) >= $model_t) {
-    return $self->{_model_vocab_cache};
+  my $ttl = $self->{main}->{conf}->{neuralnetwork_cache_ttl} || 0;
+  if ($ttl > 0 && defined $self->{_model_vocab_cache}) {
+    my $age = time() - ($self->{_model_vocab_cache_t} || 0);
+    return $self->{_model_vocab_cache} if $age < $ttl;
   }
 
   my $vocab_ref;