Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.38% covered (success)
95.38%
62 / 65
66.67% covered (warning)
66.67%
6 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
AI_Answers
95.38% covered (success)
95.38%
62 / 65
66.67% covered (warning)
66.67%
6 / 9
26
0.00% covered (danger)
0.00%
0 / 1
 init
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 register_behavior_meta
96.15% covered (success)
96.15%
25 / 26
0.00% covered (danger)
0.00%
0 / 1
2
 get_behavior_instructions
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 is_master_enabled
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 is_master_rollout_active
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 should_enforce_master
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 is_saved_on
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_enabled
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 host_allows_ai
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
1<?php
2/**
3 * AI Answers feature — behavior meta and enabled flag.
4 *
5 * @package automattic/jetpack-search
6 */
7
8namespace Automattic\Jetpack\Search;
9
10use Automattic\Jetpack\Constants;
11use Automattic\Jetpack\Modules;
12use Automattic\Jetpack\Status\Host;
13
14/**
15 * Registers behavior meta on the Gutenberg Guidelines CPT and exposes the
16 * jetpack_search_ai_answers_enabled option.
17 */
18class AI_Answers {
19    const BEHAVIOR_META_KEY   = '_guideline_block_jetpack_search-ai-summary';
20    const BEHAVIOR_OPTION_KEY = 'jetpack_search_ai_behavior_instructions';
21    const AI_MODULE           = 'ai';
22    const AI_MASTER_OPTION    = 'jetpack_ai_enabled';
23    const ENABLED_OPTION      = 'jetpack_search_ai_answers_enabled';
24
25    /**
26     * Hook up meta/setting registration.
27     */
28    public function init() {
29        add_action( 'rest_api_init', array( $this, 'register_behavior_meta' ) );
30    }
31
32    /**
33     * Register the behavior instructions storage for the REST API.
34     *
35     * When the Gutenberg Guidelines CPT is present, registers the block-specific
36     * meta key on it. Otherwise registers a site option exposed via /wp/v2/settings.
37     */
38    public function register_behavior_meta() {
39        if ( post_type_exists( 'wp_guideline' ) ) {
40            register_post_meta(
41                'wp_guideline',
42                self::BEHAVIOR_META_KEY,
43                array(
44                    'single'            => true,
45                    'type'              => 'string',
46                    'show_in_rest'      => true,
47                    'default'           => '',
48                    'sanitize_callback' => 'sanitize_textarea_field',
49                    'auth_callback'     => function () {
50                        return current_user_can( 'manage_options' );
51                    },
52                )
53            );
54            return;
55        }
56
57        register_setting(
58            'options',
59            self::BEHAVIOR_OPTION_KEY,
60            array(
61                'type'              => 'string',
62                'default'           => '',
63                'sanitize_callback' => 'sanitize_textarea_field',
64                'show_in_rest'      => true,
65            )
66        );
67    }
68
69    /**
70     * Retrieve the behavior instructions.
71     *
72     * Reads from the Gutenberg Guidelines CPT when available, otherwise falls
73     * back to the site option.
74     *
75     * @return string Behavior instructions, or empty string if none saved.
76     */
77    public static function get_behavior_instructions() {
78        if ( post_type_exists( 'wp_guideline' ) ) {
79            $posts = get_posts(
80                array(
81                    'post_type'      => 'wp_guideline',
82                    'posts_per_page' => 1,
83                    'post_status'    => 'publish',
84                )
85            );
86            if ( ! empty( $posts ) ) {
87                $guidelines = get_post_meta( $posts[0]->ID, self::BEHAVIOR_META_KEY, true );
88                return is_string( $guidelines ) ? $guidelines : '';
89            }
90        }
91        return (string) get_option( self::BEHAVIOR_OPTION_KEY, '' );
92    }
93
94    /**
95     * Whether the site-wide AI checks allow AI Answers, regardless of its saved setting.
96     *
97     * Probe the feature filter for additional restrictions from the Jetpack plugin.
98     *
99     * @since 8.0.0
100     *
101     * @return bool
102     */
103    public static function is_master_enabled() {
104        if ( ! self::should_enforce_master() ) {
105            return false;
106        }
107
108        // Ignore the saved master switch where its controls have not launched.
109        if ( ! self::is_master_rollout_active() ) {
110            return true;
111        }
112
113        return (bool) apply_filters( 'jetpack_search_ai_answers_enabled', true );
114    }
115
116    /**
117     * Whether master enforcement has rolled out here.
118     *
119     * Simple keeps its existing option contract, self-hosted sites use the
120     * Jetpack module, and Atomic remains limited to internal testing.
121     *
122     * @return bool
123     */
124    private static function is_master_rollout_active() {
125        $host = new Host();
126        if ( $host->is_wpcom_simple() ) {
127            return true;
128        }
129
130        return ! $host->is_woa_site()
131            || ( function_exists( 'jetpack_is_internal_testing_environment' ) && jetpack_is_internal_testing_environment() );
132    }
133
134    /**
135     * Whether the AI filter and the site's master switch allow AI Answers.
136     *
137     * Mirrors Jetpack_AI_Settings without requiring the Jetpack plugin on standalone Search sites.
138     *
139     * @since 8.0.0
140     *
141     * @return bool Whether site-wide AI restrictions allow AI Answers.
142     */
143    public static function should_enforce_master() {
144        /** This filter is documented in projects/plugins/jetpack/_inc/lib/class-jetpack-ai-settings.php */
145        if ( ! apply_filters( 'jetpack_ai_enabled', true ) ) {
146            return false;
147        }
148
149        if ( ! self::is_master_rollout_active() ) {
150            return true;
151        }
152
153        if ( ( new Host() )->is_wpcom_simple() ) {
154            return (bool) get_option( self::AI_MASTER_OPTION, true );
155        }
156
157        $modules = new Modules();
158
159        // Without the Jetpack plugin — a standalone Jetpack Search install — the
160        // `ai` module is not registered, so is_active() would report false for a
161        // master switch that was never installed. Don't gate those sites.
162        if ( ! in_array( self::AI_MODULE, $modules->get_available(), true ) ) {
163            return true;
164        }
165
166        // Availability is already proven above, so skip is_active()'s repeat intersect.
167        return $modules->is_active( self::AI_MODULE, false );
168    }
169
170    /**
171     * The stored AI Answers choice, ignoring every gate.
172     *
173     * The dashboard shows this while the master switch is off, so a saved choice
174     * isn't misreported back to the user as off.
175     *
176     * @since 8.0.0
177     *
178     * @return bool
179     */
180    public static function is_saved_on() {
181        return (bool) get_option( self::ENABLED_OPTION, false );
182    }
183
184    /**
185     * Whether AI Answers is enabled for the current site.
186     *
187     * Paid-plan eligibility is applied after the filter chain alongside the
188     * master gate so neither can be filtered back on.
189     */
190    public static function is_enabled() {
191        $enabled = (bool) apply_filters( 'jetpack_search_ai_answers_enabled', self::is_saved_on() );
192
193        // The master gate is applied after the filter chain so it cannot be
194        // filtered back on, matching `Jetpack_AI_Settings::is_ai_enabled()`.
195        return $enabled && self::should_enforce_master() && Search_Blocks::supports_paid_search();
196    }
197
198    /**
199     * Whether the host allows AI at all — core's wp_supports_ai(), falling back
200     * to the WP_AI_SUPPORT constant on WordPress versions that predate it.
201     * Mirrors the Jetpack plugin's Jetpack_AI_Settings::host_allows_ai().
202     *
203     * @since 8.0.0
204     *
205     * @return bool
206     */
207    public static function host_allows_ai() {
208        if ( function_exists( 'wp_supports_ai' ) ) {
209            return wp_supports_ai();
210        }
211
212        // WordPress versions predating wp_supports_ai() only have the constant.
213        return ! Constants::is_defined( 'WP_AI_SUPPORT' ) || (bool) Constants::get_constant( 'WP_AI_SUPPORT' );
214    }
215}