Changeset 3719914
- Timestamp:
- 09/29/2026 10:04:34 PM (4 weeks ago)
- Location:
- synapost/trunk
- Files:
-
- 6 edited
-
includes/class-synapost-approval-actions.php (modified) (2 diffs)
-
includes/class-synapost-llm-router.php (modified) (2 diffs)
-
includes/class-synapost-strategy-planner.php (modified) (11 diffs)
-
includes/class-synapost-topics.php (modified) (1 diff)
-
readme.txt (modified) (2 diffs)
-
synapost.php (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
synapost/trunk/includes/class-synapost-approval-actions.php
r3670985 r3719914 40 40 * linked post is deleted outside SynaPost entirely - most commonly, 41 41 * someone deleting it directly from Posts/Pages in wp-admin rather 42 * than using Cancel/Reject here. Without this, the topic's post_id 43 * keeps pointing at a post that no longer exists, and its status 44 * (queued/generating/pending_approval/approved/rejected/failed - all 45 * still "in progress" as far as the topic itself knows) never 46 * changes, so it sits in the Approval Center and Content Pipeline 47 * forever with nothing real behind it. 42 * than using Cancel/Reject here. Deletes the topic outright (not just 43 * marks it "failed") regardless of its status, including an already- 44 * published one: permanently deleting a post is a deliberate, 45 * unambiguous "this content is gone" decision, and the matching 46 * topic record shouldn't linger pointing at nothing - mirrors 47 * SYNAPOST_Topics::delete()'s own sync the other way (deleting a topic 48 * removes its linked post). 48 49 * 49 50 * Hooked to `before_delete_post` specifically, not `deleted_post` - 50 51 * by the time `deleted_post` fires, WordPress has already cascaded 51 52 * the post's own meta rows away, so `_synapost_topic_id` would no longer 52 * be there to read. 53 * 54 * Deliberately does nothing to an already-published topic - deleting 55 * a live post is the site owner's own deliberate call on real, 56 * published content, not a stale-reference cleanup problem, and 57 * silently flipping it to "failed" would just resurface it in the 58 * Pipeline as something needing action that it doesn't. 53 * be there to read. This also means a post merely moved to Trash 54 * (`wp_trash_post`, not `before_delete_post`) is left alone - only an 55 * actual, permanent deletion cascades to the topic. 59 56 */ 60 57 public function handle_linked_post_deleted( $post_id, $post ) { … … 64 61 } 65 62 66 $topics = SYNAPOST_Topics::instance(); 67 $topic = $topics->get( $topic_id ); 68 69 if ( ! $topic || (int) $topic['post_id'] !== (int) $post_id || 'published' === $topic['status'] ) { 63 $topic = SYNAPOST_Topics::instance()->get( $topic_id ); 64 if ( ! $topic || (int) $topic['post_id'] !== (int) $post_id ) { 70 65 return; 71 66 } 72 67 73 $topics->set_status( $topic_id, 'failed' ); 74 $topics->link_post( $topic_id, 0 ); 68 // $cascade_post = false: this post is already mid-deletion (we're 69 // inside its own before_delete_post), so Topics::delete() must not 70 // try to wp_delete_post() it a second time. 71 SYNAPOST_Topics::instance()->delete( $topic_id, false ); 75 72 } 76 73 -
synapost/trunk/includes/class-synapost-llm-router.php
r3670985 r3719914 436 436 * Providers with confirmed, currently-documented native web search 437 437 * tool support as of this writing: 438 * - OpenAI: web_search_options on the dedicated gpt-4o-search-preview 439 * / gpt-4o-mini-search-preview models (Chat Completions API). 438 * - OpenAI: web_search_options on the dedicated gpt-5-search-api 439 * model (Chat Completions API). Replaces gpt-4o-search-preview / 440 * gpt-4o-mini-search-preview, which OpenAI deprecated and shut 441 * down on 2026-07-23 - see OpenAI's own migration guidance at 442 * developers.openai.com/api/docs/guides/tools-web-search. 440 443 * - Anthropic: the web_search_20250305 tool, works with standard 441 444 * Claude models, no model switch needed. … … 467 470 468 471 // Web search in Chat Completions only works on OpenAI's dedicated 469 // search-preview models- passing web_search_options to any other472 // gpt-5-search-api model - passing web_search_options to any other 470 473 // model either errors or is silently ignored, so the model is 471 474 // swapped for this call specifically rather than trusting whatever 472 475 // the site owner has set as their general-purpose default. 473 if ( $web_search && false === strpos( $model, 'search-preview' ) ) { 474 $model = 'gpt-4o-mini-search-preview'; 476 // (gpt-4o-mini-search-preview/gpt-4o-search-preview, the previous 477 // dedicated search models this used, were deprecated and shut 478 // down by OpenAI on 2026-07-23.) 479 if ( $web_search && false === strpos( $model, 'search-api' ) ) { 480 $model = 'gpt-5-search-api'; 475 481 } 476 482 -
synapost/trunk/includes/class-synapost-strategy-planner.php
r3670985 r3719914 30 30 public function ranges() { 31 31 return array( 32 '7' => __( '7-day plan', 'synapost' ), 32 33 '30' => __( '30-day plan', 'synapost' ), 33 34 '90' => __( '90-day plan', 'synapost' ), … … 37 38 38 39 /** 40 * A single generation call rarely delivers exactly the topic count it 41 * was asked for - a large ask (e.g. 90 topics for a 30-day/3-per-day 42 * plan) is exactly the kind of instruction models tend to under-fill 43 * even when told "exactly N", and the Topic Quality Engine pass 44 * rejects a real fraction on top of that. Rather than silently hand 45 * back whatever survived the first attempt, generate_plan() below 46 * loops - each round asks only for the remaining shortfall and is 47 * itself insert-and-QC'd, so the plan converges on the requested 48 * count (or gets as close as the niche can realistically support) 49 * instead of stopping after one attempt. This constant is the hard 50 * ceiling on how many rounds that loop will make, so a niche that 51 * genuinely can't sustain the requested volume fails soft (returns 52 * whatever it found) rather than looping indefinitely and burning 53 * API calls chasing a count that isn't achievable. 54 */ 55 const MAX_TOPUP_ATTEMPTS = 5; 56 57 /** 39 58 * @param int $profile_id 40 * @param string $range ' 30' | '90' | '365'59 * @param string $range '7' | '30' | '90' | '365' 41 60 * @param int $rate Desired publishing cadence, used to size the topic count. 42 61 * @param string $rate_unit 'day' or 'week' - which unit $rate is expressed in. … … 53 72 $context = SYNAPOST_Business_Profile::instance()->to_prompt_context( $profile_id ); 54 73 74 $today_dt = current_datetime(); 75 $start_dt = $today_dt->add( new DateInterval( 'P1D' ) ); 76 $end_dt = $today_dt->add( new DateInterval( 'P' . $days . 'D' ) ); 77 $start_str = $start_dt->format( 'Y-m-d' ); 78 $end_str = $end_dt->format( 'Y-m-d' ); 79 80 $category_names = $this->existing_category_names(); 81 82 $pillars = array(); 83 $all_topics = array(); 84 $total_inserted = 0; 85 $total_qc_rejected = 0; 86 $total_skipped = 0; 87 $last_error = ''; 88 89 for ( $attempt = 1; $attempt <= self::MAX_TOPUP_ATTEMPTS; $attempt++ ) { 90 $remaining = $topic_count - $total_inserted; 91 if ( $remaining <= 0 ) { 92 break; 93 } 94 95 $batch = $this->generate_topics_batch( $profile_id, $remaining, $days, $context, $category_names, $start_str, $end_str ); 96 97 if ( empty( $batch['success'] ) ) { 98 $last_error = $batch['error']; 99 // A first-attempt failure is a real, reportable error (no 100 // plan at all was produced). A later attempt failing just 101 // means the top-up loop stops early with whatever earlier 102 // rounds already secured, rather than losing that progress 103 // to one flaky top-up call. 104 if ( 1 === $attempt ) { 105 return array( 'success' => false, 'inserted' => 0, 'error' => $last_error, 'topics' => array() ); 106 } 107 break; 108 } 109 110 if ( ! empty( $batch['pillars'] ) ) { 111 $pillars = array_values( array_unique( array_merge( $pillars, $batch['pillars'] ) ) ); 112 } 113 $total_qc_rejected += $batch['qc_rejected']; 114 115 $insert_result = SYNAPOST_Topics::instance()->bulk_insert( $batch['topics'], $profile_id, 'strategy_planner' ); 116 $total_inserted += $insert_result['inserted']; 117 $total_skipped += $insert_result['skipped_duplicates']; 118 $all_topics = array_merge( $all_topics, $batch['topics'] ); 119 120 // This round produced nothing new at all (every proposed topic 121 // was QC-rejected or a duplicate) - another identical request 122 // is unlikely to fare better, so stop rather than keep paying 123 // for rounds that can't make progress. 124 if ( 0 === $insert_result['inserted'] ) { 125 break; 126 } 127 } 128 129 return array( 130 'success' => true, 131 'inserted' => $total_inserted, 132 'skipped_duplicates' => $total_skipped, 133 'qc_rejected' => $total_qc_rejected, 134 'error' => '', 135 'topics' => $all_topics, 136 'pillars' => $pillars, 137 ); 138 } 139 140 /** 141 * One generation round: asks the model for exactly $count topics, 142 * sanitizes them, and runs them through the Topic Quality Engine - 143 * everything generate_plan() used to do in a single pass, now 144 * reusable so its top-up loop can call it again for a shortfall 145 * without duplicating the prompt-building/sanitization logic. 146 * 147 * Deliberately does NOT insert the topics itself - the caller owns 148 * bulk_insert() so it can track the running total across rounds and 149 * decide whether another round is needed from the real, post-insert 150 * count (which also accounts for DB-level duplicate rejection, not 151 * just QC rejection). 152 * 153 * @return array{success:bool, error:string, topics:array, qc_rejected:int, pillars:array} 154 */ 155 private function generate_topics_batch( $profile_id, $count, $days, $context, array $category_names, $start_str, $end_str ) { 55 156 // LLMs have no built-in sense of the current date - left 56 157 // ungrounded, a model tends to reason as if "today" is somewhere … … 58 159 // in the past. Asking for dates "starting tomorrow" without ever 59 160 // stating what today actually is was producing publish dates 60 // years in the past. Computing explicit, concrete bounds here 61 // (via the site's own clock/timezone, not the model's guess) 62 // removes that ambiguity entirely. 63 $today_dt = current_datetime(); 64 $start_dt = $today_dt->add( new DateInterval( 'P1D' ) ); 65 $end_dt = $today_dt->add( new DateInterval( 'P' . $days . 'D' ) ); 66 $today_str = $today_dt->format( 'Y-m-d' ); 67 $start_str = $start_dt->format( 'Y-m-d' ); 68 $end_str = $end_dt->format( 'Y-m-d' ); 161 // years in the past. $start_str/$end_str are computed once by 162 // generate_plan() and passed in (same window for every round of 163 // its top-up loop); only "today" itself needs recomputing here. 164 $today_str = current_datetime()->format( 'Y-m-d' ); 69 165 70 166 $system = "You are a senior content strategist. You design data-informed content calendars built around topic clusters and pillar pages, always tied to real business goals (traffic, leads, authority, sales) rather than random posting." … … 87 183 // better to have the model choose real categories from the 88 184 // start than to catch the mismatch after the fact. 89 $category_names = $this->existing_category_names();90 185 $category_instruction = $this->build_category_instruction( $category_names ); 91 186 … … 94 189 // to simply not propose a repeat in the first place than to have 95 190 // it silently dropped after generation - dropped duplicates count 96 // against the requested topic_count with nothing to show for it. 191 // against the requested count with nothing to show for it. Built 192 // fresh on every call (rather than once by the caller) so a 193 // top-up round automatically sees titles inserted by earlier 194 // rounds of the same plan too, not just pre-existing topics. 97 195 $duplicate_instruction = $this->build_duplicate_instruction(); 98 196 … … 110 208 . "{$research_instruction}\n" 111 209 . "\n" 112 . "Build a {$days}-day content strategy containing exactly {$ topic_count} topics, organized around 3-6 topic clusters / content pillars that support the business's goals and target audience.\n"210 . "Build a {$days}-day content strategy containing exactly {$count} topics, organized around 3-6 topic clusters / content pillars that support the business's goals and target audience.\n" 113 211 . "\n" 114 212 . "Return STRICT JSON only, no markdown fences, no commentary, matching this shape exactly:\n" … … 136 234 'prompt' => $prompt, 137 235 'temperature' => 0.6, 138 'max_tokens' => max( 4000, min( 16000, $ topic_count * 180 ) ),236 'max_tokens' => max( 4000, min( 16000, $count * 180 ) ), 139 237 'json' => true, 140 238 'web_search' => $research_enabled, … … 143 241 144 242 if ( empty( $result['success'] ) ) { 145 return array( 'success' => false, ' inserted' => 0, 'error' => $result['error'], 'topics' => array() );243 return array( 'success' => false, 'error' => $result['error'], 'topics' => array(), 'qc_rejected' => 0, 'pillars' => array() ); 146 244 } 147 245 … … 149 247 150 248 if ( empty( $parsed['topics'] ) || ! is_array( $parsed['topics'] ) ) { 151 return array( 'success' => false, ' inserted' => 0, 'error' => __( 'The AI response could not be parsed into a topic plan. Try again, or try a different provider/model.', 'synapost' ), 'topics' => array() );249 return array( 'success' => false, 'error' => __( 'The AI response could not be parsed into a topic plan. Try again, or try a different provider/model.', 'synapost' ), 'topics' => array(), 'qc_rejected' => 0, 'pillars' => array() ); 152 250 } 153 251 … … 158 256 $parsed['topics'] = $this->sanitize_topic_content_style( $parsed['topics'] ); 159 257 160 $before_qc = count( $parsed['topics'] ); 161 $parsed['topics'] = $this->topic_qc_review( $parsed['topics'], $context ); 162 $qc_rejected = $before_qc - count( $parsed['topics'] ); 163 $parsed['topics'] = $this->strip_topic_em_dashes( $parsed['topics'] ); 164 165 $insert_result = SYNAPOST_Topics::instance()->bulk_insert( $parsed['topics'], $profile_id, 'strategy_planner' ); 258 $before_qc = count( $parsed['topics'] ); 259 $parsed['topics'] = $this->topic_qc_review( $parsed['topics'], $context ); 260 $qc_rejected = $before_qc - count( $parsed['topics'] ); 261 $parsed['topics'] = $this->strip_topic_em_dashes( $parsed['topics'] ); 166 262 167 263 return array( 168 'success' => true, 169 'inserted' => $insert_result['inserted'], 170 'skipped_duplicates' => $insert_result['skipped_duplicates'], 171 'qc_rejected' => $qc_rejected, 172 'error' => '', 173 'topics' => $parsed['topics'], 174 'pillars' => $parsed['pillars'] ?? array(), 264 'success' => true, 265 'error' => '', 266 'topics' => $parsed['topics'], 267 'qc_rejected' => $qc_rejected, 268 'pillars' => $parsed['pillars'] ?? array(), 175 269 ); 176 270 } -
synapost/trunk/includes/class-synapost-topics.php
r3670985 r3719914 292 292 } 293 293 294 public function delete( $id ) { 295 global $wpdb; 294 /** 295 * Deleting a topic also removes whatever WordPress post it produced 296 * (if one exists), trashed rather than force-deleted so it's still 297 * recoverable from Trash like any other post deletion in wp-admin - 298 * keeping the two systems in sync in this direction mirrors 299 * SYNAPOST_Approval_Actions::handle_linked_post_deleted()'s sync the 300 * other way (deleting the post removes the topic). 301 * 302 * @param int $id 303 * @param bool $cascade_post Set false only when the caller already 304 * knows the linked post is being deleted by 305 * WordPress itself right now (i.e. called 306 * from a before_delete_post hook) - avoids 307 * the two sync directions calling back into 308 * each other over the same post. 309 */ 310 public function delete( $id, $cascade_post = true ) { 311 global $wpdb; 312 313 if ( $cascade_post ) { 314 $topic = $this->get( $id ); 315 if ( $topic && ! empty( $topic['post_id'] ) ) { 316 wp_delete_post( (int) $topic['post_id'] ); 317 } 318 } 319 296 320 return $wpdb->delete( $this->table(), array( 'id' => (int) $id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching 297 321 } -
synapost/trunk/readme.txt
r3706961 r3719914 5 5 Tested up to: 7.1 6 6 Requires PHP: 7.4 7 Stable tag: 3.6. 37 Stable tag: 3.6.4 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 146 146 == Changelog == 147 147 148 = 3.6.4 = 149 * Fixed: Deep Research's OpenAI web search call used `gpt-4o-mini-search-preview`, a model OpenAI deprecated and shut down on 2026-07-23. Switched to `gpt-5-search-api`, OpenAI's own documented Chat Completions replacement. 150 * Added: a 7-day option to the Strategy Planner's Plan Length. 151 * Fixed: the Strategy Planner could fall well short of the requested topic count (e.g. asking for a 30-day plan at 3 posts/day - 90 topics - could produce as few as 14) whenever the model under-generated on a large "exactly N topics" request or the Topic Quality Engine rejected some. It now automatically requests more topics to make up the shortfall (up to 5 rounds) until it reaches the requested count or genuinely can't find more good angles for the niche. 152 * Changed: deleting a topic from the Content Library, Pipeline, or Published Content now also deletes its linked WordPress post (moved to Trash, not permanently deleted, so it's still recoverable) - previously only the plugin's own tracking row was removed, leaving the real post behind with nothing managing it. Likewise, permanently deleting a post directly from Posts/Pages in wp-admin now removes its linked SynaPost topic too (previously the topic was just marked "Failed" and left in the database). Merely moving a post to Trash still leaves its topic alone either way - only a real, permanent deletion cascades. 153 148 154 = 3.6.3 = 149 155 * Fixed: the email approval confirmation page's form was completely non-functional - its `<form>`, `<select>`, and hidden `<input>` elements (including the CTA Preset picker, a Pro feature) were being silently stripped by an overly-strict sanitization pass meant for post content, not a standalone HTML page. This affected every action (Publish, Regenerate, Cancel), not only the CTA picker. -
synapost/trunk/synapost.php
r3706961 r3719914 4 4 * Plugin URI: https://synapost.ufirstdev.com 5 5 * Description: AI-Powered Content Operating System for WordPress. Plan, research, write, optimize, approve and publish content without leaving your dashboard. 6 * Version: 3.6. 36 * Version: 3.6.4 7 7 * Requires at least: 6.0 8 8 * Requires PHP: 7.4 … … 25 25 * Core constants. 26 26 */ 27 define( 'SYNAPOST_VERSION', '3.6. 3' );27 define( 'SYNAPOST_VERSION', '3.6.4' ); 28 28 define( 'SYNAPOST_PLUGIN_FILE', __FILE__ ); 29 29 define( 'SYNAPOST_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)