__( 'AI Discovery Files', 'ai-discovery-files' ), 'description' => __( 'Abilities that read content from the publisher\'s own site to populate AI Discovery Files.', 'ai-discovery-files' ), ) ); } /** * Hook callback. Registers the five extraction abilities. Guarded against * re-entry so it is safe to fire on multiple hooks (see init()). * * @return void */ public static function register_abilities() { if ( ! function_exists( 'wp_register_ability' ) ) { return; } // Begin diagnostic capture. Each wp_register_ability return value is // recorded so the AJAX layer can surface the actual failure mode to // the user when wp_get_ability comes back empty. $debug = array( 'hook_fired' => function_exists( 'current_action' ) ? current_action() : '(unknown)', 'time' => time(), 'attempts' => array(), 'wp_version' => function_exists( 'get_bloginfo' ) ? get_bloginfo( 'version' ) : 'n/a', 'fn_exists' => array( 'wp_register_ability' => function_exists( 'wp_register_ability' ), 'wp_register_ability_category' => function_exists( 'wp_register_ability_category' ), 'wp_get_ability' => function_exists( 'wp_get_ability' ), 'wp_get_abilities' => function_exists( 'wp_get_abilities' ), 'wp_ai_client_prompt' => function_exists( 'wp_ai_client_prompt' ), 'wp_get_connectors' => function_exists( 'wp_get_connectors' ), ), ); // --- 1. extract-faq ----------------------------------------------------- $debug['attempts']['ai-discovery-files/extract-faq'] = self::capture_register_result( wp_register_ability( 'ai-discovery-files/extract-faq', array( 'label' => __( 'Extract FAQs from a page', 'ai-discovery-files' ), 'description' => __( 'Reads a page on this site and extracts Q/A pairs.', 'ai-discovery-files' ), 'category' => 'ai-discovery-files', 'input_schema' => array( 'type' => 'object', 'properties' => array( 'content' => array( 'type' => 'string' ), 'source_url'=> array( 'type' => 'string' ), ), 'required' => array( 'content' ), ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'items' => array( 'type' => 'array', 'items' => array( 'type' => 'object', 'properties' => array( 'q' => array( 'type' => 'string' ), 'a' => array( 'type' => 'string' ), ), 'required' => array( 'q', 'a' ), ), 'maxItems' => 12, ), ), 'required' => array( 'items' ), ), 'permission_callback' => function () { return current_user_can( 'manage_options' ); }, 'execute_callback' => array( __CLASS__, 'execute_extract_faq' ), ) ) ); // --- 2. extract-key-people --------------------------------------------- $debug['attempts']['ai-discovery-files/extract-key-people'] = self::capture_register_result( wp_register_ability( 'ai-discovery-files/extract-key-people', array( 'label' => __( 'Extract key people from a page', 'ai-discovery-files' ), 'description' => __( 'Reads a page on this site and extracts named people in leadership roles.', 'ai-discovery-files' ), 'category' => 'ai-discovery-files', 'input_schema' => array( 'type' => 'object', 'properties' => array( 'content' => array( 'type' => 'string' ), 'source_url'=> array( 'type' => 'string' ), ), 'required' => array( 'content' ), ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'items' => array( 'type' => 'array', 'items' => array( 'type' => 'object', 'properties' => array( 'name' => array( 'type' => 'string' ), 'role' => array( 'type' => 'string' ), 'url' => array( 'type' => 'string' ), ), 'required' => array( 'name', 'role' ), ), 'maxItems' => 20, ), ), 'required' => array( 'items' ), ), 'permission_callback' => function () { return current_user_can( 'manage_options' ); }, 'execute_callback' => array( __CLASS__, 'execute_extract_key_people' ), ) ) ); // --- 3. extract-services ----------------------------------------------- $debug['attempts']['ai-discovery-files/extract-services'] = self::capture_register_result( wp_register_ability( 'ai-discovery-files/extract-services', array( 'label' => __( 'Extract services from a page', 'ai-discovery-files' ), 'description' => __( 'Reads a page on this site and extracts service/product offerings.', 'ai-discovery-files' ), 'category' => 'ai-discovery-files', 'input_schema' => array( 'type' => 'object', 'properties' => array( 'content' => array( 'type' => 'string' ), 'source_url'=> array( 'type' => 'string' ), ), 'required' => array( 'content' ), ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'items' => array( 'type' => 'array', 'items' => array( 'type' => 'object', 'properties' => array( 'name' => array( 'type' => 'string' ), 'url' => array( 'type' => 'string' ), ), 'required' => array( 'name' ), ), 'maxItems' => 30, ), ), 'required' => array( 'items' ), ), 'permission_callback' => function () { return current_user_can( 'manage_options' ); }, 'execute_callback' => array( __CLASS__, 'execute_extract_services' ), ) ) ); // --- 4. extract-boilerplate -------------------------------------------- $debug['attempts']['ai-discovery-files/extract-boilerplate'] = self::capture_register_result( wp_register_ability( 'ai-discovery-files/extract-boilerplate', array( 'label' => __( 'Extract boilerplate paragraph from a page', 'ai-discovery-files' ), 'description' => __( 'Reads a page on this site and produces a short, factual "about us" paragraph.', 'ai-discovery-files' ), 'category' => 'ai-discovery-files', 'input_schema' => array( 'type' => 'object', 'properties' => array( 'content' => array( 'type' => 'string' ), 'source_url'=> array( 'type' => 'string' ), ), 'required' => array( 'content' ), ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'paragraph' => array( 'type' => 'string' ), ), 'required' => array( 'paragraph' ), ), 'permission_callback' => function () { return current_user_can( 'manage_options' ); }, 'execute_callback' => array( __CLASS__, 'execute_extract_boilerplate' ), ) ) ); // --- 5. extract-taglines ----------------------------------------------- $debug['attempts']['ai-discovery-files/extract-taglines'] = self::capture_register_result( wp_register_ability( 'ai-discovery-files/extract-taglines', array( 'label' => __( 'Extract taglines from the homepage', 'ai-discovery-files' ), 'description' => __( 'Reads the site homepage and extracts taglines/straplines/hero slogans.', 'ai-discovery-files' ), 'category' => 'ai-discovery-files', 'input_schema' => array( 'type' => 'object', 'properties' => array( 'content' => array( 'type' => 'string' ), 'source_url'=> array( 'type' => 'string' ), ), 'required' => array( 'content' ), ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'items' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ), 'maxItems' => 5, ), ), 'required' => array( 'items' ), ), 'permission_callback' => function () { return current_user_can( 'manage_options' ); }, 'execute_callback' => array( __CLASS__, 'execute_extract_taglines' ), ) ) ); // --- 6. extract-socials ------------------------------------------------ $debug['attempts']['ai-discovery-files/extract-socials'] = self::capture_register_result( wp_register_ability( 'ai-discovery-files/extract-socials', array( 'label' => __( 'Extract social media URLs', 'ai-discovery-files' ), 'description' => __( 'Reads a page on this site (usually the homepage footer) and extracts links to social media or profile URLs.', 'ai-discovery-files' ), 'category' => 'ai-discovery-files', 'input_schema' => array( 'type' => 'object', 'properties' => array( 'content' => array( 'type' => 'string' ), 'source_url'=> array( 'type' => 'string' ), ), 'required' => array( 'content' ), ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'items' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ), 'maxItems' => 20, ), ), 'required' => array( 'items' ), ), 'permission_callback' => function () { return current_user_can( 'manage_options' ); }, 'execute_callback' => array( __CLASS__, 'execute_extract_socials' ), ) ) ); // --- 7. extract-brand-voice ------------------------------------------- // Note: this ability INFERS the brand voice from the writing style on a // page (sentence length, formality, warmth, technical density). It does // not extract a literal "this is our voice" sentence — those don't // exist on most sites. The system prompt makes the distinction explicit. $debug['attempts']['ai-discovery-files/extract-brand-voice'] = self::capture_register_result( wp_register_ability( 'ai-discovery-files/extract-brand-voice', array( 'label' => __( 'Describe brand voice from page tone', 'ai-discovery-files' ), 'description' => __( 'Analyses how the content on a page is written and produces a short phrase describing the brand\'s tone of voice.', 'ai-discovery-files' ), 'category' => 'ai-discovery-files', 'input_schema' => array( 'type' => 'object', 'properties' => array( 'content' => array( 'type' => 'string' ), 'source_url'=> array( 'type' => 'string' ), ), 'required' => array( 'content' ), ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'voice' => array( 'type' => 'string' ), ), 'required' => array( 'voice' ), ), 'permission_callback' => function () { return current_user_can( 'manage_options' ); }, 'execute_callback' => array( __CLASS__, 'execute_extract_brand_voice' ), ) ) ); // Persist diagnostic snapshot. Read by ajax_import_extract on failure. if ( function_exists( 'update_option' ) ) { update_option( 'aidf_ai_extract_debug', $debug, false ); } } /** * Convert a wp_register_ability() return value into a short summary string * for the diagnostic snapshot. Captures bool, WP_Error, null, and object * return types so we can identify silent registration failures. * * @param mixed $result Whatever wp_register_ability() returned. * @return string */ private static function capture_register_result( $result ) { if ( is_wp_error( $result ) ) { return 'WP_Error:' . $result->get_error_code() . ':' . $result->get_error_message(); } if ( true === $result ) { return 'true'; } if ( false === $result ) { return 'false'; } if ( null === $result ) { return 'null'; } if ( is_object( $result ) ) { return 'object:' . get_class( $result ); } return 'other:' . gettype( $result ); } /** * Read the diagnostic snapshot, if present. Used by the AJAX handler to * surface the actual registration outcome to the user when wp_get_ability * comes back empty. * * @return string Compact one-line summary suitable for an error message. */ public static function debug_summary() { if ( ! function_exists( 'get_option' ) ) { return 'get_option not available'; } $debug = get_option( 'aidf_ai_extract_debug' ); if ( ! is_array( $debug ) ) { return 'register_abilities() never ran (no diagnostic snapshot exists)'; } $out = array(); $out[] = 'hook=' . ( isset( $debug['hook_fired'] ) ? $debug['hook_fired'] : '?' ); $out[] = 'wp=' . ( isset( $debug['wp_version'] ) ? $debug['wp_version'] : '?' ); if ( isset( $debug['fn_exists'] ) && is_array( $debug['fn_exists'] ) ) { $fns = array(); foreach ( $debug['fn_exists'] as $k => $v ) { $fns[] = $k . '=' . ( $v ? 'Y' : 'N' ); } $out[] = 'fns:' . implode( ',', $fns ); } if ( isset( $debug['attempts'] ) && is_array( $debug['attempts'] ) ) { foreach ( $debug['attempts'] as $slug => $result ) { $out[] = $slug . '=>' . $result; } } return implode( ' | ', $out ); } /** * Whether the AI Import feature is available on this install. * * Returns true only when ALL of: * 1. The Abilities API is present (WP 7.0+). * 2. The Connectors API is present. * 3. At least one connector is actually configured by the site owner. * * @return bool */ public static function is_available() { if ( ! function_exists( 'wp_register_ability' ) ) { return false; } if ( ! function_exists( 'wp_get_connectors' ) ) { return false; } $connectors = wp_get_connectors(); return ! empty( $connectors ); } /** * Human-readable HTML hint explaining why the feature is unavailable. * Returns empty string when is_available() is true. * * @return string */ public static function unavailable_reason_html() { if ( ! function_exists( 'wp_register_ability' ) ) { return esc_html__( 'Available with WordPress 7.0 and an AI connector.', 'ai-discovery-files' ); } if ( ! function_exists( 'wp_get_connectors' ) ) { return esc_html__( 'Your WordPress install does not support the AI Abilities API. Update to 7.0+.', 'ai-discovery-files' ); } if ( empty( wp_get_connectors() ) ) { return wp_kses_post( sprintf( /* translators: %s: HTML link to Settings -> Connectors */ __( 'Set up an AI provider in %s to enable.', 'ai-discovery-files' ), '' . esc_html__( 'Settings → Connectors', 'ai-discovery-files' ) . '' ) ); } return ''; } /** * Map of ability slug to system-prompt body. Each prompt is appended with * the universal fingerprint-ban suffix returned by self::ban_suffix(). * * @return array */ private static function system_prompts() { return array( 'ai-discovery-files/extract-faq' => 'You extract FAQ items from the supplied HTML. Use the publisher\'s exact wording wherever possible. Do not rephrase, summarise, or add transitions. Return at most 12 items. If no clear Q/A structure exists, return an empty array.', 'ai-discovery-files/extract-key-people' => 'You extract named people in leadership or public-facing roles from the supplied HTML. Required fields: name, role. Optional: url (only if a clear link to a profile exists). Skip anyone whose role is not stated. Return at most 20 items.', 'ai-discovery-files/extract-services' => 'You extract service or product offerings from the supplied HTML as name/url pairs. URL only if the page links to a dedicated service page. Use the publisher\'s own service names verbatim. Do not invent service names. Return at most 30 items.', 'ai-discovery-files/extract-boilerplate' => 'You produce ONE concise paragraph (2-4 sentences, max 80 words) that describes the organisation in factual press-release tone. Use the publisher\'s own words wherever possible. No marketing copy, no taglines, no calls to action.', 'ai-discovery-files/extract-taglines' => 'You extract TAGLINES from the supplied homepage HTML. A tagline is a short marketing phrase the business uses to position itself — typically a hero strapline, mission statement, or value-proposition headline. It has an aspirational, descriptive, or evocative quality.' . "\n\n" . 'EXAMPLES of real taglines (extract phrases LIKE these):' . "\n" . '- "Your digital partner for growth"' . "\n" . '- "Building better websites since 2010"' . "\n" . '- "Where ideas become reality"' . "\n" . '- "Hand-crafted code for ambitious brands"' . "\n" . '- "Strategy, design, and delivery — under one roof"' . "\n\n" . 'DO NOT extract any of the following (these are NOT taglines, even though they look short):' . "\n" . '- Service names or product names (e.g. "Website Design", "SEO", "WordPress Hosting", "Support & Maintenance")' . "\n" . '- Navigation menu items, page titles, breadcrumbs, button labels, footer link text' . "\n" . '- Section headings that just name a topic ("Our Services", "Testimonials", "Contact Us")' . "\n" . '- Pricing tiers, plan names, package names' . "\n" . '- Calls-to-action ("Get started", "Book a call", "Learn more")' . "\n" . '- Company name on its own, or the name + a generic descriptor like "Web Design Agency"' . "\n\n" . 'A tagline answers "what is special about this business?" or "what experience do they promise?", not "what do they sell?". If the homepage has no obvious tagline (some sites just lead with service grids), return an EMPTY items array rather than guessing.' . "\n\n" . 'Use exact wording from the page. Do not invent or generate new taglines. Return at most 5 items.', 'ai-discovery-files/extract-socials' => 'You extract social media and profile URLs from the supplied HTML. Look for links to: Facebook, Twitter or X, LinkedIn, Instagram, YouTube, TikTok, GitHub, Mastodon, Bluesky, Threads, Pinterest, Google Business Profile (g.page), and similar profile pages. Include only the canonical profile URL (strip UTM parameters and tracking query strings). Use the exact URL as it appears on the page. Do not invent or guess URLs. Skip mailto:, tel:, and links to internal pages. Return at most 20 items.', 'ai-discovery-files/extract-brand-voice' => 'You characterise the brand\'s tone of voice by analysing HOW the supplied HTML content is written. You ALWAYS produce a result for any page that has real content on it. You do not extract a literal "our voice is X" sentence (those rarely exist on websites). You infer the tone from: sentence length, vocabulary level, formality, warmth, humour, technical density, use of contractions, and use of direct address.' . "\n\n" . 'OUTPUT FORMAT (mandatory): respond with ONLY a JSON object of the exact shape `{"voice": ""}` — no markdown fences, no prose, no other keys. The "voice" value MUST be a string of 5 to 12 words.' . "\n\n" . 'EXAMPLES of correct output:' . "\n" . '{"voice": "Professional but approachable, plain English, no jargon"}' . "\n" . '{"voice": "Friendly and conversational, with technical depth"}' . "\n" . '{"voice": "Warm and reassuring, written for non-technical readers"}' . "\n" . '{"voice": "Confident and direct, dry humour, British understatement"}' . "\n" . '{"voice": "Calm and authoritative, formal but not stiff"}' . "\n" . '{"voice": "Energetic and bold, short sentences, lots of contractions"}' . "\n\n" . 'STARTING POINTS to combine or adapt (do NOT just pick one verbatim — match the actual page tone): professional / friendly / technical / warm / confident / playful / calm / bold / authoritative / conversational / direct / approachable / formal / informal / dry / witty / reassuring / plain English / jargon-free / British / American.' . "\n\n" . 'If the page has fewer than ~50 words of real content (e.g. it is a near-empty landing page), respond with {"voice": ""} and we will treat that as "no inference possible". Otherwise ALWAYS return a non-empty phrase.', ); } /** * Universal suffix appended to every system prompt. Tells the AI to * avoid the same fingerprints the post-process filter will strip. * * @return string */ private static function ban_suffix() { return "\n\nRules:\n" . "- Forbidden characters: em dash, en dash, ellipsis character, curly quotes.\n" . "- Forbidden phrases: \"in today's\", \"delve into\", \"navigate the landscape\", \"unlock the potential\", \"leverage\", \"seamless\", \"robust\", \"elevate\", \"tapestry\", \"vibrant\", \"bustling\", \"treasure trove\".\n" . "- Forbidden constructions: \"It's not just X, it's Y\", \"Whether you're a X or a Y\", \"...and beyond\".\n" . "- Forbidden openers: \"Look,\", \"Listen,\".\n" . "- Use British English if the publisher's content is British English.\n" . "- Use the publisher's own wording. Do not impose marketing copy."; } /** * Extract FAQ items from page content. * * @param array $input Input containing 'content' and optional 'source_url'. * @return array|WP_Error Output array with 'items' key, or WP_Error. */ public static function execute_extract_faq( $input ) { return self::execute_ability( 'ai-discovery-files/extract-faq', $input ); } /** * Extract key people from page content. * * @param array $input Input containing 'content' and optional 'source_url'. * @return array|WP_Error Output array with 'items' key, or WP_Error. */ public static function execute_extract_key_people( $input ) { return self::execute_ability( 'ai-discovery-files/extract-key-people', $input ); } /** * Extract services from page content. * * @param array $input Input containing 'content' and optional 'source_url'. * @return array|WP_Error Output array with 'items' key, or WP_Error. */ public static function execute_extract_services( $input ) { return self::execute_ability( 'ai-discovery-files/extract-services', $input ); } /** * Extract boilerplate paragraph from page content. * * @param array $input Input containing 'content' and optional 'source_url'. * @return array|WP_Error Output array with 'paragraph' key, or WP_Error. */ public static function execute_extract_boilerplate( $input ) { return self::execute_ability( 'ai-discovery-files/extract-boilerplate', $input ); } /** * Extract taglines from page content. * * @param array $input Input containing 'content' and optional 'source_url'. * @return array|WP_Error Output array with 'items' key, or WP_Error. */ public static function execute_extract_taglines( $input ) { return self::execute_ability( 'ai-discovery-files/extract-taglines', $input ); } public static function execute_extract_socials( $input ) { return self::execute_ability( 'ai-discovery-files/extract-socials', $input ); } public static function execute_extract_brand_voice( $input ) { return self::execute_ability( 'ai-discovery-files/extract-brand-voice', $input ); } /** * Run a single AI extraction call against the configured connector, * then post-process every string in the response through the fingerprint * filter. Returns the cleaned response plus per-field flags array, ready * to be JSON-encoded back to the modal. * * @param string $slug Ability slug. * @param array $input { content: string, source_url?: string } * @return array|WP_Error */ private static function execute_ability( $slug, $input ) { if ( ! function_exists( 'wp_ai_client_prompt' ) ) { return new WP_Error( 'aidf_no_ai_client', __( 'WordPress AI Client (wp_ai_client_prompt) is not available on this site. The Abilities API is present but the AI Client component is not — this can happen on partial WordPress 7.0 builds.', 'ai-discovery-files' ) ); } $prompts = self::system_prompts(); if ( ! isset( $prompts[ $slug ] ) ) { return new WP_Error( 'aidf_unknown_ability', sprintf( 'Unknown ability: %s', $slug ) ); } $system_prompt = $prompts[ $slug ] . self::ban_suffix(); $output_schema = self::output_schema_for( $slug ); // We embed the JSON schema in the user message so the model knows the // expected output shape. WP 7.0's AI Client builder API doesn't yet // have a documented JSON-schema response_format method, so we do it // in-prompt for now and parse the JSON ourselves. $user_message = sprintf( "Source URL: %s\n\nReturn ONLY a JSON object that matches this schema (no prose, no markdown fences):\n%s\n\nPage content:\n\n%s", isset( $input['source_url'] ) ? $input['source_url'] : '(unknown)', wp_json_encode( $output_schema ), isset( $input['content'] ) ? $input['content'] : '' ); try { $builder = wp_ai_client_prompt( $user_message ); if ( is_object( $builder ) && method_exists( $builder, 'using_system_instruction' ) ) { $builder = $builder->using_system_instruction( $system_prompt ); } if ( is_object( $builder ) && method_exists( $builder, 'using_temperature' ) ) { $builder = $builder->using_temperature( 0.2 ); } $response = $builder->generate_text(); } catch ( \Throwable $e ) { return new WP_Error( 'aidf_ai_exception', $e->getMessage() ); } if ( is_wp_error( $response ) ) { return $response; } $text = self::extract_text_from_response( $response ); if ( '' === $text ) { return new WP_Error( 'aidf_bad_response_shape', sprintf( /* translators: %s: PHP type or class name of the unexpected AI response. */ __( 'AI response shape not understood (got %s).', 'ai-discovery-files' ), is_object( $response ) ? get_class( $response ) : gettype( $response ) ) ); } $json_text = self::extract_json_blob( $text ); $decoded = json_decode( $json_text, true ); if ( ! is_array( $decoded ) ) { return new WP_Error( 'aidf_bad_json', __( 'AI returned malformed JSON.', 'ai-discovery-files' ) . ' Raw start: ' . substr( $text, 0, 160 ) ); } list( $cleaned, $flag_map ) = self::clean_response_strings( $decoded ); // The Abilities API validates our return value against the declared // output_schema. That schema describes the clean shape (e.g. { items: [...] }) // without our wrapper keys. Stash the flags + token usage on the class // so the AJAX handler can read them after $ability->execute(). self::$last_flags = $flag_map; self::$last_token_usage = null; return $cleaned; } /** * Flags from the most recent execute_ability call. Read by the AJAX * handler immediately after $ability->execute() returns. * * @var array */ private static $last_flags = array(); /** * Token usage from the most recent execute_ability call (if available). * * @var array|null */ private static $last_token_usage = null; /** * Return and clear the flags from the most recent execute_ability call. * * @return array */ public static function consume_last_flags() { $flags = self::$last_flags; self::$last_flags = array(); return $flags; } /** * Return and clear the token usage from the most recent call. * * @return array|null */ public static function consume_last_token_usage() { $usage = self::$last_token_usage; self::$last_token_usage = null; return $usage; } /** * Extract the text payload from a generate_text() response. The AI Client * may return a string, an object with ->get_text() / ->text, or an array * with a 'text' key — defensively try all of them. * * @param mixed $response * @return string Empty string if the shape is not understood. */ private static function extract_text_from_response( $response ) { if ( is_string( $response ) ) { return $response; } if ( is_object( $response ) ) { if ( method_exists( $response, 'get_text' ) ) { $t = $response->get_text(); return is_string( $t ) ? $t : ''; } if ( method_exists( $response, '__toString' ) ) { return (string) $response; } if ( isset( $response->text ) && is_string( $response->text ) ) { return $response->text; } } if ( is_array( $response ) ) { if ( isset( $response['text'] ) && is_string( $response['text'] ) ) { return $response['text']; } } return ''; } /** * Extract a JSON object from text that may contain markdown code fences * or surrounding prose. Returns the original text if no obvious JSON * blob is present. * * @param string $text * @return string */ private static function extract_json_blob( $text ) { if ( preg_match( '/```(?:json)?\s*(\{.+?\}|\[.+?\])\s*```/s', $text, $m ) ) { return trim( $m[1] ); } if ( preg_match( '/(\{.*\}|\[.*\])/s', $text, $m ) ) { return $m[0]; } return $text; } /** * Pull the output_schema for an ability from the abilities API. * * @param string $slug Ability slug. * @return array */ private static function output_schema_for( $slug ) { $ability = wp_get_ability( $slug ); if ( ! $ability ) { return array(); } return $ability->get_output_schema(); } /** * Recursively walk a decoded JSON structure, run AIDF_Fingerprint_Filter * over every string leaf, and collect a parallel flag map keyed by the * dotted path to the field. * * @param mixed $node Current node. * @param string $path Current dotted path (e.g. "items.0.a"). * @return array { 0: cleaned node, 1: flag map keyed by path } */ private static function clean_response_strings( $node, $path = '' ) { $flag_map = array(); if ( is_string( $node ) ) { $r = AIDF_Fingerprint_Filter::clean( $node ); if ( ! empty( $r['flags'] ) ) { $flag_map[ $path ] = $r['flags']; } return array( $r['cleaned'], $flag_map ); } if ( is_array( $node ) ) { $out = array(); foreach ( $node as $key => $child ) { $child_path = '' === $path ? (string) $key : $path . '.' . $key; list( $cleaned_child, $child_flags ) = self::clean_response_strings( $child, $child_path ); $out[ $key ] = $cleaned_child; $flag_map = array_merge( $flag_map, $child_flags ); } return array( $out, $flag_map ); } // Numbers, nulls, bools — pass through unchanged. return array( $node, $flag_map ); } }