use llm_shield_core::{
async_trait, Entity, Error, Result, RiskFactor, ScanResult, Scanner, ScannerType, Severity,
Vault,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToxicityConfig {
pub threshold: f32,
pub model_path: Option<PathBuf>,
pub tokenizer_path: Option<PathBuf>,
pub max_length: usize,
pub use_fallback: bool,
pub categories: Vec<ToxicityCategory>,
}
impl Default for ToxicityConfig {
fn default() -> Self {
Self {
threshold: 0.7,
model_path: None,
tokenizer_path: None,
max_length: 512,
use_fallback: true,
categories: vec![
ToxicityCategory::Toxic,
ToxicityCategory::SevereToxic,
ToxicityCategory::Obscene,
ToxicityCategory::Threat,
ToxicityCategory::Insult,
ToxicityCategory::IdentityHate,
],
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ToxicityCategory {
Toxic,
SevereToxic,
Obscene,
Threat,
Insult,
IdentityHate,
}
impl ToxicityCategory {
pub fn as_str(&self) -> &'static str {
match self {
ToxicityCategory::Toxic => "toxic",
ToxicityCategory::SevereToxic => "severe_toxic",
ToxicityCategory::Obscene => "obscene",
ToxicityCategory::Threat => "threat",
ToxicityCategory::Insult => "insult",
ToxicityCategory::IdentityHate => "identity_hate",
}
}
}
pub struct Toxicity {
config: ToxicityConfig,
}
impl Toxicity {
pub fn new(config: ToxicityConfig) -> Result<Self> {
if !(0.0..=1.0).contains(&config.threshold) {
return Err(Error::config("Threshold must be between 0.0 and 1.0"));
}
Ok(Self { config })
}
pub fn default_config() -> Result<Self> {
Self::new(ToxicityConfig::default())
}
fn detect_heuristic(&self, text: &str) -> (f32, Vec<ToxicityMatch>) {
let text_lower = text.to_lowercase();
let mut matches = Vec::new();
let mut max_score: f32 = 0.0;
let severe_toxic = [
"f**k", "s**t", "b**ch", "a**hole", "d**n", "c**t",
];
let insults = [
"idiot", "stupid", "dumb", "moron", "fool", "loser",
"pathetic", "useless", "worthless", "incompetent",
];
let threats = [
"kill you", "hurt you", "destroy you", "i'll get you",
"you're dead", "watch out", "be careful",
];
let identity_hate = [
"hate", "racist", "sexist", "bigot",
];
for pattern in &severe_toxic {
if text_lower.contains(&pattern.replace('*', "")) {
matches.push(ToxicityMatch {
category: ToxicityCategory::SevereToxic,
score: 0.95,
text: pattern.to_string(),
});
max_score = max_score.max(0.95f32);
}
}
let mut insult_count = 0;
for pattern in &insults {
if text_lower.contains(pattern) {
insult_count += 1;
}
}
if insult_count > 0 {
let score = (0.6 + insult_count as f32 * 0.1).min(0.9);
matches.push(ToxicityMatch {
category: ToxicityCategory::Insult,
score,
text: format!("{} insults", insult_count),
});
max_score = f32::max(max_score, score);
}
for pattern in &threats {
if text_lower.contains(pattern) {
matches.push(ToxicityMatch {
category: ToxicityCategory::Threat,
score: 0.9,
text: pattern.to_string(),
});
max_score = max_score.max(0.9f32);
}
}
let mut hate_count = 0;
for pattern in &identity_hate {
if text_lower.contains(pattern) {
hate_count += 1;
}
}
if hate_count > 0 {
let score = (0.7 + hate_count as f32 * 0.1).min(0.95);
matches.push(ToxicityMatch {
category: ToxicityCategory::IdentityHate,
score,
text: format!("{} hate indicators", hate_count),
});
max_score = f32::max(max_score, score);
}
if matches.len() >= 2 {
let general_score = (matches.iter().map(|m| m.score).sum::<f32>() / matches.len() as f32).min(1.0);
if general_score > max_score {
matches.push(ToxicityMatch {
category: ToxicityCategory::Toxic,
score: general_score,
text: "multiple toxic indicators".to_string(),
});
max_score = max_score.max(general_score);
}
}
(max_score, matches)
}
}
#[derive(Debug, Clone)]
struct ToxicityMatch {
category: ToxicityCategory,
score: f32,
text: String,
}
#[async_trait]
impl Scanner for Toxicity {
fn name(&self) -> &str {
"Toxicity"
}
async fn scan(&self, input: &str, _vault: &Vault) -> Result<ScanResult> {
let (max_score, matches) = self.detect_heuristic(input);
if max_score < self.config.threshold {
return Ok(ScanResult::pass(input.to_string())
.with_metadata("toxicity_score", max_score.to_string()));
}
let entities: Vec<Entity> = matches
.iter()
.map(|m| {
let mut metadata = HashMap::new();
metadata.insert("category".to_string(), m.category.as_str().to_string());
metadata.insert("score".to_string(), m.score.to_string());
Entity {
entity_type: "toxicity".to_string(),
text: format!("[{}: {}]", m.category.as_str(), m.text),
start: 0,
end: input.len(),
confidence: m.score,
metadata,
}
})
.collect();
let severity = if max_score >= 0.9 {
Severity::Critical
} else if max_score >= 0.7 {
Severity::High
} else {
Severity::Medium
};
let description = format!("Detected {} toxicity indicator(s)", matches.len());
let risk_factor = RiskFactor::new(
"toxic_content",
&description,
severity,
max_score,
);
let mut result = ScanResult::new(input.to_string(), false, max_score)
.with_risk_factor(risk_factor)
.with_metadata("toxicity_score", max_score.to_string())
.with_metadata("categories", matches.len());
for entity in entities {
result = result.with_entity(entity);
}
Ok(result)
}
fn scanner_type(&self) -> ScannerType {
ScannerType::Input
}
fn description(&self) -> &str {
"Detects toxic, offensive, or harmful content using ML-based classification"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_toxicity_insults() {
let scanner = Toxicity::default_config().unwrap();
let vault = Vault::new();
let toxic = "You are an idiot and completely stupid";
let result = scanner.scan(toxic, &vault).await.unwrap();
assert!(!result.is_valid);
assert!(result.risk_score > 0.5);
}
#[tokio::test]
async fn test_toxicity_threats() {
let scanner = Toxicity::default_config().unwrap();
let vault = Vault::new();
let threat = "I will kill you if you don't listen";
let result = scanner.scan(threat, &vault).await.unwrap();
assert!(!result.is_valid);
assert!(result.risk_score > 0.7);
}
#[tokio::test]
async fn test_toxicity_clean_text() {
let scanner = Toxicity::default_config().unwrap();
let vault = Vault::new();
let clean = "Thank you for your help, I really appreciate it!";
let result = scanner.scan(clean, &vault).await.unwrap();
assert!(result.is_valid);
assert!(result.risk_score < 0.7);
}
#[tokio::test]
async fn test_toxicity_identity_hate() {
let scanner = Toxicity::default_config().unwrap();
let vault = Vault::new();
let hate = "I hate all people from that group, they are racist";
let result = scanner.scan(hate, &vault).await.unwrap();
assert!(!result.is_valid);
}
#[tokio::test]
async fn test_toxicity_threshold() {
let config = ToxicityConfig {
threshold: 0.95,
..Default::default()
};
let scanner = Toxicity::new(config).unwrap();
let vault = Vault::new();
let mild_toxic = "You are somewhat foolish";
let result = scanner.scan(mild_toxic, &vault).await.unwrap();
assert!(result.is_valid || result.risk_score < 0.95);
}
#[tokio::test]
async fn test_toxicity_multiple_categories() {
let scanner = Toxicity::default_config().unwrap();
let vault = Vault::new();
let multi_toxic = "You are an idiot and I will hurt you";
let result = scanner.scan(multi_toxic, &vault).await.unwrap();
assert!(!result.is_valid);
assert!(result.entities.len() >= 2);
}
#[tokio::test]
async fn test_toxicity_category_as_str() {
assert_eq!(ToxicityCategory::Toxic.as_str(), "toxic");
assert_eq!(ToxicityCategory::SevereToxic.as_str(), "severe_toxic");
assert_eq!(ToxicityCategory::Obscene.as_str(), "obscene");
assert_eq!(ToxicityCategory::Threat.as_str(), "threat");
assert_eq!(ToxicityCategory::Insult.as_str(), "insult");
assert_eq!(ToxicityCategory::IdentityHate.as_str(), "identity_hate");
}
}