Files
2meet-data-optimizer/includes/advisor/class-tmdo-module-detector.php
wpdev 76c01e44df refactor: 全部 128 個生產檔加入 declare(strict_types=1)(PR-H)
對齊 A v3.2.0。型別強制會把隱式轉換變成 TypeError,所以一次全檔加入
並跑完整測試(unit 451 / integration 398 全綠,無迴歸)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
2026-07-31 06:13:33 +08:00

384 lines
12 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* TMDO_Module_Detector — Smart module availability detection (v2.5.0 M16).
*
* For each registered module (see TMDO_Module_Rules), evaluates
* 1. Are required plugins active (Compatibility::is_*_active)?
* 2. Does the required post_type exist + meet min_post_count?
* 3. (zone modules) Does Classifier confidence exceed min_classifier_confidence?
* 4. Is the module already in a non-idle state (no point recommending)?
*
* Output per module: {available, confidence, recommendation, reasons,
* blockers, current_state, suggested_action}.
*
* Caching: detect_all() is heavy (queries postmeta, runs classifier).
* Daily health-cron writes to wp_options.wpdo_module_suggestions; admin UI
* reads from there for fast widget rendering.
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Detection engine — stateless static API.
*/
class TMDO_Module_Detector {
/** WP options key holding cached results. */
public const OPTION_CACHE = 'wpdo_module_suggestions';
/** Transient key for short-term cache (1 hour). */
private const TRANSIENT = 'wpdo_module_detector_results';
/** TTL for transient cache. */
private const TRANSIENT_TTL = 3600;
/**
* Detect every registered module. Cached for 1 hour via transient.
*
* @param bool $force_refresh Bypass transient cache.
* @return array<string,array> Module slug → result.
*/
public static function detect_all( bool $force_refresh = false ): array {
if ( ! $force_refresh ) {
$cached = get_transient( self::TRANSIENT );
if ( is_array( $cached ) ) {
return $cached;
}
}
$out = array();
foreach ( TMDO_Module_Rules::known_modules() as $module ) {
$out[ $module ] = self::detect_one( $module );
}
set_transient( self::TRANSIENT, $out, self::TRANSIENT_TTL );
// Persist to wp_options for the dashboard widget (no autoload).
update_option(
self::OPTION_CACHE,
array(
'results' => $out,
'generated_at' => gmdate( 'Y-m-d H:i:s' ),
),
false
);
return $out;
}
/**
* Detect a single module.
*
* @param string $module Module slug.
* @return array See class doc for shape.
*/
public static function detect_one( string $module ): array {
$rule = TMDO_Module_Rules::for_module( $module );
if ( null === $rule ) {
return self::stub( $module, 'no_rule' );
}
$reasons = array();
$blockers = array();
$confidence = 0.0;
$current_state = class_exists( 'TMDO_Feature_Flags' ) ? TMDO_Feature_Flags::get( $module ) : 'idle';
// 0. Already non-idle? Block recommendation.
if ( 'idle' !== $current_state ) {
$blockers[] = sprintf( '⚠️ Module 已在 %s 狀態(不需重複推薦)', $current_state );
return self::build(
$module,
false,
0.0,
'skip',
$reasons,
$blockers,
$current_state
);
}
// 1. Plugin compatibility check.
$compat_required = (array) ( $rule['compat_required'] ?? array() );
if ( ! empty( $compat_required ) ) {
$missing = self::check_compat( $compat_required );
if ( ! empty( $missing ) ) {
$blockers[] = '⚠️ 需要 plugin 啟用:' . implode( ', ', $missing );
return self::build( $module, false, 0.0, 'skip', $reasons, $blockers, $current_state );
}
$reasons[] = '✅ 必要 plugin 已啟用:' . implode( ', ', $compat_required );
$confidence += 0.3;
}
// 2. Required post_type + row count.
$post_type = (string) ( $rule['post_type_required'] ?? '' );
$min_count = (int) ( $rule['min_post_count'] ?? 0 );
if ( '' !== $post_type ) {
$count = self::post_type_count( $post_type );
if ( $count < $min_count ) {
$blockers[] = sprintf(
'⚠️ %s post_type 只有 %s 行(需要 ≥ %s',
$post_type,
number_format_i18n( $count ),
number_format_i18n( $min_count )
);
return self::build( $module, false, $confidence, 'wait', $reasons, $blockers, $current_state );
}
$reasons[] = sprintf(
'✅ %s post_type 有 %s 行(門檻 %s',
$post_type,
number_format_i18n( $count ),
number_format_i18n( $min_count )
);
$confidence += 0.4;
}
// 3. Archive-specific: trashed count.
if ( 'archive' === $module ) {
$min_trash = (int) ( $rule['min_trash_count'] ?? 0 );
$trash_count = self::trashed_post_count();
if ( $trash_count < $min_trash ) {
$blockers[] = sprintf( '⚠️ trashed posts 只有 %d 個(需要 ≥ %d', $trash_count, $min_trash );
return self::build( $module, false, $confidence, 'wait', $reasons, $blockers, $current_state );
}
$reasons[] = sprintf( '✅ trashed posts 有 %d 個', $trash_count );
$confidence += 0.3;
}
// 4. Classifier consultation (zone modules).
if ( ! empty( $rule['consult_classifier'] ) && '' !== $post_type && class_exists( 'TMDO_Zone_Classifier' ) ) {
$min_conf = (float) ( $rule['min_classifier_confidence'] ?? 0.6 );
$cls_score = self::classifier_score( $post_type, $module );
if ( $cls_score < $min_conf ) {
$blockers[] = sprintf(
'⚠️ Classifier 對 %s 的 %s zone confidence 僅 %.2f(需 ≥ %.2f',
$post_type,
self::module_to_zone( $module ),
$cls_score,
$min_conf
);
return self::build( $module, false, $confidence, 'wait', $reasons, $blockers, $current_state );
}
$reasons[] = sprintf( '✅ Classifier confidence %.2f(門檻 %.2f', $cls_score, $min_conf );
$confidence = min( 1.0, $confidence + $cls_score * 0.3 );
}
// 5. Priority bonus: warm/archive recommended_first.
// v2.5.0 M16 polish: bump from +0.2 → +0.5 so a low-risk module like
// `warm` (no compat / no post_type gate) crosses the actionable
// threshold (0.5) on a fresh install — Setup Wizard / Dashboard widget
// can surface it without admin chasing config.
if ( 'recommended_first' === ( $rule['priority'] ?? '' ) ) {
$confidence = min( 1.0, $confidence + 0.5 );
$reasons[] = '⭐ 入門首選(低風險)';
}
// Cap confidence.
$confidence = min( 1.0, max( 0.0, $confidence ) );
return self::build( $module, true, $confidence, 'enable', $reasons, $blockers, $current_state );
}
/**
* Filter detect_all() down to actionable enable-recommendations
* above a confidence threshold.
*
* @param float $min_confidence Threshold (0..1, default 0.5).
* @return array<string,array>
*/
public static function get_actionable( float $min_confidence = 0.5 ): array {
$all = self::detect_all();
$out = array();
foreach ( $all as $module => $r ) {
if ( ! empty( $r['available'] )
&& 'enable' === ( $r['recommendation'] ?? '' )
&& (float) ( $r['confidence'] ?? 0 ) >= $min_confidence ) {
$out[ $module ] = $r;
}
}
// Sort by confidence desc.
uasort( $out, static fn( $a, $b ) => (float) $b['confidence'] <=> (float) $a['confidence'] );
return $out;
}
/**
* Get the cached results from wp_options (for dashboard widget — no live query).
*
* @return array {results:array, generated_at:string}|null
*/
public static function get_cached(): ?array {
$v = get_option( self::OPTION_CACHE );
return is_array( $v ) ? $v : null;
}
// ─── private helpers ─────────────────────────────────────────────────
/**
* Build a result envelope.
*
* @param string $module Module slug.
* @param bool $available Whether module passes all checks.
* @param float $confidence 0..1.
* @param string $recommendation 'enable'|'wait'|'skip'.
* @param array $reasons Positive findings.
* @param array $blockers Negative findings.
* @param string $current_state Current FSM state.
* @return array
*/
private static function build( string $module, bool $available, float $confidence, string $recommendation, array $reasons, array $blockers, string $current_state ): array {
$rule = TMDO_Module_Rules::for_module( $module ) ?? array();
return array(
'module' => $module,
'available' => $available,
'confidence' => round( $confidence, 2 ),
'recommendation' => $recommendation,
'reasons' => $reasons,
'blockers' => $blockers,
'description' => (string) ( $rule['description'] ?? '' ),
'current_state' => $current_state,
'suggested_action' => $available && 'enable' === $recommendation
? array(
'type' => 'set_state',
'module' => $module,
'to_state' => 'dual_write',
'cli' => "wp wpdo mode-set {$module} dual_write",
)
: null,
);
}
/**
* Build a stub result envelope for modules that cannot be evaluated.
*
* @param string $module Module slug.
* @param string $reason Reason code or human message.
* @return array
*/
private static function stub( string $module, string $reason ): array {
return array(
'module' => $module,
'available' => false,
'confidence' => 0.0,
'recommendation' => 'skip',
'reasons' => array(),
'blockers' => array( $reason ),
'description' => '',
'current_state' => 'idle',
'suggested_action' => null,
);
}
/**
* Find which required plugins are missing.
*
* @param array $required Plugins required.
* @return array<int,string> Missing plugin slugs.
*/
private static function check_compat( array $required ): array {
if ( ! class_exists( 'TMDO_Compatibility' ) ) {
return $required;
}
$missing = array();
foreach ( $required as $plugin ) {
$active = match ( $plugin ) {
'hivepress' => TMDO_Compatibility::is_hivepress_active(),
'woocommerce' => TMDO_Compatibility::is_woocommerce_active(),
'hpct' => TMDO_Compatibility::is_hpct_active(),
'latepoint' => TMDO_Compatibility::is_latepoint_active(),
default => false,
};
if ( ! $active ) {
$missing[] = $plugin;
}
}
return $missing;
}
/**
* Return the published post count for a given post type.
*
* @param string $post_type Post type slug.
* @return int Row count.
*/
private static function post_type_count( string $post_type ): int {
global $wpdb;
$cached_key = 'wpdo_pt_count_' . md5( $post_type );
$cached = get_transient( $cached_key );
if ( false !== $cached ) {
return (int) $cached;
}
$count = (int) $wpdb->get_var(
$wpdb->prepare( // phpcs:ignore WordPress.DB
"SELECT COUNT(*) FROM `{$wpdb->posts}` WHERE post_type = %s AND post_status NOT IN ('trash','auto-draft')",
$post_type
)
);
set_transient( $cached_key, $count, HOUR_IN_SECONDS );
return $count;
}
/**
* Total trashed posts (all post types).
*
* @return int
*/
private static function trashed_post_count(): int {
global $wpdb;
$cached = get_transient( 'wpdo_trash_count' );
if ( false !== $cached ) {
return (int) $cached;
}
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$wpdb->posts}` WHERE post_status = 'trash'" ); // phpcs:ignore WordPress.DB
set_transient( 'wpdo_trash_count', $count, HOUR_IN_SECONDS );
return $count;
}
/**
* Map module → target zone for Classifier consultation.
*
* @param string $module Module slug (e.g. hot_hp_listing).
* @return string Zone (hot|cold|warm|archive).
*/
private static function module_to_zone( string $module ): string {
if ( str_starts_with( $module, 'hot_' ) ) {
return 'hot';
}
if ( str_starts_with( $module, 'cold_' ) ) {
return 'cold';
}
return $module; // 'warm' / 'archive'.
}
/**
* Classifier confidence aggregate for a (post_type, target_zone).
*
* @param string $post_type Post type.
* @param string $module Module slug → mapped to zone.
* @return float 0..1 average confidence of meta_keys whose suggested_zone matches.
*/
private static function classifier_score( string $post_type, string $module ): float {
if ( ! class_exists( 'TMDO_Zone_Classifier' ) ) {
return 0.0;
}
$zone = self::module_to_zone( $module );
try {
$results = TMDO_Zone_Classifier::analyze( $post_type, 50 );
} catch ( Throwable $e ) {
return 0.0;
}
if ( ! is_array( $results ) || empty( $results ) ) {
return 0.0;
}
$total = 0.0;
$count = 0;
foreach ( $results as $field ) {
if ( ( $field['suggested_zone'] ?? '' ) === $zone ) {
$total += (float) ( $field['confidence'] ?? 0 );
++$count;
}
}
return $count > 0 ? $total / $count : 0.0;
}
}