, strategies: array}|null */ private ?array $channel_vocabulary_cache = null; /** * Memoized live calculation strategies keyed by strategy id, holding each * item verbatim. Labels and calc_params schemas are both derived from this * one lookup, so list_strategies() is called once per tool instance. * * @var array>|null */ private ?array $strategies_cache = null; /** * Guard against Pointics being inactive or the user lacking capabilities. * * The user must hold at least one of the given capabilities. Pointics' * own REST layer accepts `manage_pointics` OR `manage_options` * (`Pointics\Api\Base_Controller::can_manage()`), so tools should guard * with both, e.g. `$this->guard( 'manage_pointics', 'manage_options' )`. * Returns an error array on failure, null on success. * * @param string $capability Required capability slug. * @param string ...$extra_capabilities Additional capability slugs (user must hold any one). * @return array|null */ protected function guard( string $capability, string ...$extra_capabilities ): ?array { if ( ! function_exists( 'pointics' ) ) { return $this->steer( [ 'error' => __( 'Pointics plugin is not active.', 'aisentic' ), 'code' => 'internal', 'status' => null, ] ); } $all_capabilities = array_merge( [ $capability ], $extra_capabilities ); foreach ( $all_capabilities as $cap ) { if ( current_user_can( $cap ) ) { return null; } } return $this->steer( [ 'error' => __( 'You do not have permission to manage Pointics data.', 'aisentic' ), 'code' => 'invalid_argument', 'status' => 403, ] ); } /** * Unwrap a Pointics REST controller result into a data array. * * Normalises the two shapes `Pointics\Api\Base_Controller` returns: * - `fail()` returns a `WP_Error` (message + `[ 'status' => int ]` data). * - `ok()` / `saved()` return a `WP_HTTP_Response`. `ok()` (reads) puts the * resource directly in the response body; `saved()` (writes) wraps it as * `[ 'success' => true, 'message' => …, 'data' => … ]`. Only the write * shape is unwrapped. A false `success` flag is a logical failure, not * successful data. Provider codes are preserved as `pointics_code` while * `code` remains the shared taxonomy. * * @param \WP_Error|\WP_HTTP_Response $result Controller return value. * @param string $fallback_error Human-readable fallback error message. * @param string|null $tool_name Optional tool name for steering context. * @return array Normalised data or a shared Pointics error taxonomy. */ protected function extract_response( $result, string $fallback_error, ?string $tool_name = null ): array { $this->steering_tool_name = $tool_name; if ( is_wp_error( $result ) ) { $message = $this->normalize_message( $result->get_error_message(), $fallback_error ); $data = $result->get_error_data( $result->get_error_code() ); $status = $this->error_status( $data ); $provider_code = (string) $result->get_error_code(); $extracted = [ 'error' => '' !== $message ? $message : $fallback_error, 'code' => $this->classify_error( $status, $provider_code, $message ), 'status' => $status, ]; $this->add_provider_code( $extracted, $provider_code ); $this->add_valid_vocabulary( $extracted, $data, $message, $status ); return $this->steer( $extracted ); } if ( ! $result instanceof \WP_HTTP_Response ) { Log::warning( 'Pointics controller returned an unexpected type', [ 'type' => gettype( $result ) ] ); return $this->steer( [ 'error' => $fallback_error, 'code' => 'internal', 'status' => null, ] ); } $status = $result->get_status(); $body = $result->get_data(); if ( $status >= 400 ) { $message = is_array( $body ) ? $this->normalize_message( $body['message'] ?? $fallback_error, $fallback_error ) : $fallback_error; $provider_code = $this->provider_code_from_body( $body ); $extracted = [ 'error' => $message, 'code' => $this->classify_error( $status, $provider_code, $message ), 'status' => $status, ]; $this->add_provider_code( $extracted, $provider_code ); $this->add_valid_vocabulary( $extracted, $body, $message, $status ); return $this->steer( $extracted ); } if ( ! is_array( $body ) ) { Log::error( 'Pointics controller returned non-array body', [ 'status' => $status, 'type' => gettype( $body ), ] ); return $this->steer( [ 'error' => $fallback_error, 'code' => 'internal', 'status' => $status, ] ); } $provider_code = $this->provider_code_from_body( $body ); if ( array_key_exists( 'success', $body ) && ! ( true === $body['success'] || 1 === $body['success'] ) ) { $message = $this->normalize_message( $body['message'] ?? $fallback_error, $fallback_error ); $extracted = $body; $extracted['error'] = $message; $extracted['code'] = 'logical_failure'; $extracted['status'] = $status; $this->add_provider_code( $extracted, $provider_code ); return $this->steer( $extracted ); } // saved()'s successful write envelope is the only shape to unwrap. if ( array_key_exists( 'success', $body ) ) { if ( ! array_key_exists( 'data', $body ) || ! is_array( $body['data'] ) ) { Log::warning( 'Pointics controller write response missing a data array; preserving the response body', [ 'body_keys' => array_keys( $body ) ] ); return $body; } return $body['data']; } // ok()'s read shape — the resource is the body itself. return $body; } /** * Preserve a provider error code without exposing it as the shared code. * * @param array $result Error result being assembled. * @param string $provider_code Pointics controller/service code. * @return void */ private function add_provider_code( array &$result, string $provider_code ): void { if ( '' !== $provider_code && ! array_key_exists( 'pointics_code', $result ) ) { $result['pointics_code'] = $provider_code; } } /** * Read a provider code from a Pointics response body. * * @param mixed $body Pointics response body. * @return string */ private function provider_code_from_body( $body ): string { if ( ! is_array( $body ) ) { return ''; } $code = $body['code'] ?? null; if ( ! is_string( $code ) && is_array( $body['data'] ?? null ) ) { $code = $body['data']['code'] ?? null; } return is_scalar( $code ) ? (string) $code : ''; } /** * Normalize a controller message to safe, useful text. * * @param mixed $message Controller message value. * @param string $fallback Fallback when no text can be recovered. * @return string */ private function normalize_message( $message, string $fallback ): string { if ( is_string( $message ) ) { return '' !== $message ? $message : $fallback; } if ( is_scalar( $message ) ) { return (string) $message; } $values = is_object( $message ) ? get_object_vars( $message ) : ( is_array( $message ) ? $message : [] ); $parts = []; foreach ( $values as $value ) { $text = $this->normalize_message( $value, '' ); if ( '' !== $text ) { $parts[] = $text; } } return ! empty( $parts ) ? implode( ' ', $parts ) : $fallback; } /** * Classify a controller failure while retaining its provider code separately. * * @param int|null $status HTTP status, when available. * @param string $provider_code Pointics controller/service code. * @param mixed $message Pointics error message. * @return string Shared error taxonomy code. */ private function classify_error( ?int $status, string $provider_code, $message ): string { $message = is_scalar( $message ) ? (string) $message : ''; $markers = strtolower( $provider_code . ' ' . $message ); if ( 409 === $status || preg_match( '/award[\s_-]+failed|duplicate|already[\s_-](?:awarded|exists|held|registered|completed)/', $markers ) ) { return 'logical_failure'; } if ( null === $status || $status < 400 ) { return 'internal'; } return $this->error_code_for_status( $status ); } /** * Extract an HTTP status from Pointics error data when one is available. * * @param mixed $data Pointics error data. * @return int|null HTTP status, or null when the controller did not provide one. */ private function error_status( $data ): ?int { $status = is_array( $data ) ? ( $data['status'] ?? null ) : $data; return is_numeric( $status ) ? (int) $status : null; } /** * Add the Pointics validation vocabulary to a 422 error when provided. * * @param array $result Error result being assembled. * @param mixed $data Controller error data or response body. * @param mixed $message Controller error message. * @param int|null $status HTTP status. * @return void */ private function add_valid_vocabulary( array &$result, $data, $message, ?int $status ): void { if ( 422 !== $status ) { return; } $valid = is_array( $data ) ? ( $data['valid'] ?? null ) : null; if ( ! is_string( $valid ) && is_array( $data ) && is_array( $data['data'] ?? null ) ) { $valid = $data['data']['valid'] ?? null; } if ( ! is_string( $valid ) || '' === $valid ) { $message = $this->normalize_message( $message, '' ); $position = strpos( $message, 'valid:' ); $valid = false !== $position ? trim( substr( $message, $position ) ) : null; } if ( is_string( $valid ) && '' !== $valid ) { $result['valid'] = $valid; } } /** * Add actionable recovery guidance to an error without dropping its data. * * Successful payloads are returned byte-for-byte as received. Validation * vocabulary is appended from `valid` without rewriting that value. * * @param array $data Normalised Pointics data or error result. * @return array The original data, with guidance appended to errors. */ protected function steer( array $data ): array { if ( ! $this->is_error( $data ) ) { return $data; } $status = (int) ( $data['status'] ?? 0 ); $guidance = 429 === $status ? __( 'Pointics limit was reached. Do not immediately retry.', 'aisentic' ) : match ( $data['code'] ?? '' ) { 'not_found' => $this->not_found_guidance( $this->steering_tool_name ), 'invalid_argument' => __( 'Check the provided arguments and your Pointics permissions, then try again.', 'aisentic' ), 'validation' => __( 'Correct the input and try again.', 'aisentic' ), 'logical_failure' => __( 'Check the current Pointics state and try again.', 'aisentic' ), 'internal' => __( 'Check the Pointics installation and database migrations. Do not retry blindly.', 'aisentic' ), default => __( 'Review the error and try again.', 'aisentic' ), }; if ( 'validation' === ( $data['code'] ?? '' ) && isset( $data['valid'] ) && is_scalar( $data['valid'] ) && '' !== (string) $data['valid'] ) { $guidance .= ' ' . sprintf( /* translators: %s: accepted validation values */ __( 'Accepted values: %s', 'aisentic' ), (string) $data['valid'] ); } $error = is_scalar( $data['error'] ) ? (string) $data['error'] : __( 'Pointics operation failed.', 'aisentic' ); if ( false !== strpos( $error, $guidance ) ) { return $data; } $data['error'] = trim( $error . ' ' . $guidance ); return $data; } /** * Build a useful list-tool instruction for a not-found result. * * @param mixed $tool_name Tool name passed by the caller, if available. * @return string Actionable recovery guidance. */ private function not_found_guidance( $tool_name ): string { $list_tool = 'pointics-list-*'; if ( is_string( $tool_name ) && preg_match( '/^pointics-list-[a-z0-9-]+$/', $tool_name ) ) { $list_tool = $tool_name; } elseif ( is_string( $tool_name ) && preg_match( '/^pointics-(?:get|find|retrieve|delete|update)-(.+)$/', $tool_name, $matches ) ) { $resource = preg_replace( '/-by-(?:id|uuid)$/', '', $matches[1] ); if ( str_ends_with( $resource, 'y' ) ) { $resource = substr( $resource, 0, -1 ) . 'ies'; } elseif ( ! str_ends_with( $resource, 's' ) ) { $resource .= 's'; } $list_tool = 'pointics-list-' . $resource; } return sprintf( /* translators: %s: relevant Pointics list tool name */ __( 'Check the identifier and confirm the record exists. Use %s to find a valid record before trying again.', 'aisentic' ), $list_tool ); } /** * Check whether Pointics' ledger table and migration version are available. * * This is the only base-tool method permitted to probe the Pointics database * directly. All operational data access must go through Pointics services or * its REST layer. * * @return array|null Internal error taxonomy when unavailable, null when ready. */ protected function pointics_tables_ready(): ?array { global $wpdb; if ( ! isset( $wpdb ) || ! is_object( $wpdb ) || ! isset( $wpdb->prefix ) || ! method_exists( $wpdb, 'prepare' ) || ! method_exists( $wpdb, 'get_var' ) || ! method_exists( $wpdb, 'esc_like' ) ) { return $this->pointics_database_error(); } $table = $wpdb->prefix . 'pointics_ledger'; $table_like = $wpdb->esc_like( $table ); $found_table = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_like ) ); if ( $table !== $found_table || empty( get_option( 'pointics_db_version', false ) ) ) { return $this->pointics_database_error(); } return null; } /** * Whether a normalised result contains an error. * * @param array $data Normalised Pointics result. * @return bool */ protected function is_error( array $data ): bool { return array_key_exists( 'error', $data ); } /** * Classify an HTTP status for the shared Pointics error taxonomy. * * @param int $status HTTP status code. * @return string Shared error code. */ private function error_code_for_status( int $status ): string { if ( 404 === $status ) { return 'not_found'; } if ( 422 === $status ) { return 'validation'; } if ( $status >= 500 ) { return 'internal'; } return 'invalid_argument'; } /** * Build the actionable error returned when Pointics storage is unavailable. * * @return array Shared internal error taxonomy. */ private function pointics_database_error(): array { return [ 'error' => __( 'Pointics database tables are unavailable. Install or update Pointics and run its database migrations, then try again.', 'aisentic' ), 'code' => 'internal', 'status' => 500, ]; } /** * Return only the whitelisted keys from an associative array. * * Keys absent from the source are skipped. Used to curate verbose * REST/service records down to the fields the LLM actually needs, * keeping tool results small. * * @param array $item Source associative array. * @param array $keys Keys to keep, in order. * @return array */ protected function pluck( array $item, array $keys ): array { $out = []; foreach ( $keys as $key ) { if ( array_key_exists( $key, $item ) ) { $out[ $key ] = $item[ $key ]; } } return $out; } /** * Map every row of a list through a shaper callback. * * Non-array rows are dropped and keys are re-indexed so the result is a * clean sequential list. * * @param array $rows List of records. * @param callable $shape Receives one record, returns the curated record. * @return array */ protected function shape_rows( array $rows, callable $shape ): array { $arrays = array_filter( $rows, 'is_array' ); return array_values( array_map( $shape, $arrays ) ); } /** * Shape a paginated Pointics list response. * * `Base_Controller::paginated()` puts the grand total in the body's * `total` key (the page count only exists as the `X-WP-TotalPages` * header, which a direct in-process controller call never surfaces). The * useful `counts` metadata is preserved explicitly while arbitrary response * keys are dropped — see * `core/Integrations/CLAUDE.md` rule 9 on always naming a true grand * total `total`, never `returned_count`. * * @param array $data Decoded controller data (expects an 'items' key). * @param callable $shape Receives one item, returns the curated item. * @return array */ protected function shape_paginated( array $data, callable $shape ): array { $has_items = isset( $data['items'] ) && is_array( $data['items'] ); $items = $has_items ? $data['items'] : []; $shaped = [ 'items' => $this->shape_rows( $items, $shape ) ]; if ( isset( $data['total'] ) ) { $shaped['total'] = $data['total']; } if ( isset( $data['counts'] ) && is_array( $data['counts'] ) ) { $shaped['counts'] = $data['counts']; } return $shaped; } /** * Scan the full paginated list for an active record matching a name or slug. * * Only active records are considered: Pointics soft-deactivates, so a * deactivated row with the same name must never block re-creating it. * The list API has no name filter, so pages are walked until the reported * total is covered; an incomplete list aborts the check rather than * risking a duplicate. Rows without a positive ID never match, so a * malformed list cannot steer the model toward an unusable "ID: 0". * * @param object $controller Pointics controller exposing the list method. * @param string $list_method Controller list method name. * @param string $list_tool List tool name used for error steering. * @param string $name Proposed record name. * @param string $entity_label Singular entity noun for error messages (e.g. 'reward'). * @param string|null $slug Optional explicit slug; falls back to the name. * @return array|int|null Error taxonomy, duplicate record ID, or null when unique. */ protected function find_duplicate_by_name( object $controller, string $list_method, string $list_tool, string $name, string $entity_label, ?string $slug = null ): array|int|null { $name_needle = strtolower( trim( $name ) ); $slug_needle = sanitize_title( '' !== trim( (string) $slug ) ? (string) $slug : $name ); $page = 1; $seen = 0; do { $request = new \WP_REST_Request( 'GET' ); $request->set_param( 'page', $page ); $request->set_param( 'per_page', 100 ); $request->set_param( 'is_active', true ); $data = $this->extract_response( $controller->$list_method( $request ), sprintf( /* translators: %s: entity plural, e.g. rewards. */ __( 'Failed to check existing Pointics %s.', 'aisentic' ), $entity_label . 's' ), $list_tool ); if ( $this->is_error( $data ) ) { return $data; } if ( ! array_key_exists( 'total', $data ) || ! is_numeric( $data['total'] ) || (int) $data['total'] < 0 ) { return $this->steer( [ 'error' => sprintf( /* translators: %s: entity singular, e.g. reward. */ __( 'Pointics returned an incomplete %s list while checking for duplicates. Do not retry blindly.', 'aisentic' ), $entity_label ), 'code' => 'internal', 'status' => null, ] ); } $total = (int) $data['total']; $items = isset( $data['items'] ) && is_array( $data['items'] ) ? $data['items'] : []; if ( [] === $items && $total > 0 ) { return $this->steer( [ 'error' => sprintf( /* translators: %s: entity singular, e.g. reward. */ __( 'Pointics returned an incomplete %s list while checking for duplicates. Do not retry blindly.', 'aisentic' ), $entity_label ), 'code' => 'internal', 'status' => null, ] ); } foreach ( $items as $item ) { if ( ! is_array( $item ) ) { continue; } $item_name = strtolower( trim( (string) ( $item['name'] ?? '' ) ) ); $item_slug = sanitize_title( (string) ( $item['slug'] ?? '' ) ); if ( $item_name !== $name_needle && ( '' === $slug_needle || $item_slug !== $slug_needle ) ) { continue; } $id = (int) ( $item['id'] ?? 0 ); if ( $id > 0 ) { return $id; } } $seen += count( $items ); if ( $seen >= $total ) { return null; } ++$page; } while ( $page <= 10000 ); return $this->steer( [ 'error' => sprintf( /* translators: %s: entity singular, e.g. reward. */ __( 'Pointics %s pagination did not reach its reported total. Do not retry blindly.', 'aisentic' ), $entity_label ), 'code' => 'internal', 'status' => null, ] ); } /** * Curate a full channel record. * * @param array $data Raw channel data. * @return array */ protected function shape_channel( array $data ): array { return $this->with_admin_url( $this->pluck( $data, [ 'id', 'slug', 'name', 'description', 'trigger', 'integration', 'type', 'calc_type', 'calc_params', 'frequency', 'frequency_params', 'expiry_days', 'conditions', 'user_roles', 'reversal_trigger', 'is_active', 'sort_order', 'meta', 'created_at', 'updated_at' ] ), 'points-program' ); } /** * The admin link that opens one record. * * Pointics registers a single admin page and routes every screen off the * hash, so this shape is not guessable from the record alone. Handing the * finished URL to the model is what stops it inventing one. * * @param string $section Hash section, e.g. `points-program`. * @param int|string $id Record id. */ protected function admin_url_for( string $section, $id ): string { return admin_url( 'admin.php?page=pointics#/' . $section . '/update/' . $id ); } /** * Attach that link to a shaped record. A record with no id has nothing to * open, and half a URL is worse than none. * * @param array $shaped * * @return array */ protected function with_admin_url( array $shaped, string $section ): array { if ( isset( $shaped['id'] ) && '' !== (string) $shaped['id'] ) { $shaped['admin_url'] = $this->admin_url_for( $section, $shaped['id'] ); } return $shaped; } /** * Curate a full reward record. * * @param array $data Raw reward data. * @return array */ protected function shape_reward( array $data ): array { return $this->with_admin_url( $this->pluck( $data, [ 'id', 'slug', 'name', 'description', 'integration', 'redeem_method', 'redeem_type', 'redeem_params', 'cost_points', 'value', 'currency', 'conditions', 'user_roles', 'is_active', 'sort_order', 'created_at', 'updated_at' ] ), 'rewards' ); } /** * Build a JSON-body REST request with the same params mirrored as fields. * * The JSON body drives Pointics' `get_json_params()`-based reads, while * the mirrored params cover controller paths that read `$request[$key]`. * * @param string $method HTTP method. * @param string $route REST route (informational for in-process calls). * @param array $body Request body fields. * @return \WP_REST_Request */ protected function json_request( string $method, string $route, array $body ): \WP_REST_Request { $request = new \WP_REST_Request( $method, $route ); $request->set_header( 'content-type', 'application/json' ); $request->set_body( wp_json_encode( $body ) ); foreach ( $body as $key => $value ) { $request->set_param( $key, $value ); } return $request; } /** * Resolve an entity by ID through a controller GET for approval cards. * * Returns the raw resource array on success and [] when the lookup is * unavailable or fails, so approval UI can degrade to describing the * supplied inputs instead of blocking the card. * * @param string $controller_class Pointics controller class name. * @param string $get_method Controller read method name. * @param int $id Entity ID. * @param string $tool_name Tool name used for not-found steering. * @param string $fallback_error Fallback message for lookup failures. * @return array */ protected function entity_lookup( string $controller_class, string $get_method, int $id, string $tool_name, string $fallback_error ): array { if ( $id <= 0 || ! function_exists( 'pointics_container' ) ) { return []; } try { $controller = pointics_container()->make( $controller_class ); $request = new \WP_REST_Request( 'GET' ); $request->set_param( 'id', $id ); $data = $this->extract_response( $controller->$get_method( $request ), $fallback_error, $tool_name ); return $this->is_error( $data ) ? [] : $data; } catch ( \Throwable $e ) { return []; } } /** * Resolve id -> label maps for live channel triggers and calculation * strategies, for approval cards. * * Channel create/update inputs carry raw dotted trigger identifiers * (`pointics.referral_registered`) and strategy ids (`fixed`) that mean * nothing to a human approver. Returns [] for either vocabulary when the * live lookup is unavailable or fails, so approval UI degrades to a * humanized identifier (see humanize_slug()) instead of blocking the card. * * @return array{triggers: array, strategies: array} */ protected function channel_vocabulary_labels(): array { // Approval rendering calls format_approval_summary() and // get_approval_inputs() on the same tool instance; memoize so the // two live lookups run once per approval card, not twice. if ( null !== $this->channel_vocabulary_cache ) { return $this->channel_vocabulary_cache; } $labels = [ 'triggers' => [], 'strategies' => [], ]; if ( function_exists( 'pointics_container' ) ) { try { $controller = pointics_container()->make( \Pointics\Api\Controllers\Channels_Controller::class ); $triggers = $this->extract_response( $controller->list_triggers(), '', 'pointics-list-channel-building-blocks' ); $labels = [ 'triggers' => $this->is_error( $triggers ) ? [] : $this->id_label_map( $triggers['items'] ?? [] ), 'strategies' => $this->id_label_map( array_values( $this->live_strategies() ) ), ]; } catch ( \Throwable $e ) { $labels = [ 'triggers' => [], 'strategies' => [], ]; } } $this->channel_vocabulary_cache = $labels; return $labels; } /** * Decode a parameter-map tool input into an associative array. * * Params like calc_params / conditions are dynamic key-value maps, which no * provider models cleanly: an ARRAY property makes Gemini reject the whole * request, and models routinely answer an OBJECT property with a * single-element list (`[{"points":100}]`) instead of the object itself. * The properties are therefore declared as STRING (a JSON object string) * and funnelled through here, which accepts every shape a model realistically * sends — JSON string, associative array, or a list wrapping one map — and * returns a plain map. * * Three outcomes, kept distinct because they demand different handling: * null when the input was absent (leave the field alone), false when it was * supplied but cannot be read as a map (fail, never write a value that would * wipe the real one), and an array otherwise. * * @param mixed $raw Raw tool input. * @return array|false|null */ protected function normalize_param_map( $raw ) { if ( null === $raw || '' === $raw || [] === $raw ) { return null; } $decoded = is_string( $raw ) ? json_decode( $raw, true ) : $raw; if ( ! is_array( $decoded ) ) { return false; } // A list of exactly one map is the model wrapping the object it was // asked for; unwrap it rather than storing a list Pointics reads as // empty. Any other list is a genuinely wrong shape. if ( array_is_list( $decoded ) ) { if ( 1 !== count( $decoded ) || ! is_array( $decoded[0] ) || array_is_list( $decoded[0] ) ) { return false; } $decoded = $decoded[0]; } return $decoded; } /** * Steering error for a params map the model sent in an unreadable shape. * * @param string $field Tool property name, e.g. "calc_params". * @return array */ protected function param_map_error( string $field ): array { return $this->steer( [ 'error' => sprintf( /* translators: %s: tool property name. */ __( '%s could not be read as a parameter object. Send it as a JSON object string, e.g. {"points":100} — not a list, not a bare value.', 'aisentic' ), $field ), 'code' => 'validation', 'status' => 422, ] ); } /** * Live parameter schema for one calculation strategy, keyed by field name. * * @param string $calc_type Strategy identifier, e.g. "fixed". * @return array>|null Null when the strategy is unknown or the lookup failed. */ protected function strategy_schema( string $calc_type ): ?array { if ( '' === $calc_type ) { return null; } $schema = $this->live_strategies()[ $calc_type ]['schema'] ?? null; return is_array( $schema ) ? $schema : null; } /** * Live calculation strategies keyed by id, memoized per tool instance. * * Both the approval-card vocabulary and calc_params validation need this * one response, so it is fetched once and each caller takes the slice it * wants. Returns [] when Pointics is inactive or the lookup fails, which * degrades labels to humanized ids and skips schema validation. * * @return array> */ private function live_strategies(): array { if ( null !== $this->strategies_cache ) { return $this->strategies_cache; } $by_id = []; if ( function_exists( 'pointics_container' ) ) { try { $controller = pointics_container()->make( \Pointics\Api\Controllers\Channels_Controller::class ); $strategies = $this->extract_response( $controller->list_strategies(), '', 'pointics-list-channel-building-blocks' ); if ( ! $this->is_error( $strategies ) ) { foreach ( $strategies['items'] ?? [] as $item ) { $id = is_array( $item ) ? (string) ( $item['id'] ?? '' ) : ''; if ( '' !== $id ) { $by_id[ $id ] = $item; } } } } catch ( \Throwable $e ) { $by_id = []; } } $this->strategies_cache = $by_id; return $by_id; } /** * Validate calculation params against the live strategy schema. * * Pointics stores calc_params verbatim and its strategies fall back to zero * points for any field they cannot read, so a malformed or incomplete map * creates a rule that silently awards nothing. Fail the tool call instead, * and name the fields the strategy actually wants so the model can retry * with the right shape. * * @param string $calc_type Strategy identifier. * @param array|null $calc_params Decoded parameters. * @return array|null Steering error array, or null when valid. */ protected function validate_calc_params( string $calc_type, ?array $calc_params ): ?array { $schema = $this->strategy_schema( $calc_type ); if ( null === $schema ) { // Unknown strategy, or the vocabulary is unreachable: Pointics // itself is the authority on both, so let the write through rather // than blocking on a lookup this tool only uses for guidance. return null; } $required = []; foreach ( $schema as $field => $spec ) { if ( is_array( $spec ) && ! empty( $spec['required'] ) ) { $required[] = (string) $field; } } $missing = array_values( array_filter( $required, static fn( string $field ): bool => null === ( $calc_params[ $field ] ?? null ) || '' === $calc_params[ $field ] ) ); if ( [] === $missing ) { return null; } return $this->steer( [ 'error' => sprintf( /* translators: 1: calculation strategy id, 2: comma-separated field names, 3: example JSON object. */ __( 'calc_params is missing field(s) the "%1$s" strategy requires: %2$s. Send calc_params as a JSON object string matching the strategy schema, e.g. %3$s — not a list and not an empty value.', 'aisentic' ), $calc_type, implode( ', ', $missing ), $this->calc_params_example( $schema ) ), 'code' => 'validation', 'status' => 422, ] ); } /** * Build a concrete calc_params example from a live strategy schema, so the * validation error shows the exact shape rather than describing it. * * @param array> $schema Strategy schema. * @return string */ private function calc_params_example( array $schema ): string { $example = []; foreach ( $schema as $field => $spec ) { $type = is_array( $spec ) ? (string) ( $spec['type'] ?? 'string' ) : 'string'; $example[ $field ] = in_array( $type, [ 'integer', 'number' ], true ) ? 100 : ''; } return (string) wp_json_encode( $example ); } /** * Build an id -> label map from a live vocabulary's item list. * * @param array $items Vocabulary items, each with at least 'id' and 'label'. * @return array */ private function id_label_map( array $items ): array { $map = []; foreach ( $items as $item ) { $id = is_array( $item ) ? (string) ( $item['id'] ?? '' ) : ''; if ( '' !== $id ) { $map[ $id ] = (string) ( $item['label'] ?? $id ); } } return $map; } /** * Turn a raw identifier/slug into a human-readable label as an approval * card fallback when the live vocabulary lookup has no match for it. * * @param string $slug Raw identifier, e.g. "pointics.referral_registered". * @return string */ protected function humanize_slug( string $slug ): string { $words = str_replace( [ '_', '-', '.' ], ' ', $slug ); return ucwords( trim( $words ) ); } /** * Resolve a live vocabulary identifier to its label, falling back to a * humanized version of the identifier when the vocabulary has no match for * it (e.g. it was omitted, or the live lookup failed). * * @param array $map Id -> label vocabulary map. * @param string $id Raw identifier to resolve. * @return string */ protected function resolved_label( array $map, string $id ): string { if ( '' === $id ) { return __( '?', 'aisentic' ); } return $map[ $id ] ?? $this->humanize_slug( $id ); } /** * Render calculation-strategy parameters as a readable "key: value" list * instead of raw JSON for approval cards. * * Accepts either shape the model may send: a single params object * (`{"points": 100}`) or a list of them (`[{"points": 100}]`). * * @param array $calc_params Calculation parameters. * @return string */ protected function format_calc_params( array $calc_params ): string { if ( [] === $calc_params ) { return __( 'None', 'aisentic' ); } $entries = array_is_list( $calc_params ) ? $calc_params : [ $calc_params ]; $parts = []; foreach ( $entries as $entry ) { if ( is_array( $entry ) ) { $pairs = []; foreach ( $entry as $key => $value ) { $pairs[] = $this->humanize_slug( (string) $key ) . ': ' . ( is_scalar( $value ) ? (string) $value : wp_json_encode( $value ) ); } $parts[] = implode( ', ', $pairs ); } else { $parts[] = (string) $entry; } } return implode( '; ', $parts ); } /** * Strip HTML and truncate free-text to a token-friendly length. * * @param string|null $text Raw text, possibly containing HTML. * @param int $length Maximum length in characters. * @return string */ protected function truncate( ?string $text, int $length = 300 ): string { $length = max( 1, $length ); $text = trim( wp_strip_all_tags( (string) $text ) ); if ( '' === $text ) { return ''; } return wp_html_excerpt( $text, $length, '…' ); } }