Skip to content

Commit 9639506

Browse files
deemonicclaude
andcommitted
feat: add non-English severity maps and result caching
Add severity maps (mild/moderate/extreme) for Spanish, French, and German so withSeverity() filtering works correctly for all languages instead of defaulting everything to High. Implement result caching in PendingCheck — check() results are cached by a hash of all parameters (text, driver, language, severity, allow/block lists, mask strategy). CallbackMask bypasses cache since closures can't serialize. Add Result::fromArray() for deserialization, extend Dictionary::clearCache() to also clear result cache, and add cache.results config toggle. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0b19ab4 commit 9639506

11 files changed

Lines changed: 596 additions & 2 deletions

File tree

README.md

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Blasp is a powerful, extensible profanity filter for Laravel. Version 4 is a gro
1717

1818
## Features
1919

20-
- **Driver Architecture**`regex` (detects obfuscation, substitutions, separators), `pattern` (fast exact matching), or `phonetic` (catches sound-alike evasions). Extend with custom drivers.
20+
- **Driver Architecture**`regex` (detects obfuscation, substitutions, separators), `pattern` (fast exact matching), `phonetic` (catches sound-alike evasions), or `pipeline` (chains multiple drivers together). Extend with custom drivers.
2121
- **Multi-Language** — English, Spanish, German, French with language-specific normalizers. Check one, many, or all at once.
2222
- **Severity Scoring** — Words categorised as mild/moderate/high/extreme. Filter by minimum severity and get a 0-100 score.
2323
- **Masking Strategies** — Character mask (`*`, `#`), grawlix (`!@#$%`), or a custom callback.
@@ -87,6 +87,11 @@ Blasp::french()->check($text);
8787
Blasp::driver('regex')->check($text); // Full obfuscation detection (default)
8888
Blasp::driver('pattern')->check($text); // Fast exact matching
8989
Blasp::driver('phonetic')->check($text); // Sound-alike detection (e.g. "phuck", "sheit")
90+
Blasp::driver('pipeline')->check($text); // Chain multiple drivers (config-based)
91+
92+
// Ad-hoc pipeline — chain any drivers without config
93+
Blasp::pipeline('regex', 'phonetic')->check($text);
94+
Blasp::pipeline('pattern', 'phonetic')->in('english')->mask('#')->check($text);
9095

9196
// Shorthand modes
9297
Blasp::strict()->check($text); // Forces regex driver
@@ -160,6 +165,31 @@ The phonetic driver uses `metaphone()` + Levenshtein distance to catch words tha
160165

161166
Configure sensitivity in `config/blasp.php` under `drivers.phonetic`. A curated false-positive list prevents common words like "fork", "duck", and "beach" from being flagged.
162167

168+
### Pipeline Driver
169+
170+
The pipeline driver chains multiple drivers together so a single `check()` call runs all of them. It uses **union merge** semantics — text is flagged if **any** driver finds a match.
171+
172+
```php
173+
// Config-based: set 'default' => 'pipeline' or use driver('pipeline')
174+
Blasp::driver('pipeline')->check('phuck this sh1t');
175+
176+
// Ad-hoc: pick drivers on the fly (no config needed)
177+
Blasp::pipeline('regex', 'phonetic')->check('phuck this sh1t');
178+
Blasp::pipeline('regex', 'pattern', 'phonetic')->check($text);
179+
```
180+
181+
When multiple drivers detect the same word at the same position, duplicates are removed — only the longest match is kept. Masks are applied from the merged result, and the score is recalculated across all matches.
182+
183+
Configure the default sub-drivers in `config/blasp.php`:
184+
185+
```php
186+
'drivers' => [
187+
'pipeline' => [
188+
'drivers' => ['regex', 'phonetic'], // Drivers to chain
189+
],
190+
],
191+
```
192+
163193
## Eloquent Integration
164194

165195
The `Blaspable` trait automatically checks model attributes during save:
@@ -335,7 +365,7 @@ Full `config/blasp.php` reference:
335365

