get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); } public static function columns( $table ) { global $wpdb; $rows = $wpdb->get_col( "SHOW COLUMNS FROM `{$table}`" ); return is_array( $rows ) ? array_map( 'strtolower', $rows ) : array(); } public static function has_cols( array $cols, array $needed ) { foreach ( $needed as $c ) { if ( ! in_array( strtolower( $c ), $cols, true ) ) { return false; } } return true; } public static function title( $object_type, $object_id ) { $title = (string) get_the_title( (int) $object_id ); if ( '' === trim( $title ) ) { return ucfirst( (string) $object_type ) . ' #' . (int) $object_id; } if ( function_exists( 'mb_strlen' ) && mb_strlen( $title ) > 120 ) { return mb_substr( $title, 0, 120 ) . '…'; } return $title; } public static function to_list( $csv ) { return array_values( array_filter( array_map( 'trim', explode( ',', (string) $csv ) ) ) ); } } class BoostSlateAbility { const ABILITY_NAME = 'aieo/boost-slate'; public static function register() { wp_register_ability( self::ABILITY_NAME, array( 'label' => __( 'Read the boost slate', 'ai-eshop-optimizer' ), 'description' => __( 'READ-ONLY, no AI credits. Reads the nightly BOOST FUSION slate — the stored ranking of what the shop should promote next across products, posts, pages, events and competitions. Per candidate: boost_score (0–100), the component signals behind it (SEO opportunity, search/commerce demand, sales forecast, on-site intent, waitlist demand, rating quality, timeliness, push appetite), the SEO quadrant, the social channels it is eligible for, and its VETOES. Vetoes come in two kinds and must never be reported as one: TRANSIENT (out_of_stock, stock_signout) means demand is real and only stock is missing — restocking clears it and the candidate returns to the top of the slate; PERMANENT (low_rating, ended) means the shop has decided not to amplify it at all. Params: limit (default 20), object_type (product|post|page|aieo_event|aieo_competition), status (all|promotable|blocked). Empty until the nightly rebuild has run. It never publishes, schedules or changes anything.', 'ai-eshop-optimizer' ), 'category' => \AIEO\Claude\Bootstrap::CATEGORY, 'input_schema' => self::input_schema(), 'output_schema' => array( 'type' => 'object' ), 'execute_callback' => array( __CLASS__, 'execute' ), 'permission_callback' => array( 'AIEO\\Claude\\Abilities\\BoostSlateAbilityHelpers', 'can' ), 'meta' => array( 'annotations' => array( 'readonly' => true, 'idempotent' => true, ), 'show_in_rest' => true, ), ) ); } private static function input_schema() { return array( 'default' => array(), 'type' => 'object', 'properties' => array( 'limit' => array( 'type' => 'integer', 'default' => 20, 'minimum' => 1, 'maximum' => 100, 'description' => 'How many candidates to return, best score first. Defaults to 20.', ), 'object_type' => array( 'type' => 'string', 'description' => 'Optional filter, e.g. product, post, page, aieo_event, aieo_competition.', ), 'status' => array( 'type' => 'string', 'enum' => array( 'all', 'promotable', 'blocked' ), 'default' => 'all', 'description' => 'promotable = no veto; blocked = held back by a veto. Defaults to all.', ), ), ); } public static function execute( $input = null ) { global $wpdb; $input = is_array( $input ) ? $input : array(); $limit = isset( $input['limit'] ) ? (int) $input['limit'] : 20; $limit = max( 1, min( 100, $limit ) ); $type = isset( $input['object_type'] ) ? sanitize_key( (string) $input['object_type'] ) : ''; $want = isset( $input['status'] ) ? sanitize_key( (string) $input['status'] ) : 'all'; if ( ! in_array( $want, array( 'all', 'promotable', 'blocked' ), true ) ) { $want = 'all'; } $table = $wpdb->prefix . 'aieo_boost_candidates'; if ( ! BoostSlateAbilityHelpers::table_exists( $table ) ) { return array( 'returned' => 0, 'candidates' => array(), 'note' => __( 'The boost slate is not installed on this site — it builds overnight once Social is active.', 'ai-eshop-optimizer' ), ); } $cols = BoostSlateAbilityHelpers::columns( $table ); if ( ! BoostSlateAbilityHelpers::has_cols( $cols, array( 'object_type', 'object_id', 'boost_score' ) ) ) { return array( 'returned' => 0, 'candidates' => array(), 'note' => __( 'The boost slate table is present but not in a readable shape on this site.', 'ai-eshop-optimizer' ), ); } $optional = array( 'seo_quadrant', 'components_json', 'weights_version', 'eligible_vectors', 'veto_reasons', 'computed_at' ); $select = array( 'object_type', 'object_id', 'boost_score' ); foreach ( $optional as $c ) { if ( in_array( $c, $cols, true ) ) { $select[] = $c; } } $select_sql = '`' . implode( '`, `', $select ) . '`'; $where = array( 'boost_score > 0' ); $args = array(); if ( '' !== $type ) { $where[] = 'object_type = %s'; $args[] = $type; } $has_veto_col = in_array( 'veto_reasons', $cols, true ); if ( $has_veto_col && 'promotable' === $want ) { $where[] = "veto_reasons = ''"; } elseif ( $has_veto_col && 'blocked' === $want ) { $where[] = "veto_reasons <> ''"; } $args[] = $limit; $sql = "SELECT {$select_sql} FROM `{$table}` WHERE " . implode( ' AND ', $where ) . " ORDER BY boost_score DESC, object_id DESC LIMIT %d"; $rows = $wpdb->get_results( $wpdb->prepare( $sql, $args ), ARRAY_A ); $can_split = class_exists( 'AIEO_Boost_Candidates' ) && method_exists( 'AIEO_Boost_Candidates', 'split_vetoes' ); $candidates = array(); $summary = array( 'promotable' => 0, 'blocked_transient' => 0, 'blocked_permanent' => 0, 'blocked_unclassified' => 0, ); foreach ( (array) $rows as $r ) { $object_type = (string) $r['object_type']; $object_id = (int) $r['object_id']; $raw_veto = isset( $r['veto_reasons'] ) ? (string) $r['veto_reasons'] : ''; $split = array( 'transient' => array(), 'permanent' => array() ); if ( '' !== $raw_veto && $can_split ) { $split = \AIEO_Boost_Candidates::split_vetoes( $raw_veto ); } if ( ! empty( $split['permanent'] ) ) { $blocked = 'permanent'; $summary['blocked_permanent']++; } elseif ( ! empty( $split['transient'] ) ) { $blocked = 'transient'; $summary['blocked_transient']++; } elseif ( '' !== $raw_veto ) { $blocked = 'unclassified'; $summary['blocked_unclassified']++; } else { $blocked = ''; $summary['promotable']++; } $components = array(); if ( isset( $r['components_json'] ) ) { $decoded = json_decode( (string) $r['components_json'], true ); if ( is_array( $decoded ) ) { $components = $decoded; } } $candidates[] = array( 'object_type' => $object_type, 'object_id' => $object_id, 'title' => BoostSlateAbilityHelpers::title( $object_type, $object_id ), 'url' => (string) get_permalink( $object_id ), 'boost_score' => isset( $r['boost_score'] ) ? (float) $r['boost_score'] : 0.0, 'seo_quadrant' => isset( $r['seo_quadrant'] ) ? (string) $r['seo_quadrant'] : '', 'components' => isset( $components['components'] ) && is_array( $components['components'] ) ? $components['components'] : $components, 'weights_version' => isset( $r['weights_version'] ) ? (string) $r['weights_version'] : '', 'eligible_vectors' => BoostSlateAbilityHelpers::to_list( isset( $r['eligible_vectors'] ) ? $r['eligible_vectors'] : '' ), 'veto_reasons' => $raw_veto, 'veto' => array( 'transient' => array_values( (array) $split['transient'] ), 'permanent' => array_values( (array) $split['permanent'] ), ), 'blocked_by' => $blocked, 'computed_at' => isset( $r['computed_at'] ) ? (string) $r['computed_at'] : '', ); } return array( 'returned' => count( $candidates ), 'summary_of_returned' => $summary, 'candidates' => $candidates, 'veto_glossary' => array( 'out_of_stock' => __( 'TRANSIENT — people are waiting for it but it cannot be bought today. Restocking clears this and the candidate returns to the slate with a warm audience.', 'ai-eshop-optimizer' ), 'stock_signout' => __( 'TRANSIENT — the 28-day forecast would exhaust the stock on hand, so promoting it would sell through before the campaign ends. Restocking clears this.', 'ai-eshop-optimizer' ), 'low_rating' => __( 'PERMANENT — enough customers have rated it badly to be a judgement, not noise. Amplifying it damages the store; no score outvotes this.', 'ai-eshop-optimizer' ), 'ended' => __( 'PERMANENT — the event or competition is over. The date has passed and cannot be cleared.', 'ai-eshop-optimizer' ), ), 'note' => __( 'Read-only view of the nightly slate. Counts in summary_of_returned describe ONLY the rows in this response, not the whole slate. Distinguish the two veto kinds when answering: a transient veto is a purchasing decision (restock it), a permanent one is a decision not to promote at all. The chat does not publish, schedule or accept slate items — the operator does that in e-shop Manager → Social.', 'ai-eshop-optimizer' ), ); } } class RestockToUnlockAbility { const ABILITY_NAME = 'aieo/restock-to-unlock'; public static function register() { wp_register_ability( self::ABILITY_NAME, array( 'label' => __( 'Restock to unlock — demand waiting on empty shelves', 'ai-eshop-optimizer' ), 'description' => __( 'READ-ONLY, no AI credits. Lists the OUT-OF-STOCK products that customers have joined the waitlist for, with how many people are waiting on each, the total waiting, and the product\'s current boost score when the slate has one. This is the strongest demand signal in the shop because it was volunteered rather than inferred — a named person asked to be told when the product returns. Each of these carries a TRANSIENT veto on the boost slate: promotion is withheld only while the product cannot be bought, so restocking clears the block and turns the queue into a ready-made back-in-stock audience. Params: limit (default 25). Returns nothing when the waitlist feature is not in use. It never notifies anyone and never changes stock.', 'ai-eshop-optimizer' ), 'category' => \AIEO\Claude\Bootstrap::CATEGORY, 'input_schema' => array( 'default' => array(), 'type' => 'object', 'properties' => array( 'limit' => array( 'type' => 'integer', 'default' => 25, 'minimum' => 1, 'maximum' => 100, 'description' => 'How many products to return, longest queue first. Defaults to 25.', ), ), ), 'output_schema' => array( 'type' => 'object' ), 'execute_callback' => array( __CLASS__, 'execute' ), 'permission_callback' => array( 'AIEO\\Claude\\Abilities\\BoostSlateAbilityHelpers', 'can' ), 'meta' => array( 'annotations' => array( 'readonly' => true, 'idempotent' => true, ), 'show_in_rest' => true, ), ) ); } public static function execute( $input = null ) { global $wpdb; $input = is_array( $input ) ? $input : array(); $limit = isset( $input['limit'] ) ? (int) $input['limit'] : 25; $limit = max( 1, min( 100, $limit ) ); $waitlist = $wpdb->prefix . 'aieo_waitlist'; if ( ! BoostSlateAbilityHelpers::table_exists( $waitlist ) ) { return array( 'returned' => 0, 'products' => array(), 'note' => __( 'The waitlist feature is not in use on this site, so there is nothing waiting on a restock.', 'ai-eshop-optimizer' ), ); } $cols = BoostSlateAbilityHelpers::columns( $waitlist ); if ( ! in_array( 'product_id', $cols, true ) ) { return array( 'returned' => 0, 'products' => array(), 'note' => __( 'The waitlist table is present but not in a readable shape on this site.', 'ai-eshop-optimizer' ), ); } $where = ''; if ( in_array( 'notified_at', $cols, true ) ) { $where = 'WHERE w.notified_at IS NULL'; } elseif ( in_array( 'status', $cols, true ) ) { $where = "WHERE w.status <> 'notified'"; } $sql = "SELECT CASE WHEN p.post_type = 'product_variation' AND p.post_parent > 0 THEN p.post_parent ELSE p.ID END AS pid, COUNT(DISTINCT w.email) AS waiting FROM `{$waitlist}` w INNER JOIN `{$wpdb->posts}` p ON p.ID = w.product_id AND p.post_status = 'publish' {$where} GROUP BY pid ORDER BY waiting DESC LIMIT %d"; $rows = $wpdb->get_results( $wpdb->prepare( $sql, $limit * 4 ), ARRAY_A ); if ( ! $rows ) { return array( 'returned' => 0, 'products' => array(), 'note' => __( 'Nobody is currently waiting on a product.', 'ai-eshop-optimizer' ), ); } $ids = array(); foreach ( $rows as $r ) { $pid = (int) $r['pid']; if ( $pid > 0 ) { $ids[ $pid ] = true; } } $ids = array_keys( $ids ); if ( ! $ids ) { return array( 'returned' => 0, 'products' => array() ); } $ph = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); $stock_rows = $wpdb->get_results( $wpdb->prepare( "SELECT post_id, meta_value FROM `{$wpdb->postmeta}` WHERE meta_key = '_stock_status' AND post_id IN ({$ph})", $ids ), ARRAY_A ); $in_stock = array(); foreach ( (array) $stock_rows as $s ) { if ( 'instock' === (string) $s['meta_value'] ) { $in_stock[ (int) $s['post_id'] ] = true; } } $scores = array(); $boost_table = $wpdb->prefix . 'aieo_boost_candidates'; if ( BoostSlateAbilityHelpers::table_exists( $boost_table ) ) { $bcols = BoostSlateAbilityHelpers::columns( $boost_table ); if ( BoostSlateAbilityHelpers::has_cols( $bcols, array( 'object_type', 'object_id', 'boost_score' ) ) ) { $brows = $wpdb->get_results( $wpdb->prepare( "SELECT object_id, boost_score FROM `{$boost_table}` WHERE object_type = 'product' AND object_id IN ({$ph})", $ids ), ARRAY_A ); foreach ( (array) $brows as $b ) { $scores[ (int) $b['object_id'] ] = (float) $b['boost_score']; } } } $products = array(); $total = 0; foreach ( $rows as $r ) { $pid = (int) $r['pid']; if ( $pid <= 0 || isset( $in_stock[ $pid ] ) ) { continue; } $waiting = (int) $r['waiting']; $products[] = array( 'product_id' => $pid, 'title' => BoostSlateAbilityHelpers::title( 'product', $pid ), 'url' => (string) get_permalink( $pid ), 'waiting' => $waiting, 'boost_score' => isset( $scores[ $pid ] ) ? $scores[ $pid ] : null, ); $total += $waiting; if ( count( $products ) >= $limit ) { break; } } if ( ! $products ) { return array( 'returned' => 0, 'products' => array(), 'note' => __( 'Everybody on the waitlist is waiting for a product that is back in stock — nothing is blocked on a restock right now.', 'ai-eshop-optimizer' ), ); } return array( 'returned' => count( $products ), 'total_waiting' => $total, 'products' => $products, 'note' => __( 'These carry a TRANSIENT veto on the boost slate: nothing is wrong with the product, it simply cannot be bought today. Restocking clears the veto, and the waiting queue is the audience for the back-in-stock announcement. Read-only — nobody is notified by this call.', 'ai-eshop-optimizer' ), ); } }