From f415f429ed744f7012e7e20ceef8a18bddbc6b0b Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov Date: Tue, 21 Apr 2026 16:20:28 +0500 Subject: [PATCH] refactor(classifier): single-pass hard-drop trigger; cleaner drop reason rendering Co-Authored-By: Claude Sonnet 4.6 --- includes/class-classifier.php | 70 ++++++++++++++++------------ includes/class-message-formatter.php | 6 +++ tests/ClassifierTest.php | 2 +- 3 files changed, 46 insertions(+), 32 deletions(-) diff --git a/includes/class-classifier.php b/includes/class-classifier.php index e1a667d..56e6848 100644 --- a/includes/class-classifier.php +++ b/includes/class-classifier.php @@ -160,27 +160,17 @@ final class Classifier } // Drop has highest priority (overrides client/unclear/spam/honeypot) - $is_drop_by_rule = self::evaluate_hard_drop($fields); + $drop_trigger = self::compute_hard_drop_trigger($fields); $is_drop_by_score = isset($thresholds['drop']) && $score <= (int)$thresholds['drop']; - if ($is_drop_by_rule || $is_drop_by_score) { + if ($drop_trigger !== null || $is_drop_by_score) { $label = 'drop'; - $reasons[] = ['sign' => '×', 'weight' => 0, 'text' => self::drop_reason_label($fields, $thresholds, $is_drop_by_rule)]; + $reason_text = $drop_trigger ?? ('score ≤ ' . (int)$thresholds['drop']); + $reasons[] = ['sign' => '×', 'weight' => 0, 'text' => $reason_text]; } 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 { $out = []; @@ -333,6 +323,40 @@ final class Classifier return $count; } + /** + * Returns the hard-rule trigger description, or null if no rule fires. + * Centralises positive-signal checks + trigger detection so callers don't + * recompute. Used by evaluate_hard_drop() and classify()'s reason builder. + * + * @param array $fields CF7 posted_data + * @return string|null e.g. "hard-rule: короткое латинское имя + ≥3 поля-плейсхолдера" + */ + private static function compute_hard_drop_trigger(array $fields): ?string + { + $rules = apply_filters('cf7stg_hard_drop_rules', self::DEFAULT_HARD_DROP_RULES); + + // Positive-signal short-circuits + $name = self::pick_field($fields, ['your-name', 'name', 'имя', 'fio']); + if ($name === '') $name = FieldDetector::find_name($fields); + if ($name !== '' && self::is_cyrillic_name($name)) return null; + + $message = self::pick_field($fields, ['your-message', 'message', 'comment', 'сообщение', 'вопрос']); + if ($message !== '' && self::is_meaningful_text($message)) return null; + + if (self::has_valid_email_anywhere($fields)) return null; + + // Triggers + $hits = []; + if (!empty($rules['short_latin_name']) && self::has_short_latin_name($fields)) { + $hits[] = 'короткое латинское имя'; + } + if (!empty($rules['placeholder_fields']) && self::count_placeholder_fields($fields) >= 3) { + $hits[] = '≥3 поля-плейсхолдера'; + } + if (empty($hits)) return null; + return 'hard-rule: ' . implode(' + ', $hits); + } + /** * 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 @@ -345,22 +369,6 @@ final class Classifier */ 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; + return self::compute_hard_drop_trigger($fields) !== null; } } diff --git a/includes/class-message-formatter.php b/includes/class-message-formatter.php index b22f5e8..f052bf8 100644 --- a/includes/class-message-formatter.php +++ b/includes/class-message-formatter.php @@ -108,6 +108,12 @@ final class MessageFormatter if (!$reasons) return '—'; $parts = []; foreach ($reasons as $r) { + // Drop reasons use sign='×' weight=0 as a sentinel — render text only + // to avoid the meaningless "×0 hard-rule: ..." prefix. + if (($r['sign'] ?? '') === '×' && (int)($r['weight'] ?? 0) === 0) { + $parts[] = (string)$r['text']; + continue; + } $parts[] = $r['sign'] . $r['weight'] . ' ' . $r['text']; } return esc_html(implode(', ', $parts)); diff --git a/tests/ClassifierTest.php b/tests/ClassifierTest.php index 4395f25..dcad7a9 100644 --- a/tests/ClassifierTest.php +++ b/tests/ClassifierTest.php @@ -20,7 +20,7 @@ final class ClassifierTest extends Cf7stgTestCase self::assertGreaterThanOrEqual(3, $r['score']); } - public function test_seo_spam_with_url_is_red(): void + public function test_seo_spam_with_url_yields_drop(): 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)