Skip to content

Commit

Permalink
Merge pull request #2 from diego-ninja/feature/improved_detection
Browse files Browse the repository at this point in the history
feat(detection): improves local checker detection
  • Loading branch information
diego-ninja authored Dec 6, 2024
2 parents 4b5ad22 + eb23f09 commit 9577793
Show file tree
Hide file tree
Showing 35 changed files with 1,347 additions and 118 deletions.
3 changes: 1 addition & 2 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
CENSOR_THRESHOLD_SCORE=0.7
CENSOR_DEFAULT_LANGUAGE=en
CENSOR_LANGUAGES=en,es,fr
CENSOR_MASK_CHAR=*
TISANE_AI_API_KEY=
PERSPECTIVE_AI_API_KEY=
PERSPECTIVE_AI_THRESHOLD=0.7
PERSPECTIVE_AI_ATTRIBUTES=TOXICITY,SEVERE_TOXICITY,IDENTITY_ATTACK,INSULT,PROFANITY,THREAT,SEXUALLY
AZURE_AI_API_KEY=
AZURE_AI_ENDPOINT=
AZURE_AI_VERSION=2024-09-01
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ The package configuration file will be published at `config/censor.php`. Here yo
Some services require API keys. Add these to your `.env` file:

```env
CENSOR_THRESHOLD_SCORE=0.5
PERSPECTIVE_AI_API_KEY=your-perspective-api-key
TISANE_AI_API_KEY=your-tisane-api-key
AZURE_AI_API_KEY=your-azure-api-key
Expand Down Expand Up @@ -206,6 +207,22 @@ This ensures unique caching for:
- Same service checking different texts
- Different environments using the same cache store

## Detection Mechanism

The local checker uses a multi-strategy approach to detect offensive content accurately. Each piece of text is processed through different detection strategies in sequence:

1. **Pattern Strategy**: Handles exact matches and character substitutions (like '@' for 'a', '1' for 'i'). This is the primary detection method and uses precompiled regular expressions for efficiency.

2. **NGram Strategy**: Detects offensive phrases by analyzing word combinations. Unlike single-word detection, this strategy can identify offensive content that spans multiple words.

3. **Variation Strategy**: Catches attempts to evade detection through character separation (like 'f u c k' or 'f.u.c.k'). This strategy understands various separator patterns while respecting word boundaries.

4. **Repeated Chars Strategy**: Identifies words with intentionally repeated characters (like 'fuuuck'). This helps catch common obfuscation techniques.

5. **Levenshtein Strategy**: Uses string distance comparison to find words that are similar to offensive terms, helping catch typos and intentional misspellings.

Each strategy can operate in either full word or partial matching mode, with full word mode ensuring that matches are not part of larger words (preventing false positives like 'class' matching 'ass'). Results from all strategies are combined, deduplicated, and scored based on the type and quantity of matches found.

## Custom Dictionaries

You can add your own dictionaries or modify existing ones:
Expand Down
19 changes: 16 additions & 3 deletions config/censor.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

return [

/*
|--------------------------------------------------------------------------
| Profanity threshold score
|--------------------------------------------------------------------------
|
| Define the threshold score to consider a text as profane
|
|
*/
'threshold_score' => env('CENSOR_THRESHOLD_SCORE', 0.5),

/*
|--------------------------------------------------------------------------
| Default language
Expand Down Expand Up @@ -58,7 +69,7 @@
'b' => '(b|b\.|b\-|8|\|3|ß|Β|β)',
'c' => '(c|c\.|c\-|Ç|ç|¢|€|<|\(|{|©)',
'd' => '(d|d\.|d\-|&part;|\|\)|Þ|��|Ð|ð)',
'e' => '(e|e\.|e\-|3|€|È|è|É|é|Ê|ê|∑)',
'e' => '(e|e\.|e\-|3|€|È|è|É|é|Ê|ê|∑|ë|Ë)',
'f' => '(f|f\.|f\-|ƒ)',
'g' => '(g|g\.|g\-|6|9)',
'h' => '(h|h\.|h\-|Η)',
Expand All @@ -74,7 +85,7 @@
'r' => '(r|r\.|r\-|®)',
's' => '(s|s\.|s\-|5|\$|§)',
't' => '(t|t\.|t\-|Τ|τ|7)',
'u' => '(u|u\.|u\-|υ|µ)',
'u' => '(u|u\.|u\-|υ|µ|û|ü|ù|ú|ū|ů)',
'v' => '(v|v\.|v\-|υ|ν)',
'w' => '(w|w\.|w\-|ω|ψ|Ψ)',
'x' => '(x|x\.|x\-|Χ|χ)',
Expand Down Expand Up @@ -124,7 +135,9 @@
'version' => env('AZURE_AI_VERSION', \Ninja\Censor\Checkers\AzureAI::DEFAULT_API_VERSION),
],
'purgomalum' => [],
'censor' => [],
'local' => [
'levenshtein_threshold' => env('CENSOR_LEVENSHTEIN_THRESHOLD', 1),
],
],

/*
Expand Down
1 change: 0 additions & 1 deletion phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,4 @@ parameters:

includes:
- vendor/phpstan/phpstan-deprecation-rules/rules.neon
- vendor/phpstan/phpstan-strict-rules/rules.neon
- vendor/larastan/larastan/extension.neon
8 changes: 8 additions & 0 deletions src/CensorServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Ninja\Censor\Contracts\ProfanityChecker;
use Ninja\Censor\Enums\Service;
use Ninja\Censor\Factories\ProfanityCheckerFactory;
use Ninja\Censor\Support\PatternGenerator;

final class CensorServiceProvider extends ServiceProvider
{
Expand Down Expand Up @@ -56,6 +57,13 @@ public function register(): void
return $service;
});

$this->app->singleton(PatternGenerator::class, function () {
/** @var array<string, string> $replacements */
$replacements = config('censor.replacements', []);

return new PatternGenerator($replacements);
});

$this->app->bind('censor', function () {
return new Censor;
});
Expand Down
90 changes: 57 additions & 33 deletions src/Checkers/Censor.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,15 @@

use Ninja\Censor\Contracts\ProfanityChecker;
use Ninja\Censor\Contracts\Result;
use Ninja\Censor\Detection\LevenshteinStrategy;
use Ninja\Censor\Detection\NGramStrategy;
use Ninja\Censor\Detection\PatternStrategy;
use Ninja\Censor\Detection\RepeatedCharStrategy;
use Ninja\Censor\Detection\VariationStrategy;
use Ninja\Censor\Dictionary;
use Ninja\Censor\Result\CensorResult;
use Ninja\Censor\Support\PatternGenerator;
use Ninja\Censor\Support\TextAnalyzer;
use Ninja\Censor\Support\TextNormalizer;
use Ninja\Censor\Whitelist;

Expand All @@ -25,7 +32,7 @@ final class Censor implements ProfanityChecker

private Whitelist $whitelist;

public function __construct()
public function __construct(private readonly PatternGenerator $generator, private readonly int $levenshtein_threshold = 1)
{
/** @var string[] $whitelist */
$whitelist = config('censor.whitelist', []);
Expand All @@ -46,19 +53,8 @@ public function __construct()

private function generatePatterns(bool $fullWords = false): void
{
/** @var array<string, string> $replacements */
$replacements = config('censor.replacements', []);

$this->patterns = array_map(function ($word) use ($replacements, $fullWords) {
$escaped = preg_quote($word, '/');
$pattern = str_ireplace(
array_map(fn ($key) => preg_quote($key, '/'), array_keys($replacements)),
array_values($replacements),
$escaped
);

return $fullWords ? '/\b'.$pattern.'\b/iu' : '/'.$pattern.'/iu';
}, $this->words);
$this->generator->setFullWords($fullWords);
$this->patterns = $this->generator->forWords($this->words);
}

public function setDictionary(Dictionary $dictionary): self
Expand Down Expand Up @@ -95,11 +91,12 @@ public function addWords(array $words): self
public function whitelist(array $list): self
{
$this->whitelist->add($list);

return $this;
}

/**
* @return array<string, mixed>
* @return array{orig: string, clean: string, matched: array<int, string>, score?: float}
*/
public function clean(string $string, bool $fullWords = false): array
{
Expand All @@ -114,23 +111,57 @@ public function clean(string $string, bool $fullWords = false): array
'orig' => html_entity_decode($string),
'clean' => '',
'matched' => [],
'details' => [],
];

$original = TextNormalizer::normalize($this->whitelist->replace($newstring['orig']));
$counter = 0;
$processedWords = [];
$allMatches = [];
$finalText = $original;

// Configure detection strategies
$strategies = [
new PatternStrategy($this->patterns, $this->replacer),
new NGramStrategy($this->replacer),
new VariationStrategy($this->replacer, $fullWords),
new RepeatedCharStrategy($this->replacer),
new LevenshteinStrategy($this->replacer, $this->levenshtein_threshold),
];

$newstring['clean'] = preg_replace_callback(
$this->patterns,
function ($matches) use (&$counter, &$newstring) {
$newstring['matched'][$counter++] = $matches[0];
// Apply each detection strategy
foreach ($strategies as $strategy) {
$result = $strategy->detect($original, $this->words);

// Filter out already processed words and censored content
$newMatches = array_filter(
$result['matches'],
function ($match) use ($processedWords) {
return ! in_array($match['word'], $processedWords, true) &&
! str_contains($match['word'], $this->replacer);
}
);

return str_repeat($this->replacer, mb_strlen($matches[0]));
},
$original
);
if (count($newMatches) > 0) {
$allMatches = array_merge($allMatches, $newMatches);
$processedWords = array_merge(
$processedWords,
array_column($newMatches, 'word')
);

foreach ($newMatches as $match) {
$finalText = str_replace(
$match['word'],
str_repeat($this->replacer, mb_strlen($match['word'])),
$finalText
);
}
}
}

$newstring['clean'] = $this->whitelist->replace($newstring['clean'] ?? '', true);
$newstring['matched'] = array_unique($newstring['matched']);
$newstring['clean'] = $this->whitelist->replace($finalText, true);
$newstring['matched'] = array_values(array_unique($processedWords));
$newstring['details'] = $allMatches;
$newstring['score'] = TextAnalyzer::calculateScore($allMatches, $newstring['orig']);

return $newstring;
}
Expand All @@ -141,11 +172,4 @@ public function check(string $text): Result

return CensorResult::fromResponse($text, $result);
}

public function setReplaceChar(string $replacer): self
{
$this->replacer = $replacer;

return $this;
}
}
1 change: 1 addition & 0 deletions src/Checkers/TisaneAI.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public function check(string $text): Result
'snippets' => true,
'abuse' => true,
'sentiment' => true,
'document_sentiment' => true,
'profanity' => true,
],
],
Expand Down
15 changes: 15 additions & 0 deletions src/Contracts/DetectionStrategy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

namespace Ninja\Censor\Contracts;

interface DetectionStrategy
{
/**
* @param array<string> $words
* @return array{
* clean: string,
* matches: array<int, array{word: string, type: string}>
* }
*/
public function detect(string $text, array $words): array;
}
71 changes: 71 additions & 0 deletions src/Detection/LevenshteinStrategy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

namespace Ninja\Censor\Detection;

use Ninja\Censor\Contracts\DetectionStrategy;

final readonly class LevenshteinStrategy implements DetectionStrategy
{
public function __construct(
private string $replacer,
private int $threshold
) {}

public function detect(string $text, array $words): array
{
$matches = [];
$clean = $text;

$textWords = preg_split('/\s+/', $text);

if ($textWords === false) {
return [
'clean' => $clean,
'matches' => $matches,
];
}

foreach ($textWords as $textWord) {
$bestMatch = null;
$shortestDistance = PHP_INT_MAX;

foreach ($words as $badWord) {
if (mb_strtolower($textWord) === mb_strtolower($badWord) ||
str_contains($badWord, ' ')) {
continue;
}

$distance = levenshtein(
mb_strtolower($textWord),
mb_strtolower($badWord)
);

if ($distance <= $this->threshold && $distance < $shortestDistance) {
$shortestDistance = $distance;
$bestMatch = [
'word' => $textWord,
'type' => 'levenshtein',
'distance' => $distance,
];
}
}

if ($bestMatch !== null) {
$matches[] = [
'word' => $bestMatch['word'],
'type' => $bestMatch['type'],
];
$clean = str_replace(
$bestMatch['word'],
str_repeat($this->replacer, mb_strlen($bestMatch['word'])),
$clean
);
}
}

return [
'clean' => $clean,
'matches' => $matches,
];
}
}
Loading

0 comments on commit 9577793

Please sign in to comment.