336366
```php
337367
return [
338-
'default' => env('BLASP_DRIVER', 'regex'), // 'regex' | 'pattern' | 'phonetic'
368+
'default' => env('BLASP_DRIVER', 'regex'), // 'regex' | 'pattern' | 'phonetic' | 'pipeline'
339369
'language' => env('BLASP_LANGUAGE', 'english'), // Default language
340370
'mask' => '*', // Default mask character
341371
'severity' => 'mild', // Minimum severity
@@ -345,6 +375,7 @@ return [
345375
'enabled' => true,
346376
'driver' => env('BLASP_CACHE_DRIVER'),
347377
'ttl' => 86400,
378+
'results' => true, // Cache check() results by content hash
348379
],
349380

350381
'middleware' => [
@@ -359,6 +390,9 @@ return [
359390
],
360391

361392
'drivers' => [
393+
'pipeline' => [
394+
'drivers' => ['regex', 'phonetic'], // Sub-drivers to chain
395+
],
362396
'phonetic' => [
363397
'phonemes' => 4, // metaphone code length (2-8)
364398
'min_word_length' => 3, // skip short words
@@ -402,6 +436,41 @@ Blasp::extend('my-driver', fn($app) => new MyDriver());
402436
Blasp::driver('my-driver')->check($text);
403437
```
404438

