register_strategist_routes(); * * Plus the small settings patch (business_context / target_keywords / serp_gl) * added to save_settings()'s update_option array — see the README note. * * Endpoints (all gated by current_user_can('edit_posts')): * POST /aicobr/v1/strategist/preview → cost preview (no charge) * POST /aicobr/v1/strategist/plan → research + plan (charges credits) * POST /aicobr/v1/strategist/populate → write approved items as scheduled drafts */ if (!defined('ABSPATH')) exit; trait AICOBR_Strategist_Trait { // How far back to dedupe against the live site (spec §12 — Ibbz: last 12 months). private static $AICOBR_STRATEGIST_LIVE_DEDUPE_MONTHS = 12; private static $AICOBR_STRATEGIST_LIVE_DEDUPE_CAP = 500; // bound the WP_Query on big sites public function register_strategist_routes() { $this->aicobr_register_route('/strategist/preview', [ 'methods' => 'POST', 'callback' => [$this, 'strategist_preview'], 'permission_callback' => fn() => current_user_can('edit_posts'), ]); $this->aicobr_register_route('/strategist/plan', [ 'methods' => 'POST', 'callback' => [$this, 'strategist_plan'], 'permission_callback' => fn() => current_user_can(apply_filters('aicobr_rest_capability', 'manage_options', 'strategist_plan')), ]); $this->aicobr_register_route('/strategist/populate', [ 'methods' => 'POST', 'callback' => [$this, 'strategist_populate'], 'permission_callback' => fn() => current_user_can(apply_filters('aicobr_rest_capability', 'manage_options', 'strategist_populate')), ]); } // ─── sanitisers ────────────────────────────────────────────────────────── /** Normalise a string for dedupe — mirrors normaliseKey() on the licence server. */ private function aicobr_normalise_key($s) { $s = strtolower((string) $s); $s = preg_replace('/[^a-z0-9]+/', ' ', $s); return trim($s); } /** Recursively sanitise a scalar/array blob before it goes into wp_options. */ private function aicobr_sanitise_meta($v) { if (is_array($v)) { $out = []; foreach ($v as $k => $val) { $out[sanitize_key((string) $k)] = $this->aicobr_sanitise_meta($val); } return $out; } if (is_bool($v) || is_int($v) || is_float($v) || $v === null) return $v; return sanitize_text_field((string) $v); } /** Pull the cadence object out of a request, sanitised into the two primitives. */ private function aicobr_sanitise_cadence($c) { $c = is_array($c) ? $c : []; $mode = ($c['mode'] ?? 'weekday') === 'interval' ? 'interval' : 'weekday'; return [ 'mode' => $mode, 'interval_days' => max(1, (int) ($c['interval_days'] ?? 7)), 'weekdays' => array_values(array_filter(array_map( fn($n) => max(0, min(6, (int) $n)), is_array($c['weekdays'] ?? null) ? $c['weekdays'] : [] ), fn($n) => $n !== null)), 'week_stride' => max(1, (int) ($c['week_stride'] ?? 1)), ]; } /** Plan parameters block, sanitised. */ private function aicobr_sanitise_params($p) { $p = is_array($p) ? $p : []; return [ 'timespan_months' => max(1, (int) ($p['timespan_months'] ?? 3)), 'cadence' => $this->aicobr_sanitise_cadence($p['cadence'] ?? []), 'content_types' => array_values(array_filter(array_map( 'sanitize_text_field', is_array($p['content_types'] ?? null) ? $p['content_types'] : [] ))), 'start_date' => sanitize_text_field($p['start_date'] ?? ''), ]; } // ─── dedupe inputs ───────────────────────────────────────────────────────── /** * Build the exclusion list the licence server uses so the planner never * re-suggests something already in the diary or already published live. * Titles + focus keywords; the server normalises both sides. */ /** * AICOBR_CLUSTER_INTENT_DEDUPE_2026_08 * * $pillar_id: when planning a supporting cluster for an existing page, also * return the spokes that page ALREADY has, as title+keyword pairs. The flat * titles/keywords lists dedupe on strings; this list lets the planning model * reject a proposal that serves the same SEARCH INTENT under different * wording ("boiler service pricing explained" vs "how much does a boiler * service cost"), which string matching cannot catch and which produces * cannibalising pages rather than useful coverage. */ private function aicobr_strategist_exclude_list($pillar_id = 0) { $titles = []; $kws = []; $siblings = []; $pillar_id = absint($pillar_id); // 1. Content Diary (planned + generated entries). $entries = get_option('aicobr_entries', []); if (is_array($entries)) { foreach ($entries as $e) { if (!empty($e['title'])) $titles[] = (string) $e['title']; if (!empty($e['primary_keyword'])) $kws[] = (string) $e['primary_keyword']; if (!empty($e['seo_focus_keyword'])) $kws[] = (string) $e['seo_focus_keyword']; // Same-pillar spokes, for intent-level rejection. if ($pillar_id > 0 && absint($e['cluster_pillar']['post_id'] ?? 0) === $pillar_id && !empty($e['title'])) { $siblings[] = [ 'title' => (string) $e['title'], 'keyword' => (string) ($e['primary_keyword'] ?? ''), ]; } } } // 2. Recently published content on the live site (bounded). $q = new WP_Query([ 'post_type' => ['post', 'page'], 'post_status' => 'publish', 'fields' => 'ids', 'posts_per_page' => self::$AICOBR_STRATEGIST_LIVE_DEDUPE_CAP, 'no_found_rows' => true, 'date_query' => [[ 'after' => self::$AICOBR_STRATEGIST_LIVE_DEDUPE_MONTHS . ' months ago', 'inclusive' => true, ]], ]); foreach ($q->posts as $pid) { $t = get_the_title($pid); if ($t) $titles[] = $t; } wp_reset_postdata(); return [ 'titles' => array_values(array_unique($titles)), 'focus_keywords' => array_values(array_unique($kws)), // Bounded: the prompt only needs enough to judge overlap, and a // cluster with 60+ spokes would otherwise bloat every plan request. 'cluster_siblings' => array_slice($siblings, 0, 60), ]; } /** Latest scheduled/planned date in the diary, or '' — used to top up a re-run AFTER what exists. */ private function aicobr_strategist_latest_planned_date() { $entries = get_option('aicobr_entries', []); $latest = ''; if (is_array($entries)) { foreach ($entries as $e) { $d = (string) ($e['publish_date'] ?? ''); if ($d && $d > $latest) $latest = $d; } } return $latest; } // ─── proxy: cost preview (no charge, no AI) ──────────────────────────────── public function strategist_preview($request) { $data = $request->get_json_params() ?: []; $settings = get_option('aicobr_settings', []); $payload = [ 'license_key' => $settings['license_key'] ?? '', 'params' => $this->aicobr_sanitise_params($data['params'] ?? []), ]; return $this->aicobr_strategist_proxy('/api/strategist/preview', $payload, 20); } // ─── proxy: the real plan run (charges credits server-side) ──────────────── public function strategist_plan($request) { $data = $request->get_json_params() ?: []; $settings = get_option('aicobr_settings', []); $license_key = $settings['license_key'] ?? ''; if (empty($license_key)) { return new WP_Error('no_license', 'No license key configured. Please visit Settings.', ['status' => 403]); } // Business context: prefer the persisted settings (filled once); allow the // request to override on the very first run before it's been saved. $bc_saved = is_array($settings['business_context'] ?? null) ? $settings['business_context'] : []; $bc_req = is_array($data['business_context'] ?? null) ? $data['business_context'] : []; $business = [ 'what' => sanitize_text_field($bc_req['what'] ?? ($bc_saved['what'] ?? '')), 'industry' => sanitize_text_field($bc_req['industry'] ?? ($bc_saved['industry'] ?? '')), 'location' => sanitize_text_field($bc_req['location'] ?? ($bc_saved['location'] ?? '')), ]; if (empty($business['location'])) { return new WP_Error('no_location', 'Add your business location before running a plan.', ['status' => 400]); } // Seeds: persisted target keywords + any "for this run" additions. $persisted = is_array($settings['target_keywords'] ?? null) ? $settings['target_keywords'] : []; $run_add = is_array($data['seed_keywords'] ?? null) ? $data['seed_keywords'] : []; $seeds = array_values(array_unique(array_filter(array_map( 'sanitize_text_field', array_merge($persisted, $run_add) )))); // AICOBR_CLUSTER_SEEDLESS_2026_08 // Keywords are optional when a pillar page was chosen: selecting the page // the cluster supports tells the planner more than typed keywords would, // since its title, URL and copy all reach the prompt. The pillar is parsed // further down, so check the raw request here. $has_pillar = is_array($data['pillar'] ?? null) && absint($data['pillar']['post_id'] ?? 0) > 0 && !empty($data['pillar']['url']); if (empty($seeds) && !$has_pillar) { return new WP_Error( 'no_seeds', 'Add at least one target keyword, or choose a page to build a cluster around.', ['status' => 400] ); } $params = $this->aicobr_sanitise_params($data['params'] ?? []); // Re-run convenience: if no explicit start was chosen and the diary already // holds planned dates, start the new plan the day AFTER the latest one so a // top-up lands around (not on top of) what's already scheduled. if (empty($params['start_date'])) { $latest = $this->aicobr_strategist_latest_planned_date(); if ($latest) { $next = gmdate('Y-m-d', strtotime($latest . ' +1 day')); if ($next) $params['start_date'] = $next; } } // AICOBR_CLUSTER_INTENT_DEDUPE_2026_08 — forward the chosen pillar so the // server can plan around it, and so the exclude list can include the // spokes that pillar already has (for intent-level, not string-level, // rejection of overlapping proposals). $req_pillar = is_array($data['pillar'] ?? null) ? $data['pillar'] : []; $req_pillar_id = absint($req_pillar['post_id'] ?? 0); $payload = [ 'license_key' => $license_key, 'business_context' => $business, 'seed_keywords' => $seeds, 'params' => $params, 'exclude' => $this->aicobr_strategist_exclude_list($req_pillar_id), ]; if ($req_pillar_id > 0 && !empty($req_pillar['url'])) { $payload['pillar'] = [ 'post_id' => $req_pillar_id, 'title' => sanitize_text_field($req_pillar['title'] ?? get_the_title($req_pillar_id)), 'url' => esc_url_raw($req_pillar['url']), 'excerpt' => sanitize_textarea_field((string) ($req_pillar['excerpt'] ?? '')), ]; } if (!empty($settings['serp_gl'])) { $payload['serp_gl'] = sanitize_text_field($settings['serp_gl']); } // Planning runs a Serper fan-out + a Claude pass — give it room. return $this->aicobr_strategist_proxy('/api/strategist/plan', $payload, 120); } /** Shared proxy: POST payload to the licence server with the shared secret. */ private function aicobr_strategist_proxy($path, $payload, $timeout = 60) { $url = self::get_server_url() . $path; $response = wp_remote_post($url, [ 'timeout' => $timeout, 'headers' => [ 'Content-Type' => 'application/json', 'x-generate-secret' => self::get_secret(), ], 'body' => wp_json_encode($payload), ]); if (is_wp_error($response)) { aicobr_dbg('[ACB] strategist proxy failed: ' . $response->get_error_message()); return new WP_Error('server_unreachable', 'Could not reach the Strategist server. Please try again.', ['status' => 502]); } $code = wp_remote_retrieve_response_code($response); $body = json_decode(wp_remote_retrieve_body($response), true); if ($code >= 400) { $payload_out = ['status' => $code]; // Pass through the server's credits_remaining so the UI can show it on a 402. if (isset($body['credits_remaining'])) $payload_out['credits_remaining'] = $body['credits_remaining']; return new WP_Error('strategist_failed', $body['error'] ?? 'Strategist request failed.', $payload_out); } return rest_ensure_response($body); } // ─── write approved items into the Content Diary as scheduled drafts ────── /** * Takes the approved (and possibly user-edited) plan items and appends them * to aicobr_entries as `scheduled` entries. Defensive dedupe at write * time (the plan was already deduped at planning, but the diary can change in * between): skip an item whose date is already taken, or whose normalised * title / focus keyword already exists. Reuses the entries lock. */ public function strategist_populate($request) { $data = $request->get_json_params() ?: []; $items = is_array($data['items'] ?? null) ? $data['items'] : []; if (empty($items)) { return new WP_Error('no_items', 'No plan items to add.', ['status' => 400]); } $settings = get_option('aicobr_settings', []); $default_wc = (int) ($settings['default_word_count'] ?? 1500) ?: 1500; // AICOBR_CLUSTER_PILLAR_2026_08 // When the plan was built against an existing page, every brief in this // batch becomes a SPOKE of that page. The pillar's URL is snapshotted // here (not resolved later) because it is handed to the generation // prompt so the model can write a real contextual link into the body — // the pillar page itself is NEVER edited by ACB. // // One cluster_id per populate request. It is minted from the same // millisecond clock as $base_id below, so it is stable, sortable and // collision-free without needing a counter or a schema. $pillar = is_array($data['pillar'] ?? null) ? $data['pillar'] : []; $pillar_id = absint($pillar['post_id'] ?? 0); $pillar_ok = $pillar_id > 0 && get_post($pillar_id) && !empty($pillar['url']); $cluster_id = null; $cluster_pillar = null; if ($pillar_ok) { $cluster_id = 'clu_' . (int) round(microtime(true) * 1000); // AICOBR_CLUSTER_TITLE_CAP_2026_08 // Every brief in the cluster stores its own copy of this title, and // the diary is ONE serialised wp_options row — so an unbounded title // is multiplied across the batch. 200 chars is far above any real // page title (WordPress itself has no limit, and page builders can // produce very long ones) while bounding the blob. Display is // truncated separately with CSS so the stored value stays honest. $pillar_title = sanitize_text_field($pillar['title'] ?? get_the_title($pillar_id)); if (function_exists('mb_substr')) { $pillar_title = mb_substr($pillar_title, 0, 200); } else { $pillar_title = substr($pillar_title, 0, 200); } $cluster_pillar = [ 'post_id' => $pillar_id, 'title' => $pillar_title, 'url' => esc_url_raw($pillar['url']), ]; } $locked = $this->aicobr_lock_entries(); $entries = get_option('aicobr_entries', []); if (!is_array($entries)) $entries = []; // Existing-state sets for dedupe. // // AICOBR_STRATEGIST_BATCH_DATES_2026_08 // $takenDates is deliberately scoped to THIS batch only, and is NOT // seeded from the existing diary. // // It used to be seeded from every entry ever created, which made a // publish date a site-wide, permanent reservation: once any brief sat on // 17 Aug, no later plan could ever use that date again, whatever its // pillar or topic. Planning a second cluster over the same weeks — the // normal case, since a site runs several clusters at once — therefore // skipped every item with 'date_taken' and reported "0 briefs inserted", // even though not one of them was a duplicate. // // Duplication is what we actually guard against, and title + keyword // dedupe (still global, below) is what guards it. Two DIFFERENT posts // sharing a publish date is an editorial choice, not an error — the diary // and the calendar both render several entries on one day already. // // Within a single batch the date set is still enforced, so one plan run // cannot stack two of its own briefs onto the same slot. $takenDates = []; $seenTitle = []; $seenKw = []; foreach ($entries as $e) { if (!empty($e['title'])) $seenTitle[$this->aicobr_normalise_key($e['title'])] = true; if (!empty($e['primary_keyword'])) $seenKw[$this->aicobr_normalise_key($e['primary_keyword'])] = true; if (!empty($e['seo_focus_keyword'])) $seenKw[$this->aicobr_normalise_key($e['seo_focus_keyword'])] = true; } $base_id = (int) round(microtime(true) * 1000); $added = 0; $skipped = []; $i = 0; foreach ($items as $item) { $i++; $title = sanitize_text_field($item['title'] ?? ''); $focus = sanitize_text_field($item['focus_keyphrase'] ?? ($item['primary_keyword'] ?? '')); $date = sanitize_text_field($item['suggested_date'] ?? ($item['publish_date'] ?? '')); $type = sanitize_text_field($item['content_type'] ?? 'blog_post'); // Date must look like YYYY-MM-DD. if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { $skipped[] = ['title' => $title, 'reason' => 'bad_date']; continue; } if (empty($title)) { $skipped[] = ['title' => '(untitled)', 'reason' => 'no_title']; continue; } $nt = $this->aicobr_normalise_key($title); $nk = $this->aicobr_normalise_key($focus); if (isset($takenDates[$date])) { $skipped[] = ['title' => $title, 'reason' => 'date_taken_in_batch']; continue; } if ($nt && isset($seenTitle[$nt])) { $skipped[] = ['title' => $title, 'reason' => 'dup_title']; continue; } if ($nk && isset($seenKw[$nk])) { $skipped[] = ['title' => $title, 'reason' => 'dup_keyword']; continue; } $entries[] = [ 'id' => $base_id + $i, 'title' => $title, 'primary_keyword' => $focus, 'seo_focus_keyword' => $focus, // AICOBR_ANCHOR_VARIANTS_2026_08 — extra spoke phrasings for // inbound linking. Carried through when the planner supplies // them; harmless empty array otherwise. >= 2 words enforced here // and again in the matcher. 'anchor_variants' => array_values(array_filter(array_map( fn($v) => sanitize_text_field(trim(preg_replace('/\s+/u', ' ', (string) $v))), (array) ($item['anchor_variants'] ?? []) ), fn($v) => $v !== '' && count(preg_split('/\s+/u', $v, -1, PREG_SPLIT_NO_EMPTY)) >= 2)), 'content_type' => $type, // ACB_PAGE_TYPES_DEFAULT_2026_07_21: page-natured types publish to a // WordPress page; everything else in the Strategist palette is a post. 'post_type' => in_array($type, ['landing_page', 'service_page', 'about_us', 'faq_page'], true) ? 'page' : 'post', 'publish_date' => $date, 'status' => 'scheduled', 'target_word_count' => $default_wc, 'special_instructions'=> sanitize_textarea_field($item['special_instructions'] ?? ''), 'content_type_meta' => $this->aicobr_sanitise_meta($item['content_type_meta'] ?? []), // Cluster membership lives in TOP-LEVEL entry fields, deliberately // NOT inside content_type_meta: that key is client-owned in the // /entries merge, so the browser's ~2.5s write-back would clobber // anything the server writes into it (same race class as the // image_history / thumbnail fix). These fields are added to // $serverOwned in the entries merge for the same reason. 'cluster_id' => $cluster_id, 'cluster_role' => $cluster_id ? 'spoke' : null, 'cluster_pillar' => $cluster_pillar, 'include_image' => true, // Integration fields default to match a manually-added entry. 'mailpoet_list_id' => 0, 'tnp_list_id' => 0, 'tec_start_date' => '', 'tec_end_date' => '', 'tec_cost' => '', 'tec_url' => '', 'tec_venue' => '', 'tec_organiser' => '', 'lms_type' => '', 'lms_course_id' => 0, 'lms_lesson_id' => 0, 'lms_section_id' => 0, 'ld_post_type' => '', 'source' => 'strategist', 'created_at' => current_time('mysql'), ]; // Mark consumed so the same batch can't add two items to one date/topic. $takenDates[$date] = true; if ($nt) $seenTitle[$nt] = true; if ($nk) $seenKw[$nk] = true; $added++; } update_option('aicobr_entries', $entries); if ($locked) $this->aicobr_unlock_entries(); return rest_ensure_response([ 'success' => true, 'added' => $added, 'skipped' => $skipped, ]); } }