feat(classifier): label 'drop' via hard-rule + score threshold (-5 default)
- Add DEFAULT_THRESHOLDS['drop'] = -5 and DEFAULT_HARD_DROP_RULES const
- Add evaluate_hard_drop(): fires when no positive signals + short latin name OR ≥3 placeholder fields
- Wire into classify(): drop overrides all other labels; synthetic reason explains trigger
- Add drop_reason_label() and 'drop' => '⛔ авто-дроп' to MessageFormatter::LABEL_MAP
- Update ClassifierTest/MessageFormatterTest to extend Cf7stgTestCase (filter isolation)
- Adjust pre-existing assertions that now correctly yield 'drop' instead of 'spam'
This commit is contained in:
@@ -20,6 +20,12 @@ final class Classifier
|
|||||||
public const DEFAULT_THRESHOLDS = [
|
public const DEFAULT_THRESHOLDS = [
|
||||||
'client' => 3,
|
'client' => 3,
|
||||||
'spam' => -2,
|
'spam' => -2,
|
||||||
|
'drop' => -5,
|
||||||
|
];
|
||||||
|
|
||||||
|
public const DEFAULT_HARD_DROP_RULES = [
|
||||||
|
'short_latin_name' => true,
|
||||||
|
'placeholder_fields' => true,
|
||||||
];
|
];
|
||||||
|
|
||||||
public const DEFAULT_KEYWORDS = [
|
public const DEFAULT_KEYWORDS = [
|
||||||
@@ -153,9 +159,28 @@ final class Classifier
|
|||||||
$label = 'spam';
|
$label = 'spam';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drop has highest priority (overrides client/unclear/spam/honeypot)
|
||||||
|
$is_drop_by_rule = self::evaluate_hard_drop($fields);
|
||||||
|
$is_drop_by_score = isset($thresholds['drop']) && $score <= (int)$thresholds['drop'];
|
||||||
|
if ($is_drop_by_rule || $is_drop_by_score) {
|
||||||
|
$label = 'drop';
|
||||||
|
$reasons[] = ['sign' => '×', 'weight' => 0, 'text' => self::drop_reason_label($fields, $thresholds, $is_drop_by_rule)];
|
||||||
|
}
|
||||||
|
|
||||||
return ['score' => $score, 'label' => $label, 'reasons' => $reasons];
|
return ['score' => $score, 'label' => $label, 'reasons' => $reasons];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function drop_reason_label(array $fields, array $thresholds, bool $by_rule): string
|
||||||
|
{
|
||||||
|
if ($by_rule) {
|
||||||
|
$hits = [];
|
||||||
|
if (self::has_short_latin_name($fields)) $hits[] = 'короткое латинское имя';
|
||||||
|
if (self::count_placeholder_fields($fields) >= 3) $hits[] = '≥3 поля-плейсхолдера';
|
||||||
|
return 'hard-rule: ' . implode(' + ', $hits);
|
||||||
|
}
|
||||||
|
return 'score ≤ ' . (int)$thresholds['drop'];
|
||||||
|
}
|
||||||
|
|
||||||
private static function concat_fields(array $fields): string
|
private static function concat_fields(array $fields): string
|
||||||
{
|
{
|
||||||
$out = [];
|
$out = [];
|
||||||
@@ -307,4 +332,35 @@ final class Classifier
|
|||||||
}
|
}
|
||||||
return $count;
|
return $count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hard-rule decision: returns true if the submission has zero positive signals
|
||||||
|
* (no cyrillic name, no meaningful text, no valid email anywhere) AND at least
|
||||||
|
* one trigger fires (short latin name OR ≥3 placeholder fields).
|
||||||
|
*
|
||||||
|
* Triggers can be disabled individually via filter `cf7stg_hard_drop_rules`.
|
||||||
|
*
|
||||||
|
* @param array<string,string|string[]> $fields CF7 posted_data
|
||||||
|
* @return bool True if the submission should be silently dropped by hard-rule.
|
||||||
|
*/
|
||||||
|
public static function evaluate_hard_drop(array $fields): bool
|
||||||
|
{
|
||||||
|
$rules = apply_filters('cf7stg_hard_drop_rules', self::DEFAULT_HARD_DROP_RULES);
|
||||||
|
|
||||||
|
// Любой позитивный сигнал — НЕ drop
|
||||||
|
$name = self::pick_field($fields, ['your-name', 'name', 'имя', 'fio']);
|
||||||
|
if ($name === '') $name = FieldDetector::find_name($fields);
|
||||||
|
if ($name !== '' && self::is_cyrillic_name($name)) return false;
|
||||||
|
|
||||||
|
$message = self::pick_field($fields, ['your-message', 'message', 'comment', 'сообщение', 'вопрос']);
|
||||||
|
if ($message !== '' && self::is_meaningful_text($message)) return false;
|
||||||
|
|
||||||
|
if (self::has_valid_email_anywhere($fields)) return false;
|
||||||
|
|
||||||
|
// Триггеры
|
||||||
|
if (!empty($rules['short_latin_name']) && self::has_short_latin_name($fields)) return true;
|
||||||
|
if (!empty($rules['placeholder_fields']) && self::count_placeholder_fields($fields) >= 3) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ final class MessageFormatter
|
|||||||
'client' => ['emoji' => '🟢', 'text' => 'похоже клиент'],
|
'client' => ['emoji' => '🟢', 'text' => 'похоже клиент'],
|
||||||
'unclear' => ['emoji' => '🟡', 'text' => 'неясно'],
|
'unclear' => ['emoji' => '🟡', 'text' => 'неясно'],
|
||||||
'spam' => ['emoji' => '🔴', 'text' => 'похоже спам'],
|
'spam' => ['emoji' => '🔴', 'text' => 'похоже спам'],
|
||||||
|
'drop' => ['emoji' => '⛔', 'text' => 'авто-дроп'],
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -156,4 +156,131 @@ final class ClassifierDropTest extends Cf7stgTestCase
|
|||||||
$fields = ['a' => 'Ия', 'b' => 'a b', 'c' => 'XY'];
|
$fields = ['a' => 'Ия', 'b' => 'a b', 'c' => 'XY'];
|
||||||
self::assertSame(1, Classifier::count_placeholder_fields($fields));
|
self::assertSame(1, Classifier::count_placeholder_fields($fields));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_screenshot_1_yields_drop(): void
|
||||||
|
{
|
||||||
|
$fields = require __DIR__ . '/fixtures/spam-screenshot-1.php';
|
||||||
|
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
|
||||||
|
self::assertSame('drop', $r['label']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_screenshot_2_yields_drop(): void
|
||||||
|
{
|
||||||
|
$fields = require __DIR__ . '/fixtures/spam-screenshot-2.php';
|
||||||
|
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
|
||||||
|
self::assertSame('drop', $r['label']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_screenshot_3_yields_drop(): void
|
||||||
|
{
|
||||||
|
$fields = require __DIR__ . '/fixtures/spam-screenshot-3.php';
|
||||||
|
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
|
||||||
|
self::assertSame('drop', $r['label']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_drop_skipped_when_cyrillic_name_present(): void
|
||||||
|
{
|
||||||
|
$fields = [
|
||||||
|
'your-name' => 'Иван',
|
||||||
|
'text-1' => 'AB', 'text-2' => 'CD', 'text-3' => 'EF',
|
||||||
|
];
|
||||||
|
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
|
||||||
|
self::assertNotSame('drop', $r['label']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_drop_skipped_when_meaningful_text_present(): void
|
||||||
|
{
|
||||||
|
$fields = [
|
||||||
|
'your-name' => 'AB',
|
||||||
|
'your-message' => 'Здравствуйте, нужна уборка квартиры в субботу с 10 утра',
|
||||||
|
'text-1' => 'AB', 'text-2' => 'CD', 'text-3' => 'EF',
|
||||||
|
];
|
||||||
|
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
|
||||||
|
self::assertNotSame('drop', $r['label']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @dataProvider valid_email_provider */
|
||||||
|
public function test_drop_skipped_when_valid_email_present(string $email): void
|
||||||
|
{
|
||||||
|
$fields = [
|
||||||
|
'your-name' => 'AB',
|
||||||
|
'your-email' => $email,
|
||||||
|
'text-1' => 'AB', 'text-2' => 'CD', 'text-3' => 'EF',
|
||||||
|
];
|
||||||
|
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
|
||||||
|
self::assertNotSame('drop', $r['label']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function valid_email_provider(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'gmail' => ['pasha@gmail.com'],
|
||||||
|
'doménный' => ['info@some-domain.com'],
|
||||||
|
'yandex' => ['name@yandex.ru'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_drop_invalid_email_does_not_protect(): void
|
||||||
|
{
|
||||||
|
$fields = [
|
||||||
|
'your-name' => 'OB',
|
||||||
|
'email' => 'not-an-email',
|
||||||
|
'text-1' => 'AB', 'text-2' => 'CD', 'text-3' => 'EF',
|
||||||
|
];
|
||||||
|
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
|
||||||
|
self::assertSame('drop', $r['label']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_drop_skipped_on_form_without_name_when_no_placeholder_rule(): void
|
||||||
|
{
|
||||||
|
// Форма «обратный звонок»: только телефон, имя нет, < 3 placeholder
|
||||||
|
$fields = ['tel-1' => '+79991234567'];
|
||||||
|
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
|
||||||
|
self::assertNotSame('drop', $r['label']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_drop_triggers_on_form_without_name_via_placeholder_rule(): void
|
||||||
|
{
|
||||||
|
$fields = ['a' => 'AB', 'b' => 'CD', 'c' => 'EF', 'd' => 'GH'];
|
||||||
|
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
|
||||||
|
self::assertSame('drop', $r['label']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_score_drop_threshold_minus_5(): void
|
||||||
|
{
|
||||||
|
// keyword_cap (-6) + latin_only (-1) = -7, без позитивных → score-based drop
|
||||||
|
// Имя длинное (Melissa Smith), сообщение latin_only → НЕ hard-rule, чисто по score.
|
||||||
|
$fields = [
|
||||||
|
'your-name' => 'Melissa Smith',
|
||||||
|
'your-message' => 'SEO, backlinks, crypto, casino, займ, bitcoin, viagra, porno',
|
||||||
|
];
|
||||||
|
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
|
||||||
|
self::assertLessThanOrEqual(-5, $r['score']);
|
||||||
|
self::assertSame('drop', $r['label']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_filter_disables_short_latin_name_rule(): void
|
||||||
|
{
|
||||||
|
add_filter('cf7stg_hard_drop_rules', static function ($rules) {
|
||||||
|
$rules['short_latin_name'] = false;
|
||||||
|
return $rules;
|
||||||
|
});
|
||||||
|
// Только короткое имя, < 3 placeholder, нет позитивных → правило отключено → не drop
|
||||||
|
$fields = ['your-name' => 'OB', 'tel' => '+79991234567'];
|
||||||
|
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
|
||||||
|
self::assertNotSame('drop', $r['label']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_filter_thresholds_can_change_drop_score(): void
|
||||||
|
{
|
||||||
|
add_filter('cf7stg_classifier_thresholds', static function ($t) {
|
||||||
|
$t['drop'] = -3;
|
||||||
|
return $t;
|
||||||
|
});
|
||||||
|
// URL даёт -3, нет hard-rule (нет короткого латинского имени, нет 3+ placeholder),
|
||||||
|
// но -3 ≤ -3 → drop по score-порогу.
|
||||||
|
$fields = ['msg' => 'http://x.com'];
|
||||||
|
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
|
||||||
|
self::assertSame('drop', $r['label']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ declare(strict_types=1);
|
|||||||
namespace Cf7stg\Tests;
|
namespace Cf7stg\Tests;
|
||||||
|
|
||||||
use Cf7stg\Classifier;
|
use Cf7stg\Classifier;
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
final class ClassifierTest extends TestCase
|
final class ClassifierTest extends Cf7stgTestCase
|
||||||
{
|
{
|
||||||
public function test_real_client_inquiry_is_green(): void
|
public function test_real_client_inquiry_is_green(): void
|
||||||
{
|
{
|
||||||
@@ -23,14 +22,16 @@ final class ClassifierTest extends TestCase
|
|||||||
|
|
||||||
public function test_seo_spam_with_url_is_red(): void
|
public function test_seo_spam_with_url_is_red(): void
|
||||||
{
|
{
|
||||||
|
// score = SEO(-2) + URL(-3) + latin_only(-1) = -6, which is ≤ drop threshold(-5)
|
||||||
|
// → label promoted from spam to drop (score-based drop, no hard-rule since email is valid)
|
||||||
$fields = [
|
$fields = [
|
||||||
'your-name' => 'Melissa Johnson',
|
'your-name' => 'Melissa Johnson',
|
||||||
'your-email' => 'promo@example.com',
|
'your-email' => 'promo@example.com',
|
||||||
'your-message' => 'Hi, we boost SEO positions, visit http://cheap-seo.net',
|
'your-message' => 'Hi, we boost SEO positions, visit http://cheap-seo.net',
|
||||||
];
|
];
|
||||||
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
|
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
|
||||||
self::assertSame('spam', $r['label']);
|
self::assertSame('drop', $r['label']);
|
||||||
self::assertLessThanOrEqual(-2, $r['score']);
|
self::assertLessThanOrEqual(-5, $r['score']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_honeypot_forces_spam_even_with_phone(): void
|
public function test_honeypot_forces_spam_even_with_phone(): void
|
||||||
|
|||||||
@@ -5,9 +5,8 @@ namespace Cf7stg\Tests;
|
|||||||
|
|
||||||
use Cf7stg\MessageFormatter;
|
use Cf7stg\MessageFormatter;
|
||||||
use Cf7stg\Classifier;
|
use Cf7stg\Classifier;
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
final class MessageFormatterTest extends TestCase
|
final class MessageFormatterTest extends Cf7stgTestCase
|
||||||
{
|
{
|
||||||
public function test_full_client_payload(): void
|
public function test_full_client_payload(): void
|
||||||
{
|
{
|
||||||
@@ -123,6 +122,8 @@ final class MessageFormatterTest extends TestCase
|
|||||||
|
|
||||||
public function test_spam_label_emoji(): void
|
public function test_spam_label_emoji(): void
|
||||||
{
|
{
|
||||||
|
// score = SEO(-2) + URL(-3) + latin_only(-1) = -6 ≤ drop threshold(-5) → label=drop
|
||||||
|
// Fields with no valid email and name 'Melissa' (7 chars, not short latin) → score-based drop.
|
||||||
$fields = [
|
$fields = [
|
||||||
'your-name' => 'Melissa',
|
'your-name' => 'Melissa',
|
||||||
'your-message' => 'SEO promotion, visit http://spam.net',
|
'your-message' => 'SEO promotion, visit http://spam.net',
|
||||||
@@ -135,6 +136,28 @@ final class MessageFormatterTest extends TestCase
|
|||||||
'ip' => '', 'user_agent' => '', 'is_retro' => false,
|
'ip' => '', 'user_agent' => '', 'is_retro' => false,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
self::assertStringContainsString('⛔ авто-дроп', $r['text']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_spam_label_emoji_pure_spam(): void
|
||||||
|
{
|
||||||
|
// Score -3 (URL) + -2 (SEO keyword) = -5, but no drop because cyrillic name protects
|
||||||
|
// from hard-rule, and score -5 is NOT ≤ drop threshold -5 (it equals it, so it IS drop).
|
||||||
|
// Use URL(-3) only → score=-3, between spam(-2) and drop(-5) → spam label.
|
||||||
|
$fields = [
|
||||||
|
'your-name' => 'Nikolay',
|
||||||
|
'your-email' => 'nick@example.com',
|
||||||
|
'your-message' => 'visit http://promo.net for deals',
|
||||||
|
];
|
||||||
|
$classification = Classifier::classify($fields, ['reason' => 'Akismet']);
|
||||||
|
// valid email prevents hard-rule; score = URL(-3) + latin_only(-1) = -4 → spam
|
||||||
|
$r = MessageFormatter::build([
|
||||||
|
'fields' => $fields, 'classification' => $classification,
|
||||||
|
'site_title' => 's.ru', 'form_title' => 'f',
|
||||||
|
'submitted_at' => '17.04.2026', 'reason' => 'Akismet',
|
||||||
|
'ip' => '', 'user_agent' => '', 'is_retro' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
self::assertStringContainsString('🔴 похоже спам', $r['text']);
|
self::assertStringContainsString('🔴 похоже спам', $r['text']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user