439+
## Caching
440+
441+
Blasp caches `check()` results by default. When the same text is checked with the same configuration (language, driver, severity, allow/block lists), the cached result is returned instantly.
442+
443+
```php
444+
// First call — runs full analysis, caches result
445+
$result = Blasp::check('some text');
446+
447+
// Second call — returns cached result
448+
$result = Blasp::check('some text');
449+
```
450+
451+
Configure caching in `config/blasp.php`:
452+
453+
```php
454+
'cache' => [
455+
'enabled' => true, // Master switch for all caching
456+
'driver' => env('BLASP_CACHE_DRIVER'), // null = default cache driver
457+
'ttl' => 86400, // Cache lifetime in seconds
458+
'results' => true, // Cache check() results (disable independently)
459+
],
460+
```
461+
462+
Result caching is automatically bypassed when using a `CallbackMask` (closures can't be serialized). Clear both dictionary and result caches with:
463+
464+
```bash
465+
php artisan blasp:clear
466+
```
467+
468+
Or programmatically:
469+
470+
```php
471+
Dictionary::clearCache();
472+
```
473+
405474
## Artisan Commands
406475

407476
```bash

config/blasp.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
'enabled' => true,
7171
'driver' => env('BLASP_CACHE_DRIVER'),
7272
'ttl' => 86400,
73+
'results' => true,
7374
],
7475

7576
// Backward compat alias

config/languages/french.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,41 @@
11
<?php
22

33
return [
4+
'severity' => [
5+
'mild' => [
6+
'crotte', 'crottes', 'caca', 'cacas', 'zut',
7+
'mince', 'flûte', 'flute', 'punaise',
8+
'idiot', 'idiots', 'idiote', 'idiotes',
9+
'bête', 'bete', 'bêtes', 'betes',
10+
'sot', 'sots', 'sotte', 'sottes',
11+
'niais', 'niaise', 'niaises',
12+
'ballot', 'ballots', 'andouille', 'andouilles',
13+
],
14+
'moderate' => [
15+
'connard', 'connarde', 'con', 'conne',
16+
'salaud', 'salope', 'garce', 'garces',
17+
'pétasse', 'petasse', 'pétasses', 'petasses',
18+
'bâtard', 'batard', 'bâtards', 'batards',
19+
'bâtarde', 'batarde', 'bâtardes', 'batardes',
20+
'abruti', 'abrutis', 'abrutie', 'abruties',
21+
'crétin', 'cretin', 'crétins', 'cretins',
22+
'crétine', 'cretine', 'crétines', 'cretines',
23+
'débile', 'debile', 'débiles', 'debiles',
24+
'imbécile', 'imbecile', 'imbéciles', 'imbeciles',
25+
'cul', 'culs', 'trou du cul', 'trou de balle',
26+
'cochon', 'cochons', 'cochonne', 'cochonnes',
27+
],
28+
'extreme' => [
29+
'pédé', 'pede', 'pédés', 'pedes',
30+
'pédéraste', 'pederaste', 'pédérastes', 'pederastes',
31+
'tapette', 'tapettes', 'tantouze', 'tantouzes',
32+
'fiotte', 'fiottes', 'tarlouze', 'tarlouzes',
33+
'gouine', 'gouines',
34+
'attardé', 'attarde', 'attardés', 'attardes',
35+
'attardée', 'attardee', 'attardées', 'attardees',
36+
],
37+
],
38+
439
'profanities' => [
540
// Common French profanities and vulgar expressions
641
'merde',

config/languages/german.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,36 @@
11
<?php
22

33
return [
4+
'severity' => [
5+
'mild' => [
6+
'mist', 'kacke', 'verdammt', 'verdammte', 'verdammter', 'verdammtes',
7+
'blöd', 'bloed', 'blöde', 'bloede', 'blöder', 'bloeder', 'blödes', 'bloedes',
8+
'doof', 'doofe', 'doofer', 'doofes',
9+
'dumm', 'dumme', 'dummer', 'dummes',
10+
'albern', 'alberne', 'alberner', 'albernes',
11+
'peinlich', 'peinliche', 'peinlicher', 'peinliches',
12+
],
13+
'moderate' => [
14+
'arsch', 'arschloch', 'arschlöcher', 'arschlocher',
15+
'schlampe', 'nutte', 'hure',
16+
'wichser', 'depp', 'trottel',
17+
'idiot', 'vollidiot',
18+
'bescheuert', 'bescheuerte', 'bescheuerter', 'bescheuertes',
19+
'bekloppt', 'bekloppte', 'bekloppter', 'beklopptes',
20+
'schwanz', 'pimmel',
21+
'hintern', 'po', 'popo',
22+
],
23+
'extreme' => [
24+
'schwul', 'schwuler', 'schwule', 'schwules',
25+
'tunte', 'tuntig',
26+
'kampflesbe', 'kampflesben',
27+
'kanake', 'kanaken',
28+
'neger', 'negerin',
29+
'zigeuner', 'zigeunerin',
30+
'retardiert', 'retardierte', 'retardierter',
31+
],
32+
],
33+
434
'profanities' => [
535
// Common German profanities and vulgar expressions
636
'scheiße',

config/languages/spanish.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,31 @@
11
<?php
22

33
return [
4+
'severity' => [
5+
'mild' => [
6+
'maldito', 'maldita', 'maldición', 'maldicion', 'carajo',
7+
'hostia', 'hostias', 'jolines', 'joline', 'jobar', 'joroba',
8+
'caca', 'mear', 'meada', 'peo', 'pedorro', 'pedorra', 'pedos',
9+
'tonto', 'tonta', 'bobo', 'boba', 'baboso', 'babosa',
10+
'cursi', 'pesado', 'pesada', 'latoso', 'latosa',
11+
],
12+
'moderate' => [
13+
'cabrón', 'cabron', 'cabrona', 'cabrones', 'cabronazo',
14+
'perra', 'zorra', 'gilipollas', 'gilipolla',
15+
'imbécil', 'imbecil', 'idiota', 'estúpido', 'estupido', 'estúpida', 'estupida',
16+
'pendejo', 'pendeja', 'mamón', 'mamon',
17+
'boludo', 'boluda', 'pelotudo', 'pelotuda',
18+
'culo', 'ojete', 'putilla', 'putita',
19+
'capullo', 'coñazo', 'conazo', 'putada',
20+
],
21+
'extreme' => [
22+
'maricón', 'maricon', 'marica', 'maricona', 'mariconazo',
23+
'tortillera', 'bollera',
24+
'retrasado', 'retrasada', 'retardado', 'retardada',
25+
'mongoloide', 'subnormal',
26+
],
27+
],
28+
429
'profanities' => [
530
// Common Spanish profanities and vulgar expressions
631
'mierda',

src/Core/Dictionary.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,15 @@ public static function clearCache(): void
325325
}
326326

327327
$cache->forget('blasp_cache_keys');
328+
329+
// Also clear result cache keys
330+
$resultKeys = $cache->get('blasp_result_cache_keys', []);
331+
332+
foreach ($resultKeys as $key) {
333+
$cache->forget($key);
334+
}
335+
336+
$cache->forget('blasp_result_cache_keys');
328337
}
329338

330339
private static function getCache(): \Illuminate\Contracts\Cache\Repository

src/Core/Result.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,28 @@ public static function none(string $text): self
114114
return new self($text, $text, [], 0);
115115
}
116116

117+
public static function fromArray(array $data): self
118+
{
119+
$matchedWords = [];
120+
foreach ($data['words'] ?? [] as $wordData) {
121+
$matchedWords[] = new MatchedWord(
122+
text: $wordData['text'],
123+
base: $wordData['base'],
124+
severity: Severity::tryFrom($wordData['severity']) ?? Severity::High,
125+
position: $wordData['position'],
126+
length: $wordData['length'],
127+
language: $wordData['language'] ?? 'english',
128+
);
129+
}
130+
131+
return new self(
132+
$data['original'] ?? '',
133+
$data['clean'] ?? '',
134+
$matchedWords,
135+
$data['score'] ?? 0,
136+
);
137+
}
138+
117139
public static function withMatches(array $words, string $originalText = '', string $cleanText = ''): self
118140
{
119141
$matchedWords = [];

src/PendingCheck.php

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
use Blaspsoft\Blasp\Drivers\PipelineDriver;
1414
use Blaspsoft\Blasp\Enums\Severity;
1515
use Blaspsoft\Blasp\Events\ProfanityDetected;
16+
use Illuminate\Support\Facades\Cache;
1617

1718
class PendingCheck
1819
{
@@ -161,6 +162,29 @@ public function check(?string $text): Result
161162
{
162163
$text = $text ?? '';
163164

165+
if ($this->shouldCache()) {
166+
$cacheKey = $this->buildCacheKey($text);
167+
$cache = $this->getCache();
168+
$ttl = config('blasp.cache.ttl', 86400);
169+
170+
$cached = $cache->get($cacheKey);
171+
if ($cached !== null) {
172+
return Result::fromArray($cached);
173+
}
174+
175+
$result = $this->performCheck($text);
176+
177+
$cache->put($cacheKey, $result->toArray(), $ttl);
178+
$this->trackCacheKey($cacheKey);
179+
180+
return $result;
181+
}
182+
183+
return $this->performCheck($text);
184+
}
185+
186+
protected function performCheck(string $text): Result
187+
{
164188
$dictionary = $this->buildDictionary();
165189
$driver = $this->resolveDriver();
166190
$mask = $this->resolveMask();
@@ -243,4 +267,57 @@ protected function resolveMask(): MaskStrategyInterface
243267
$maskConfig = config('blasp.mask', config('blasp.mask_character', '*'));
244268
return new CharacterMask($maskConfig);
245269
}
270+
271+
// --- Caching ---
272+
273+
protected function shouldCache(): bool
274+
{
275+
if (!config('blasp.cache.enabled', true)) {
276+
return false;
277+
}
278+
279+
if (!config('blasp.cache.results', true)) {
280+
return false;
281+
}
282+
283+
if ($this->maskStrategy instanceof CallbackMask) {
284+
return false;
285+
}
286+
287+
return true;
288+
}
289+
290+
protected function buildCacheKey(string $text): string
291+
{
292+
$parts = [
293+
'text' => $text,
294+
'driver' => $this->driverName ?? config('blasp.default', 'regex'),
295+
'pipeline' => $this->pipelineDrivers,
296+
'languages' => $this->languages,
297+
'all_languages' => $this->allLanguages,
298+
'allow' => $this->allowList,
299+
'block' => $this->blockList,
300+
'severity' => $this->minimumSeverity?->value,
301+
'strict' => $this->strictMode,
302+
'lenient' => $this->lenientMode,
303+
'mask' => $this->maskStrategy ? serialize($this->maskStrategy) : null,
304+
];
305+
306+
return 'blasp_result_' . md5(serialize($parts));
307+
}
308+
309+
protected function getCache(): \Illuminate\Contracts\Cache\Repository
310+
{
311+
$driver = config('blasp.cache.driver', config('blasp.cache_driver'));
312+
313+
return $driver !== null ? Cache::store($driver) : Cache::store();
314+
}
315+
316+
protected function trackCacheKey(string $key): void
317+
{
318+
$cache = $this->getCache();
319+
$keys = $cache->get('blasp_result_cache_keys', []);
320+
$keys[] = $key;
321+
$cache->forever('blasp_result_cache_keys', array_unique($keys));
322+
}
246323
}

0 commit comments

Comments
 (0)