Connectors. Statbird never holds an * API key. Every feature runs only when someone clicks a button (or for the weekly briefing), * answers are cached for a day, a monthly cap limits usage, and only aggregate reports are * sent unless the owner opts in to sharing customer names. * * @package Statbird */ namespace Statbird; defined( 'ABSPATH' ) || exit; /** * Prompts, guardrails and usage accounting. */ class AI { const USAGE = 'statbird_ai_usage'; /** * Whether an AI provider is connected and AI is switched on. * * @return bool */ public static function available() { if ( ! Settings::get( 'ai_enabled' ) || ! function_exists( 'wp_ai_client_prompt' ) ) { return false; } if ( function_exists( 'wp_supports_ai' ) && ! wp_supports_ai() ) { return false; } $cached = wp_cache_get( 'statbird_ai_available', 'statbird' ); if ( false !== $cached ) { return 'yes' === $cached; } try { $ok = (bool) wp_ai_client_prompt( 'ping' )->is_supported_for_text_generation(); } catch ( \Throwable $e ) { $ok = false; } wp_cache_set( 'statbird_ai_available', $ok ? 'yes' : 'no', 'statbird', 300 ); return $ok; } /** * Why AI is not available, for the interface. * * @return string '', 'off', 'unsupported' (WordPress older than 7.0) or 'no_provider'. */ public static function status() { if ( ! Settings::get( 'ai_enabled' ) ) { return 'off'; } if ( ! function_exists( 'wp_ai_client_prompt' ) ) { return 'unsupported'; } return self::available() ? '' : 'no_provider'; } /** * This month's usage. * * @return array{month:string,count:int,features:array} */ public static function usage() { $u = get_option( self::USAGE, array() ); if ( ! is_array( $u ) || ( $u['month'] ?? '' ) !== gmdate( 'Y-m' ) ) { $u = array( 'month' => gmdate( 'Y-m' ), 'count' => 0, 'features' => array(), ); } return $u; } /** * Records one AI call. * * @param string $feature Feature key. */ private static function count_call( $feature ) { $u = self::usage(); $u['count'] += 1; $u['features'][ $feature ] = ( $u['features'][ $feature ] ?? 0 ) + 1; update_option( self::USAGE, $u, false ); } /** * Sends a prompt, with caching, the monthly cap and error handling. * * @param string $feature Feature key (for usage and cache). * @param string $system System instruction. * @param string $prompt User prompt. * @param bool $fresh Skip the cache. * @param array $schema Optional JSON schema; the answer is decoded when given. * @return string|array|\WP_Error */ public static function ask_model( $feature, $system, $prompt, $fresh = false, array $schema = array() ) { if ( ! self::available() ) { return new \WP_Error( 'statbird_ai_unavailable', __( 'AI is not available. Connect an AI provider in Settings > Connectors.', 'statbird' ) ); } $key = 'statbird_ai_' . md5( $feature . '|' . $system . '|' . $prompt . '|' . wp_json_encode( $schema ) ); if ( ! $fresh ) { $hit = get_transient( $key ); if ( false !== $hit ) { return $hit; } } $cap = (int) Settings::get( 'ai_monthly_cap' ); if ( self::usage()['count'] >= $cap ) { return new \WP_Error( 'statbird_ai_cap', __( 'This month\'s AI limit has been reached. You can raise it in Statbird settings.', 'statbird' ) ); } try { $builder = wp_ai_client_prompt( $prompt )->using_system_instruction( $system )->using_max_tokens( 900 )->using_temperature( 0.3 ); if ( $schema ) { $builder = $builder->as_json_response( $schema ); } $text = $builder->generate_text(); } catch ( \Throwable $e ) { $text = new \WP_Error( 'statbird_ai_error', $e->getMessage() ); } self::count_call( $feature ); if ( is_wp_error( $text ) ) { return $text; } $out = trim( (string) $text ); if ( $schema ) { $decoded = self::decode_json( $out ); if ( null === $decoded ) { return new \WP_Error( 'statbird_ai_bad_json', __( 'The AI answer could not be read. Please try again.', 'statbird' ) ); } $out = $decoded; } set_transient( $key, $out, DAY_IN_SECONDS ); return $out; } /** * Decodes JSON, also when the model wrapped it in a code fence. * * @param string $text Model output. * @return array|null */ public static function decode_json( $text ) { $text = trim( $text ); if ( preg_match( '/```(?:json)?\s*(.+?)```/s', $text, $m ) ) { $text = trim( $m[1] ); } $data = json_decode( $text, true ); if ( ! is_array( $data ) && preg_match( '/\{.*\}/s', $text, $m ) ) { $data = json_decode( $m[0], true ); } return is_array( $data ) ? $data : null; } /** * The house rules every prompt starts with. * * @return string */ private static function system() { return 'You are the analyst inside Statbird, a customer analytics plugin for a WooCommerce store. ' . 'Use only the data you are given. Never invent numbers, customers or products. If the data does not answer the question, say so plainly. ' . 'Write for a busy shop owner: short sentences, plain words, no jargon, no markdown headings. ' . 'Money is in ' . get_woocommerce_currency() . '. Round sensibly.'; } /** * Monthly summary in plain words. * * @param bool $fresh Ignore the cache. * @return string|\WP_Error */ public static function summary( $fresh = false ) { $data = array( 'overview' => Reports::run( 'overview' ), 'groups' => Reports::run( 'groups' ), 'changes' => Reports::run( 'changes' ), 'trends' => Reports::run( 'trends' )['anomalies'], ); $prompt = "Write 3 to 4 sentences summarising how this store's customers are doing this month: what is good, what needs attention, and the single most useful next step. Data:\n" . wp_json_encode( $data ); return self::ask_model( 'summary', self::system(), $prompt, $fresh ); } /** * Next best action and a draft message for one group. * * @param string $group Group key. * @param bool $fresh Ignore the cache. * @return array|\WP_Error Array with action, why, subject, message. */ public static function next_action( $group, $fresh = false ) { $detail = Reports::group_detail( $group ); if ( isset( $detail['error'] ) ) { return new \WP_Error( 'statbird_bad_group', __( 'Unknown group.', 'statbird' ) ); } $schema = array( 'type' => 'object', 'properties' => array( 'action' => array( 'type' => 'string' ), 'why' => array( 'type' => 'string' ), 'subject' => array( 'type' => 'string' ), 'message' => array( 'type' => 'string' ), ), 'required' => array( 'action', 'why', 'subject', 'message' ), ); $prompt = 'Suggest the single best action for this customer group, why it fits the numbers, and a short email the shop could send them. ' . 'Use {first_name} where the customer\'s name goes. Keep the email under 120 words, warm and specific to what they buy. Group data:' . "\n" . wp_json_encode( $detail ); return self::ask_model( 'next_action', self::system(), $prompt, $fresh, $schema ); } /** * Explains an unusual week. * * @param int $index Anomaly index in the snapshot. * @param bool $fresh Ignore the cache. * @return string|\WP_Error */ public static function explain( $index, $fresh = false ) { $snap = Insights::get(); $a = $snap['anomalies'][ $index ] ?? null; if ( ! $a ) { return new \WP_Error( 'statbird_no_anomaly', __( 'That change is no longer in the latest data.', 'statbird' ) ); } $data = array( 'unusual_week' => $a, 'weeks' => array_slice( $snap['weeks'] ?? array(), -13 ), 'moves_30d' => Changes::flows( 30 ), 'groups' => Reports::run( 'groups' ), ); $prompt = 'One weekly number moved a lot. In 2 to 4 sentences, explain the most likely reasons using only this data, and say what to check next. If the data cannot tell, say what is missing. Data:' . "\n" . wp_json_encode( $data ); return self::ask_model( 'explain', self::system(), $prompt, $fresh ); } /** * Turns product patterns into ideas. * * @param bool $fresh Ignore the cache. * @return string|\WP_Error */ public static function pattern_ideas( $fresh = false ) { $prompt = 'Here are products bought together, and what customers come back for next with the usual number of days in between. Suggest 3 concrete ideas (bundles, reminder emails timed to the usual gap, cross-sells). One line each. Data:' . "\n" . wp_json_encode( Reports::run( 'patterns' ) ); return self::ask_model( 'patterns', self::system(), $prompt, $fresh ); } /** * Answers a question about the store, using only the built-in reports. * * Step 1 asks the model which reports it needs (it cannot run anything else). * Step 2 runs those reports here and asks for an answer from them. * * @param string $question Owner's question. * @return array|\WP_Error Array with answer and the reports used. */ public static function ask( $question ) { $question = trim( wp_strip_all_tags( $question ) ); if ( '' === $question ) { return new \WP_Error( 'statbird_empty', __( 'Type a question first.', 'statbird' ) ); } $question = mb_substr( $question, 0, 400 ); $plan = self::ask_model( 'ask_plan', self::system(), "Choose which reports are needed to answer the owner's question. Pick 1 to 3. For group_detail also give the group key.\n" . 'Reports: ' . wp_json_encode( Reports::catalogue() ) . "\n" . 'Group keys: ' . wp_json_encode( array_keys( Scoring::groups() ) ) . "\n" . 'Question: ' . $question, false, array( 'type' => 'object', 'properties' => array( 'reports' => array( 'type' => 'array', 'items' => array( 'type' => 'object', 'properties' => array( 'id' => array( 'type' => 'string', 'enum' => array_keys( Reports::catalogue() ), ), 'group' => array( 'type' => 'string' ), ), 'required' => array( 'id' ), ), ), ), 'required' => array( 'reports' ), ) ); if ( is_wp_error( $plan ) ) { return $plan; } $data = array(); $used = array(); foreach ( array_slice( (array) ( $plan['reports'] ?? array() ), 0, 3 ) as $r ) { $id = (string) ( $r['id'] ?? '' ); if ( ! isset( Reports::catalogue()[ $id ] ) ) { continue; } $args = isset( $r['group'] ) ? array( 'group' => sanitize_key( $r['group'] ) ) : array(); $label = $id . ( $args ? ':' . $args['group'] : '' ); $data[ $label ] = Reports::run( $id, $args ); $used[] = $label; } if ( ! $data ) { $data['overview'] = Reports::run( 'overview' ); $used[] = 'overview'; } $answer = self::ask_model( 'ask_answer', self::system(), 'Answer the question in 2 to 5 sentences from this data only. Question: ' . $question . "\nData:\n" . wp_json_encode( $data ) ); if ( is_wp_error( $answer ) ) { return $answer; } return array( 'answer' => $answer, 'reports' => $used, ); } }