AI spam classification: the model's opinion as one bounded spam signal, and the llm_prompt Sieve function (AI-1 to AI-28)
The classifier sends only the subject and text, between unforgeable markers after the operator's prompt, to an OpenAI-compatible endpoint the operator configured; nothing is preset. Its answer maps to an LLM_ tag whose score is clamped (+5.0, -1.0 by default) and can never discard or reject on its own; X-Spam-LLM is sanitized, encoded and folded, and a planted one is removed. Failures, timeouts past the ceiling, a full slot or a paused model leave mail flowing untagged. llm_prompt answers trusted scripts, and accounts holding interactAi within an hourly limit. Redirects aren't followed and no content or secret is logged. The limits live in inbuxa:AiLimits. Acceptance tests 1 and 3 to 21; test 2 as the re-enabled shared llm case, whose setup no longer waits on a rules file from a developer's own path; test 22 written as the ignored ai_compat.
This commit is contained in:
@@ -11,6 +11,7 @@ store = { path = "../store" }
|
||||
trc = { path = "../trc" }
|
||||
common = { path = "../common" }
|
||||
registry = { path = "../registry" }
|
||||
inbuxa-features = { path = "../features" }
|
||||
smtp-proto = { version = "0.2", features = ["rkyv"] }
|
||||
mail-parser = { version = "0.11", features = ["full_encoding"] }
|
||||
mail-auth = { version = "0.13" }
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! The language model's opinion as one spam signal (AI spam classification
|
||||
//! spec, AI-3, AI-4, AI-6, AI-9 to AI-13, AI-16). Any failure leaves the
|
||||
//! message scored as if the classifier were off.
|
||||
|
||||
use crate::{SpamFilterContext, TextPart};
|
||||
use common::{
|
||||
Server,
|
||||
config::mailstore::spamfilter::SpamFilterAction,
|
||||
enterprise::llm::Call,
|
||||
};
|
||||
use inbuxa_features::ai::{answer, request};
|
||||
use registry::schema::structs::SpamLlm;
|
||||
use std::future::Future;
|
||||
use types::id::Id;
|
||||
|
||||
pub trait SpamFilterAnalyzeLlm: Sync + Send {
|
||||
fn spam_filter_analyze_llm(
|
||||
&self,
|
||||
ctx: &mut SpamFilterContext<'_>,
|
||||
) -> impl Future<Output = ()> + Send;
|
||||
}
|
||||
|
||||
impl SpamFilterAnalyzeLlm for Server {
|
||||
async fn spam_filter_analyze_llm(&self, ctx: &mut SpamFilterContext<'_>) {
|
||||
// Read now, so a change applies to the next message (AI-18)
|
||||
let Ok(Some(SpamLlm::Enable(settings))) =
|
||||
self.registry().object::<SpamLlm>(Id::singleton()).await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
// AI-16: local users' own mail isn't sent to a model, and neither is
|
||||
// mail another tag already discards or rejects
|
||||
if ctx.input.authenticated_as.is_some_and(|a| !a.is_empty())
|
||||
|| ctx.result.tags.iter().any(|tag| {
|
||||
matches!(
|
||||
self.core.spam.lists.scores.get(tag),
|
||||
Some(SpamFilterAction::Discard | SpamFilterAction::Reject)
|
||||
)
|
||||
})
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(model) = self.ai_model_by_id(settings.model_id).await else {
|
||||
return;
|
||||
};
|
||||
let limits = self.ai_limits().await;
|
||||
|
||||
// AI-3: the subject and the message's text, nothing else
|
||||
let text = ctx
|
||||
.output
|
||||
.text_parts
|
||||
.iter()
|
||||
.filter_map(|part| match part {
|
||||
TextPart::Plain { text_body, .. } => Some(*text_body),
|
||||
TextPart::Html { text_body, .. } => Some(text_body.as_str()),
|
||||
TextPart::None => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n");
|
||||
let nonce = request::nonce();
|
||||
let user = request::classification_text(
|
||||
&ctx.output.subject,
|
||||
&text,
|
||||
limits.max_content_bytes as usize,
|
||||
&nonce,
|
||||
);
|
||||
let system = request::system_text(&settings.prompt);
|
||||
|
||||
// AI-9: never longer than the ceiling, whatever the model's timeout
|
||||
let timeout = model
|
||||
.timeout
|
||||
.into_inner()
|
||||
.min(limits.spam_call_ceiling.into_inner());
|
||||
let Ok(reply) = self
|
||||
.ai_call(Call {
|
||||
model_id: settings.model_id,
|
||||
model: &model,
|
||||
account_id: None,
|
||||
system: Some(&system),
|
||||
user: &user,
|
||||
temperature: settings.temperature.into_inner(),
|
||||
max_tokens: request::CLASSIFY_MAX_TOKENS,
|
||||
timeout,
|
||||
})
|
||||
.await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let categories = settings.categories.iter().cloned().collect::<Vec<_>>();
|
||||
let confidence = settings.confidence.iter().cloned().collect::<Vec<_>>();
|
||||
let rules = answer::Rules {
|
||||
separator: &settings.separator,
|
||||
pos_category: settings.response_pos_category as usize,
|
||||
pos_confidence: settings.response_pos_confidence.map(|p| p as usize),
|
||||
pos_explanation: settings.response_pos_explanation.map(|p| p as usize),
|
||||
categories: &categories,
|
||||
confidence: &confidence,
|
||||
};
|
||||
if let Some(classified) = answer::parse(&reply, &rules) {
|
||||
ctx.result.tags.insert(classified.tag.clone());
|
||||
ctx.result.llm_result = Some((
|
||||
classified.tag,
|
||||
classified.explanation.unwrap_or_default(),
|
||||
));
|
||||
ctx.result.llm_bounds = Some((
|
||||
limits.spam_max_added as f32,
|
||||
limits.spam_max_subtracted as f32,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ pub mod from;
|
||||
pub mod headers;
|
||||
pub mod html;
|
||||
pub mod init;
|
||||
pub mod llm; // inbuxa: AI spam classification
|
||||
pub mod ip;
|
||||
pub mod messageid;
|
||||
pub mod mime;
|
||||
|
||||
@@ -16,6 +16,7 @@ use crate::{
|
||||
recipient::SpamFilterAnalyzeRecipient, replyto::SpamFilterAnalyzeReplyTo,
|
||||
rules::SpamFilterAnalyzeRules, subject::SpamFilterAnalyzeSubject,
|
||||
url::SpamFilterAnalyzeUrl,
|
||||
llm::SpamFilterAnalyzeLlm,
|
||||
},
|
||||
};
|
||||
use common::{Server, config::mailstore::spamfilter::SpamFilterAction};
|
||||
@@ -55,6 +56,21 @@ impl SpamFilterAnalyzeScore for Server {
|
||||
let mut rbl_count = 0;
|
||||
|
||||
for tag in &ctx.result.tags {
|
||||
// inbuxa: AI-13: the model's tag moves the score only so far, and
|
||||
// never discards or rejects on its own
|
||||
if inbuxa_features::ai::answer::is_llm_tag(tag) {
|
||||
let (max_added, max_subtracted) = ctx.result.llm_bounds.unwrap_or((5.0, 1.0));
|
||||
let score = match self.core.spam.lists.scores.get(tag) {
|
||||
Some(SpamFilterAction::Allow(score)) => {
|
||||
inbuxa_features::ai::answer::clamp(*score, max_added, max_subtracted)
|
||||
}
|
||||
_ => 0.0,
|
||||
};
|
||||
ctx.result.score += score;
|
||||
header_len += tag.len() + 10;
|
||||
results.push((tag.as_str(), score));
|
||||
continue;
|
||||
}
|
||||
let score = match self.core.spam.lists.scores.get(tag) {
|
||||
Some(SpamFilterAction::Allow(score)) => *score,
|
||||
Some(SpamFilterAction::Discard) => {
|
||||
@@ -139,8 +155,12 @@ impl SpamFilterAnalyzeScore for Server {
|
||||
}
|
||||
headers.push_str("\r\n");
|
||||
|
||||
if let Some((category, explanation)) = &ctx.result.llm_result {
|
||||
let _ = write!(&mut headers, "X-Spam-LLM: {category} ({explanation})\r\n",);
|
||||
// inbuxa: AI-15: sanitized, encoded and folded
|
||||
if let Some((tag, explanation)) = &ctx.result.llm_result {
|
||||
headers.push_str(&inbuxa_features::ai::answer::header(
|
||||
tag,
|
||||
Some(explanation.as_str()).filter(|e| !e.is_empty()),
|
||||
));
|
||||
}
|
||||
|
||||
let is_spam = final_score >= self.core.spam.scores.spam_threshold;
|
||||
@@ -239,6 +259,10 @@ impl SpamFilterAnalyzeScore for Server {
|
||||
// Model classification
|
||||
self.spam_filter_analyze_classify(ctx).await;
|
||||
|
||||
// inbuxa: AI-17: the language model, after every other analysis and
|
||||
// before user-defined rules, so rules can test its tags
|
||||
self.spam_filter_analyze_llm(ctx).await;
|
||||
|
||||
// User-defined rules
|
||||
self.spam_filter_analyze_rules(ctx).await;
|
||||
|
||||
|
||||
@@ -113,6 +113,8 @@ pub struct SpamFilterResult {
|
||||
pub rbl_url_checks: usize,
|
||||
pub rbl_email_checks: usize,
|
||||
pub llm_result: Option<(String, String)>,
|
||||
// inbuxa: AI-13: how far the model's tag may move the score (added, subtracted)
|
||||
pub llm_bounds: Option<(f32, f32)>,
|
||||
}
|
||||
|
||||
pub struct SpamFilterContext<'x> {
|
||||
|
||||
Reference in New Issue
Block a user