get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 0)', $lk ) ) ) { return 1; } try { $existing = (int) $wpdb->get_var( $wpdb->prepare( "SELECT a.action_id FROM {$wpdb->prefix}actionscheduler_actions a LEFT JOIN {$wpdb->prefix}actionscheduler_groups g ON g.group_id = a.group_id WHERE a.hook = %s AND a.status IN ('pending','in-progress') AND g.slug = %s ORDER BY a.action_id ASC LIMIT 1", self::HOOK_TICK, 'aieo' ) ); return $existing > 0 ? $existing : $pre; } finally { $wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lk ) ); } } public static function tick() { if ( ! self::ready() ) { return; } global $wpdb; $lock = 'aieo_behavioural_vector'; if ( 1 !== (int) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 0)', $lock ) ) ) { return; } try { $subjects = self::dirty_subjects( self::BATCH ); if ( empty( $subjects ) ) { return; } $built = array(); foreach ( $subjects as $sid ) { $vec = self::build_subject_vector( (int) $sid ); if ( is_array( $vec ) ) { $built[ (int) $sid ] = $vec; } } if ( ! empty( $built ) ) { self::precompute_recos( $built ); } if ( ! empty( $built ) && count( $subjects ) >= self::BATCH && function_exists( 'as_enqueue_async_action' ) ) { as_enqueue_async_action( self::HOOK_TICK, array(), 'aieo' ); } } finally { $wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock ) ); } } private static function dirty_subjects( $limit ) { global $wpdb; $is = aieo_interaction_summary_table(); $emb = $wpdb->prefix . 'aieo_chat_embeddings'; $subj = $wpdb->prefix . 'aieo_subjects'; $model = self::model(); return array_map( 'intval', (array) $wpdb->get_col( $wpdb->prepare( "SELECT i.subject_id FROM `{$is}` i LEFT JOIN `{$emb}` e ON e.post_id = i.subject_id AND e.post_type = %s AND e.engine_type = %s LEFT JOIN `{$subj}` s ON s.subject_id = i.subject_id WHERE i.last_at > (UTC_TIMESTAMP() - INTERVAL %d DAY) GROUP BY i.subject_id HAVING MAX(i.last_at) > COALESCE( MAX( CONVERT_TZ( e.updated_at, @@session.time_zone, '+00:00' ) ), '1970-01-01 00:00:00' ) AND COALESCE( MAX(s.merged_into), 0 ) = 0 ORDER BY MAX(i.last_at) DESC LIMIT %d", self::OBJECT_TYPE, $model, self::ACTIVE_DAYS, (int) $limit ) ) ); } public static function build_subject_vector( $subject_id ) { global $wpdb; $subject_id = (int) $subject_id; if ( $subject_id <= 0 || ! class_exists( 'AI_eShop_Chat_Database_Manager' ) ) { return null; } $is = aieo_interaction_summary_table(); $model = self::model(); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT item_id, weight, last_at, ( weight * POW(2, - TIMESTAMPDIFF(HOUR, last_at, UTC_TIMESTAMP()) / (24 * %f) ) ) AS ew FROM `{$is}` WHERE subject_id = %d AND item_type = 'product' AND weight > 0 AND last_at IS NOT NULL ORDER BY ew DESC LIMIT %d", self::DECAY_H, $subject_id, self::MAX_ITEMS ), ARRAY_A ); if ( empty( $rows ) ) { return null; } $item_ids = array_map( static function ( $r ) { return (int) $r['item_id']; }, $rows ); $db = AI_eShop_Chat_Database_Manager::get_instance(); $vectors = self::fetch_item_vectors( $item_ids, $model, $db ); if ( empty( $vectors ) ) { return null; } $dim = 0; $acc = null; $wsum = 0.0; $sig = ''; foreach ( $rows as $r ) { $iid = (int) $r['item_id']; if ( ! isset( $vectors[ $iid ] ) || ! is_array( $vectors[ $iid ] ) || empty( $vectors[ $iid ] ) ) { continue; } $ew = (float) $r['ew']; if ( $ew < 0.0001 ) { continue; } $vec = $vectors[ $iid ]; if ( null === $acc ) { $dim = count( $vec ); if ( $dim <= 0 ) { continue; } $acc = array_fill( 0, $dim, 0.0 ); } if ( count( $vec ) !== $dim ) { continue; } for ( $k = 0; $k < $dim; $k++ ) { $acc[ $k ] += $ew * (float) $vec[ $k ]; } $wsum += $ew; $sig .= $iid . ':' . round( $ew, 3 ) . ';'; } if ( null === $acc || $wsum <= 0.0 || $dim <= 0 ) { return null; } $mag = 0.0; for ( $k = 0; $k < $dim; $k++ ) { $acc[ $k ] /= $wsum; $mag += $acc[ $k ] * $acc[ $k ]; } $mag = sqrt( $mag ); if ( $mag <= 0.0 ) { return null; } for ( $k = 0; $k < $dim; $k++ ) { $acc[ $k ] /= $mag; } $db->store_embedding( $subject_id, $acc, md5( $sig ), self::OBJECT_TYPE, $model, 0 ); return $acc; } private static function precompute_recos( array $built ) { global $wpdb; if ( ! class_exists( 'AI_eShop_Chat_Database_Manager' ) ) { return; } $emb = $wpdb->prefix . 'aieo_chat_embeddings'; $model = self::model(); $cap = (int) apply_filters( 'aieo_behavioural_pool_cap', self::POOL_CAP ); $topN = (int) apply_filters( 'aieo_behavioural_reco_count', self::RECO_COUNT ); $db = AI_eShop_Chat_Database_Manager::get_instance(); $excl = array(); $heaps = array(); foreach ( $built as $sid => $v ) { $excl[ $sid ] = self::converted_items( (int) $sid ); $heaps[ $sid ] = array(); } $cursor = 0; $seen = 0; while ( $seen < $cap ) { $rows = $wpdb->get_results( $wpdb->prepare( "SELECT post_id, embedding FROM `{$emb}` WHERE post_type = 'product' AND engine_type = %s AND deduplicated = 0 AND embedding <> '' AND post_id > %d ORDER BY post_id ASC LIMIT %d", $model, $cursor, self::POOL_CHUNK ), ARRAY_A ); if ( empty( $rows ) ) { break; } foreach ( $rows as $r ) { $pid = (int) $r['post_id']; $cursor = $pid; $seen++; $ivec = $db->decompress_embedding( $r['embedding'] ); if ( ! is_array( $ivec ) || empty( $ivec ) ) { continue; } $idim = count( $ivec ); foreach ( $built as $sid => $sv ) { if ( isset( $excl[ $sid ][ $pid ] ) || count( $sv ) !== $idim ) { continue; } $score = self::cosine( $sv, $ivec ); if ( $score <= 0 ) { continue; } self::heap_push( $heaps[ $sid ], $pid, $score, $topN ); } if ( $seen >= $cap ) { break; } } } if ( $seen >= $cap ) { self::log( "reco pool capped at {$cap} — catalog tail excluded from recommendations" ); } $now = gmdate( 'Y-m-d H:i:s' ); $table = aieo_subject_recos_table(); foreach ( $heaps as $sid => $heap ) { arsort( $heap ); $wpdb->delete( $table, array( 'subject_id' => (int) $sid ), array( '%d' ) ); $rank = 1; foreach ( $heap as $item_id => $score ) { $wpdb->insert( $table, array( 'subject_id' => (int) $sid, 'reco_rank' => $rank, 'item_id' => (int) $item_id, 'item_type' => 'product', 'score' => round( (float) $score, 6 ), 'computed_at' => $now, ), array( '%d', '%d', '%d', '%s', '%f', '%s' ) ); if ( ++$rank > $topN ) { break; } } } } private static function heap_push( &$heap, $item_id, $score, $n ) { if ( count( $heap ) < $n ) { $heap[ $item_id ] = $score; return; } $min = min( $heap ); if ( $score <= $min ) { return; } $heap[ $item_id ] = $score; $min_key = array_search( $min, $heap, true ); if ( false !== $min_key ) { unset( $heap[ $min_key ] ); } } private static function fetch_item_vectors( array $item_ids, $model, $db ) { global $wpdb; $item_ids = array_values( array_unique( array_map( 'intval', $item_ids ) ) ); if ( empty( $item_ids ) ) { return array(); } $emb = $wpdb->prefix . 'aieo_chat_embeddings'; $in = implode( ',', $item_ids ); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT post_id, embedding FROM `{$emb}` WHERE post_type = 'product' AND engine_type = %s AND deduplicated = 0 AND embedding <> '' AND post_id IN ({$in})", $model ), ARRAY_A ); $out = array(); foreach ( (array) $rows as $r ) { $v = $db->decompress_embedding( $r['embedding'] ); if ( is_array( $v ) && ! empty( $v ) ) { $out[ (int) $r['post_id'] ] = $v; } } return $out; } private static function converted_items( $subject_id ) { global $wpdb; $is = aieo_interaction_summary_table(); $ids = $wpdb->get_col( $wpdb->prepare( "SELECT item_id FROM `{$is}` WHERE subject_id = %d AND item_type = 'product' AND conversions > 0", (int) $subject_id ) ); return array_flip( array_map( 'intval', (array) $ids ) ); } private static function cosine( array $a, array $b ) { $dot = 0.0; $nb = 0.0; $n = count( $a ); for ( $i = 0; $i < $n; $i++ ) { $x = (float) $a[ $i ]; $y = (float) $b[ $i ]; $dot += $x * $y; $nb += $y * $y; } if ( $nb <= 0.0 ) { return 0.0; } return $dot / sqrt( $nb ); } public static function get_vector( $subject_id ) { global $wpdb; $subject_id = (int) $subject_id; if ( $subject_id <= 0 || ! class_exists( 'AI_eShop_Chat_Database_Manager' ) ) { return null; } $emb = $wpdb->prefix . 'aieo_chat_embeddings'; $model = self::model(); $blob = $wpdb->get_var( $wpdb->prepare( "SELECT embedding FROM `{$emb}` WHERE post_id = %d AND post_type = %s AND engine_type = %s LIMIT 1", $subject_id, self::OBJECT_TYPE, $model ) ); if ( ! $blob ) { return null; } $db = AI_eShop_Chat_Database_Manager::get_instance(); $vec = $db->decompress_embedding( $blob ); return is_array( $vec ) && ! empty( $vec ) ? $vec : null; } private static function model() { $provider = (string) get_option( 'aieo_embedding_provider', 'voyage' ); if ( 'openai' === $provider ) { return (string) get_option( 'aieo_chat_openai_embedding_model', 'text-embedding-3-large' ); } return (string) get_option( 'aieo_chat_embedding_model', 'voyage-2' ); } private static function log( $msg ) { if ( function_exists( 'aieo_debug_log' ) ) { aieo_debug_log( 'AIEO_Behavioural_Vector: ' . $msg ); } } private static function ready() { if ( ! function_exists( 'aieo_interaction_summary_table' ) || ! function_exists( 'aieo_subject_recos_table' ) ) { return false; } global $wpdb; $is = aieo_interaction_summary_table(); return (string) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $is ) ) === $is && class_exists( 'AI_eShop_Chat_Database_Manager' ); } }