Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.08% covered (success)
95.08%
58 / 61
66.67% covered (warning)
66.67%
6 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
AI_Answers
95.08% covered (success)
95.08%
58 / 61
66.67% covered (warning)
66.67%
6 / 9
25
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%
3 / 3
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%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 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 gates currently allow AI Answers — the reporting
96     * predicate.
97     *
98     * The Jetpack plugin enforces the AI master switch and the host's AI opt-out
99     * through the `jetpack_search_ai_answers_enabled` filter; probing the chain
100     * with `true` reads that verdict without depending on the plugin. Sites with
101     * no gate registered (e.g. standalone Search) report on.
102     *
103     * @since 8.0.0
104     *
105     * @return bool
106     */
107    public static function is_master_enabled() {
108        // Where enforcement hasn't rolled out, report ungated so no master-off
109        // UI shows before the switch itself does. Remove at public launch.
110        if ( ! self::is_master_rollout_active() ) {
111            return true;
112        }
113
114        // ANDed with the computed predicate so reporting can never be more
115        // permissive than enforcement — e.g. a Simple request where the plugin's
116        // filter never registered, or plugin/package version skew.
117        return (bool) apply_filters( 'jetpack_search_ai_answers_enabled', true ) && self::should_enforce_master();
118    }
119
120    /**
121     * Whether master enforcement has rolled out here.
122     *
123     * Simple keeps its existing option contract, self-hosted sites use the
124     * Jetpack module, and Atomic remains limited to internal testing.
125     *
126     * @return bool
127     */
128    private static function is_master_rollout_active() {
129        $host = new Host();
130        if ( $host->is_wpcom_simple() ) {
131            return true;
132        }
133
134        return ! $host->is_woa_site()
135            || ( function_exists( 'jetpack_is_internal_testing_environment' ) && jetpack_is_internal_testing_environment() );
136    }
137
138    /**
139     * Whether this package should enforce the master switch — the rollout-scoped
140     * enforcement predicate behind the block gate.
141     *
142     * Mirrors `Jetpack_AI_Settings::is_master_enabled()` in the Jetpack plugin —
143     * the source of truth, unreferenceable from standalone installs. Computed
144     * rather than filtered so no plugin can flip a gate that must hold.
145     *
146     * @since 8.0.0
147     *
148     * @return bool True when Jetpack AI is on, or when the site has no master switch.
149     */
150    public static function should_enforce_master() {
151        if ( ! self::is_master_rollout_active() ) {
152            return true;
153        }
154
155        if ( ( new Host() )->is_wpcom_simple() ) {
156            return (bool) get_option( self::AI_MASTER_OPTION, true );
157        }
158
159        $modules = new Modules();
160
161        // Without the Jetpack plugin — a standalone Jetpack Search install — the
162        // `ai` module is not registered, so is_active() would report false for a
163        // master switch that was never installed. Don't gate those sites.
164        if ( ! in_array( self::AI_MODULE, $modules->get_available(), true ) ) {
165            return true;
166        }
167
168        // Availability is already proven above, so skip is_active()'s repeat intersect.
169        return $modules->is_active( self::AI_MODULE, false );
170    }
171
172    /**
173     * The stored AI Answers choice, ignoring every gate.
174     *
175     * The dashboard shows this while the master switch is off, so a saved choice
176     * isn't misreported back to the user as off.
177     *
178     * @since 8.0.0
179     *
180     * @return bool
181     */
182    public static function is_saved_on() {
183        return (bool) get_option( self::ENABLED_OPTION, false );
184    }
185
186    /**
187     * Whether AI Answers is enabled for the current site.
188     *
189     * Paid-plan eligibility is applied after the filter chain alongside the
190     * master gate so neither can be filtered back on.
191     */
192    public static function is_enabled() {
193        $enabled = (bool) apply_filters( 'jetpack_search_ai_answers_enabled', self::is_saved_on() );
194
195        // The master gate is applied after the filter chain so it cannot be
196        // filtered back on, matching `Jetpack_AI_Settings::is_ai_enabled()`.
197        return $enabled && self::should_enforce_master() && Search_Blocks::supports_paid_search();
198    }
199
200    /**
201     * Whether the host allows AI at all — core's wp_supports_ai(), falling back
202     * to the WP_AI_SUPPORT constant on WordPress versions that predate it.
203     * Mirrors the Jetpack plugin's Jetpack_AI_Settings::host_allows_ai().
204     *
205     * @since 8.0.0
206     *
207     * @return bool
208     */
209    public static function host_allows_ai() {
210        if ( function_exists( 'wp_supports_ai' ) ) {
211            return wp_supports_ai();
212        }
213
214        // WordPress versions predating wp_supports_ai() only have the constant.
215        return ! Constants::is_defined( 'WP_AI_SUPPORT' ) || (bool) Constants::get_constant( 'WP_AI_SUPPORT' );
216    }
217}