Connectors via WordPress core's AI Client * (wp_ai_client_prompt(), WP 7.0+). * * This class is only the conversation: the prompt, the history, the reply. The rule itself * is built by RuleBuilder from the fields the model extracted. * * Deliberately never writes to the database itself: the AI is not trusted to * commit pricing changes unsupervised. It only builds the rule's data and hands * it to the admin JS to insert as an unsaved row - the admin reviews it and * clicks the rule's own Save button, going through the exact same * ajax_save_rule() path a manually-built rule would. */ class RuleAssistant { const LOG_FILE = 'adp-ai-chat-debug.log'; // how many prior messages (user + assistant turns combined) to replay as // conversation history on every request - the AI Client is stateless per // request, so without this the model never sees anything before the // current message and can't handle follow-ups ("make it 20% instead") const HISTORY_LIMIT = 20; const AVAILABILITY_TRANSIENT = 'adp_ai_chat_availability'; const TAB_RULES = 'rules'; const MODE_RULES = 'rules'; const MODE_SETTINGS = 'settings'; /** * Check of whether the chat can be used at all right now, for the admin page to decide * what to show BEFORE the admin types anything - handle() already checks * wp_ai_client_prompt() existence and is_supported_for_text_generation() per request, but * that only surfaces as a reply after a message is sent; this lets the UI show the right * state (and hide the input entirely) up front instead. * * is_supported_for_text_generation() is NOT a free local check for every provider: for a * provider whose model list isn't baked in (e.g. the DeepSeek connector), it lists models * via a real HTTP request to the provider's API, and WordPress core's AI client has no * persistent cache wired up for that by default - only an in-memory one scoped to the * current request. Without caching our own result, that network round trip (seconds, and * occasionally failing outright) would run again on every single admin page load of the * Rules tab. So the outcome is cached here in a short-lived transient, and the check * itself is wrapped in a try/catch - a transient provider-side hiccup should not break * rendering of the whole admin page. * * @return array{wp_version_ok: bool, ai_configured: bool} */ public static function checkAvailability() { global $wp_version; $wpVersionOk = $wp_version && version_compare($wp_version, '7.0', '>='); $aiConfigured = false; if ($wpVersionOk && function_exists('wp_ai_client_prompt')) { $cached = get_transient(self::AVAILABILITY_TRANSIENT); if ($cached === '1' || $cached === '0') { $aiConfigured = $cached === '1'; } else { try { $aiConfigured = wp_ai_client_prompt('ping')->is_supported_for_text_generation(); } catch (\Throwable $e) { $aiConfigured = false; } // a failed check is often a passing hiccup of the provider's API (its model list // could not be fetched): keep it only briefly, so the chat does not stay hidden set_transient(self::AVAILABILITY_TRANSIENT, $aiConfigured ? '1' : '0', $aiConfigured ? 5 * MINUTE_IN_SECONDS : 30); } } return array('wp_version_ok' => $wpVersionOk, 'ai_configured' => $aiConfigured); } /** * @param string $text user's chat message * @param int|null $ruleId rule currently open in the editor, if any (for context only) * @param ChatMessage[] $history prior messages in the conversation, oldest first (e.g. from * ChatRepository::getHistory()) - NOT including the current $text * @param array|null $liveRuleState the open rule's current, possibly-unsaved on-screen form state * (Rule::getData() shape, from Ajax::parseLiveRuleState()) - preferred * over the saved DB copy when given, since the admin may have made * (still-unsaved) edits - via this chat or by hand - since the rule * was last saved, and the AI should reason about what's actually on * screen, not stale data * @param string $tab key of the admin tab the chat was used on ("rules", "options", * "tools", ...). Decides which instruction set the model gets: the * rule-drafting one on the Rules tab, the settings/navigation * reference everywhere else - a deterministic signal, not something * the model is asked to judge up front. * * @return array{reply: string, rule: array|null, go_to_rules: bool} reply to show in the chat, the * created rule's data (Rule::getData() shape) when an action * created one, and whether to offer a link to the Rules tab */ public static function handle($text, $ruleId, $history = array(), $liveRuleState = null, $tab = self::TAB_RULES) { if ( ! function_exists('wp_ai_client_prompt')) { return self::result(__('AI Client is not available on this site (requires WordPress 7.0+).', 'advanced-dynamic-pricing-for-woocommerce')); } if ($tab !== self::TAB_RULES) { return self::answerSettingsQuestion($text, $history, true); } $openRule = $liveRuleState ?: ($ruleId ? RuleDescriber::loadRule($ruleId) : null); $ruleContextInstruction = self::buildRuleContextInstruction($openRule, $ruleId); // Single pass, always with the FULL instructions (free + Pro mechanisms together) - the // model picks whichever action/fields genuinely fit the request, Pro-only ones included, // without trying to judge licensing itself (an earlier two-pass design that asked it to // flag `needs_pro` up front led to it inventing free-tier workarounds instead of the // correct Pro mechanism in some cases, since pass 1 only ever saw the free vocabulary). // Licensing is instead checked AFTER the rule is built, below - see detectProFeature(). $run = self::runPrompt($text, self::getInstructions(self::MODE_RULES) . $ruleContextInstruction, $history); if (isset($run['error'])) { return self::result($run['error']); } $decoded = $run['decoded']; $reply = (string)$decoded['reply']; $existingRule = $openRule && ! empty($decoded['edit_current_rule']) ? $openRule : null; $actionName = $decoded['action'] ?? 'none'; if ($actionName === 'need_settings') { // a settings/navigation question asked on the Rules tab - the reference for those // isn't part of the rule-drafting instructions, so ask again with it loaded. A // wrong call here is harmless (worst case: "I don't know of such a setting"), unlike // routing rule drafting through a model-made guess. return self::answerSettingsQuestion($text, $history, false); } $action = RuleBuilder::applyAction($actionName, $decoded, $existingRule); if ($action) { $proFeature = ProFeatureDetector::detectProFeature($action['rule'], $decoded, $actionName); if ($proFeature && ! ProFeatureDetector::isProActive()) { // The model built a real rule using a Pro-only mechanism, but this site doesn't // actually have Pro - discard the draft entirely and say so, rather than // showing something the admin could never save/use. This is the one // programmatic (not model-judged) licensing check in the whole flow. return self::result(sprintf( /* translators: %s: short name of the Pro-only feature the draft would have needed, e.g. "a Product set deal" */ __('That needs %s, which requires the Pro version of this plugin. Upgrading would unlock it.', 'advanced-dynamic-pricing-for-woocommerce'), $proFeature )); } } if ($action && ! $action['found']) { // Only append the backend's own note when something it alone can verify // (a product/category/role/attribute/etc. lookup) failed - that's the one // thing the AI has no way to know about. On a clean match the AI's own // reply (per the instructions) already confirms the draft in the user's // own language, and the new row is right there in the list. $reply .= "\n\n" . $action['note']; } elseif ( ! $action && $actionName !== 'none') { // The AI picked a real action and its `reply` text almost certainly claims a // draft was added/updated (per the instructions) - but the builder for that // action couldn't produce anything at all (a required field was missing, or a // value like `filter_type` fell outside what's actually implemented). Showing // the AI's own reply as-is here would tell the admin a draft was added when // nothing was - so it's replaced with an honest fallback instead. $reply = __("Sorry, I couldn't put together a draft for that - could you rephrase or give a bit more detail?", 'advanced-dynamic-pricing-for-woocommerce'); } return self::result( $reply, $action ? $action['rule'] : null, $action ? $action['titles'] : null, $action ? $action['links'] : null, $action ? $action['edited'] : false ); } private static function result($reply, $rule = null, $titles = null, $links = null, $edited = false) { return array( 'reply' => $reply, 'rule' => $rule, 'titles' => $titles, 'links' => $links, 'edited' => $edited, 'go_to_rules' => false, ); } /** * Answers from the settings/navigation reference (instructions/settings-*.md) - never * drafts a rule. `go_to_rules` is how that instruction set says "this is a request to * make/change a rule": off the Rules tab the frontend then adds a link to that tab under * the reply ($offerRulesTab). On the Rules tab itself that answer makes no sense (the * rule-drafting pass just sent the question here), so it becomes a "please rephrase". */ private static function answerSettingsQuestion($text, $history, $offerRulesTab) { $run = self::runPrompt($text, self::getInstructions(self::MODE_SETTINGS), $history, true); if (isset($run['error'])) { return self::result($run['error']); } $wantsRule = ($run['decoded']['action'] ?? 'none') === 'go_to_rules'; if ($wantsRule && ! $offerRulesTab) { return self::result(self::unableToCreateMessage()); } $result = self::result((string)$run['decoded']['reply']); $result['go_to_rules'] = $wantsRule; return $result; } /** * Shown when no rule could be made from the request; the chat turns these two links * (and only links to our docs/support) into real links. */ private static function unableToCreateMessage() { return sprintf( /* translators: 1: documentation URL, 2: support URL */ __('Unable to create the rule. Please consult the documentation or contact support, providing the exact prompt for which the rule could not be created.', 'advanced-dynamic-pricing-for-woocommerce'), 'https://docs.algolplus.com/category/frequently-created-rules/', 'https://algolplus.freshdesk.com/support/tickets/new' ); } /** * The "admin currently has rule X open..." suffix appended to the system instruction - * split out into its own method mainly for readability. */ private static function buildRuleContextInstruction($openRule, $ruleId) { if ($openRule) { // $ruleId is empty for a rule the chat itself just drafted and the admin hasn't // clicked Save on yet - still a real, currently-open rule as far as editing goes, // just without a database id to quote $ruleLabel = $ruleId ? sprintf('#%d ("%s")', $ruleId, $openRule['title']) : sprintf('an unsaved draft titled "%s"', $openRule['title']); // the editing rules themselves are in instructions/00-overview.md ("Editing the rule // that's currently open") - only what is specific to this request goes here return sprintf( "\n\nThe admin currently has %s open in the rule editor. Its relevant settings, " . 'exactly as currently shown on screen right now (which may include edits - from this chat or ' . "made by hand - that haven't been saved yet, so this can differ from what's in the database), " . "are: %s\n\n" . 'If the user asks what this rule currently does, answer from the settings shown above. If the ' . 'message describes a change to THIS rule - including a correction of a draft you made a moment ' . 'ago - follow "Editing the rule that\'s currently open"; the values to keep in a list field are ' . 'the ones shown above.', $ruleLabel, RuleDescriber::summarizeRuleForContext($openRule) ); } if ($ruleId) { return "\n\nThe admin currently has rule #{$ruleId} open in the rule editor."; } return ''; } /** * Sends one prompt to the configured AI provider and returns either * `{'decoded': array}` (parsed JSON reply) or `{'error': string}` (a user-facing message * for handle() to return immediately). * * @param bool $acceptPlainText when the model answers with plain text instead of the JSON * object it was asked for, use that text as the reply * (`action: none`) rather than failing. Only safe where the * answer is nothing but text anyway (settings questions) - seen * in practice there, since the conversation history it is shown * consists of earlier replies stored as plain text. */ private static function runPrompt($text, $systemInstruction, $history, $acceptPlainText = false) { self::log('REQUEST', array( 'message' => $text, 'history' => array_map(function (ChatMessage $m) { return array('role' => $m->role, 'message' => $m->message); }, $history), 'instructions' => $systemInstruction, )); // Not using as_json_response() here: the configured connector (DeepSeek) // reports is_supported_for_text_generation() === false as soon as a JSON // schema is attached, i.e. it doesn't support schema-constrained output. // Falling back to plain text generation with strict prompt instructions // and lenient parsing below instead. $historyMessages = self::buildHistoryMessages($history); $buildPrompt = function ($options) use ($text, $systemInstruction, $historyMessages) { $prompt = wp_ai_client_prompt($text)->using_system_instruction($systemInstruction); if ($options['temperature'] !== null) { $prompt = $prompt->using_temperature($options['temperature']); } if ($options['max_tokens'] !== null) { $prompt = $prompt->using_max_tokens($options['max_tokens']); } return $historyMessages ? $prompt->with_history(...$historyMessages) : $prompt; }; // temperature / max tokens as set in Settings -> AI Assistant, minus what this model does not // take (by its metadata, or because it already refused it once - see ModelSettings) $options = ModelSettings::forPrompt(); $prompt = $buildPrompt($options); // No is_supported_for_text_generation() check here - it's a real (uncached) API call // to the provider (see checkAvailability()'s doc comment), and the admin page already // ran that check once at load time and hides the compose area entirely when it fails, // so this code only runs when a provider is expected to be available. generate_text() // below still fails gracefully (is_wp_error() branch) if that turns out to be wrong. $result = $prompt->generate_text_result(); // the model refused an option its metadata claims it takes: remember that, so it is // never sent to this model again, and ask once more without it while (is_wp_error($result) && ($refused = ModelSettings::refusedOption($result->get_error_message())) && $options[$refused] !== null) { self::log('RESPONSE', array('wp_error' => $result->get_error_message(), 'retrying' => 'without ' . $refused)); ModelSettings::remember($refused); $options[$refused] = null; $prompt = $buildPrompt($options); $result = $prompt->generate_text_result(); } if (is_wp_error($result)) { // one retry: the provider layer re-fetches its model list on every request, and a // single hiccup there surfaces as an error even though the provider is fine self::log('RESPONSE', array('wp_error' => $result->get_error_message(), 'retrying' => true)); $result = $prompt->generate_text_result(); } if (is_wp_error($result)) { self::log('RESPONSE', array('wp_error' => $result->get_error_message())); if (stripos($result->get_error_message(), 'No models found') !== false) { // the provider's model list could not be loaded - usually its API being briefly // unavailable or rate limited, not a missing connector return array('error' => __('The AI provider is not responding right now (its list of models could not be loaded). Please try again in a minute.', 'advanced-dynamic-pricing-for-woocommerce')); } return array('error' => sprintf( /* translators: %s: error message from the AI provider */ __('AI request failed: %s', 'advanced-dynamic-pricing-for-woocommerce'), $result->get_error_message() )); } ModelSettings::usedModel($result->getProviderMetadata()->getId() . '/' . $result->getModelMetadata()->getId()); try { $result = $result->toText(); } catch (\Throwable $e) { $result = ''; } self::log('RESPONSE', array('raw' => $result)); if (trim((string)$result) === '') { // Seen with DeepSeek specifically: a low max_tokens budget can get fully // spent on hidden reasoning tokens before any visible output is written, // leaving the response empty. Bumping using_max_tokens() above is the // main mitigation; this is the user-facing fallback for when it still happens. return array('error' => __('The AI returned an empty response - please try again.', 'advanced-dynamic-pricing-for-woocommerce')); } $decoded = self::parseJsonReply($result); if (( ! is_array($decoded) || empty($decoded['reply'])) && $acceptPlainText && strpos(ltrim((string)$result), '{') !== 0) { return array('decoded' => array('reply' => trim((string)$result), 'action' => 'none')); } if (is_array($decoded) && ($decoded['action'] ?? '') === 'need_settings') { // a pure hand-off - its `reply` is never shown, so an empty one is fine return array('decoded' => array('reply' => '', 'action' => 'need_settings')); } if ( ! is_array($decoded) || empty($decoded['reply'])) { // model didn't follow the "JSON only" instruction - the raw text is logged // above for debugging, but showing it as-is to the user is just confusing return array('error' => self::unableToCreateMessage()); } return array('decoded' => $decoded); } /** * Converts stored chat turns into the AI Client's Message DTOs for `with_history()`. * Order is preserved (oldest first), matching what `with_history()` expects - it * prepends them, in the given order, right before the current message being built. * * @param ChatMessage[] $history * * @return (UserMessage|ModelMessage)[] */ private static function buildHistoryMessages($history) { $messages = array(); foreach ($history as $chatMessage) { if ( ! $chatMessage instanceof ChatMessage || trim($chatMessage->message) === '') { continue; } $part = new MessagePart($chatMessage->message); $messages[] = $chatMessage->role === ChatMessage::ROLE_ASSISTANT ? new ModelMessage(array($part)) : new UserMessage(array($part)); } return $messages; } /** * Two instruction sets, picked by the caller (see handle()), never by the model: * * - MODE_RULES: everything needed to draft rules (NN-*.md) - each section documents its * free and Pro-only mechanisms TOGETHER, regardless of whether Pro is active. An earlier * two-pass design (free-only first, Pro detail on a second request) led the model to * invent incorrect free-tier workarounds for requests that needed a Pro-only mechanism; * licensing is instead enforced programmatically after the rule is built, see * detectProFeature(). * - MODE_SETTINGS: the settings/tabs reference (settings-*.md), for questions only. * * Each set is a stable prefix of the request, which is what the provider's prompt cache * keys on - keep anything request-specific (the open rule) appended AFTER it. */ private static function getInstructions($mode) { static $instructions = array(); if ( ! isset($instructions[$mode])) { $instructions[$mode] = implode("\n\n---\n\n", self::instructionSections($mode)); } return $instructions[$mode]; } /** * The rule-drafting instructions section by section, for a caller that hands them to a * model of its own (see Abilities::getGuide()). * * @return array file name without extension => its text */ public static function ruleInstructionSections() { return self::instructionSections(self::MODE_RULES); } private static function instructionSections($mode) { $files = glob(__DIR__ . '/instructions' . ($mode === self::MODE_SETTINGS ? '/settings-*.md' : '/[0-9][0-9]-*.md')); sort($files); $sections = array(); foreach ($files as $file) { //phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents $sections[basename($file, '.md')] = file_get_contents($file); } return $sections; } /** * The model is instructed (see instructions/*.md) to reply with nothing but a * JSON object, but models routinely wrap it in a ```json fence or add a stray * sentence anyway - strip that before decoding. * * @return array|null */ private static function parseJsonReply($text) { $text = trim($text); if (strpos($text, '```') !== false) { $text = preg_replace('/^```[a-zA-Z]*\s*|\s*```$/', '', $text); $text = trim($text); } $decoded = json_decode($text, true); if (is_array($decoded)) { return $decoded; } // last resort: grab the outermost {...} in case there's leading/trailing prose $start = strpos($text, '{'); $end = strrpos($text, '}'); if ($start !== false && $end !== false && $end > $start) { $decoded = json_decode(substr($text, $start, $end - $start + 1), true); if (is_array($decoded)) { return $decoded; } } return null; } /** * Debug log of every AI request/response, timestamped. WP_DEBUG-gated since * it can contain full chat text. File lives in uploads/, not the plugin * folder, so it survives plugin updates and isn't web-served as PHP. */ private static function log($label, $data) { if ( ! defined('WP_DEBUG') || ! WP_DEBUG) { return; } $uploadDir = wp_upload_dir(); if (empty($uploadDir['basedir'])) { return; } $line = sprintf( "[%s] %s: %s\n", current_time('mysql'), $label, wp_json_encode($data) ); //phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents file_put_contents( trailingslashit($uploadDir['basedir']) . self::LOG_FILE, $line, FILE_APPEND | LOCK_EX ); } }