Plugin Directory

Changeset 3719914


Ignore:
Timestamp:
09/29/2026 10:04:34 PM (4 weeks ago)
Author:
iamfortunatus
Message:

V3.6.4 is ready.
Fixed strategy planner

Location:
synapost/trunk
Files:
6 edited

Legend:

Unmodified
Added
Removed
  • synapost/trunk/includes/class-synapost-approval-actions.php

    r3670985 r3719914  
    4040         * linked post is deleted outside SynaPost entirely - most commonly,
    4141         * 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).
    4849         *
    4950         * Hooked to `before_delete_post` specifically, not `deleted_post` -
    5051         * by the time `deleted_post` fires, WordPress has already cascaded
    5152         * 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.
    5956         */
    6057        public function handle_linked_post_deleted( $post_id, $post ) {
    … …  
    6461                }
    6562
    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 ) {
    7065                        return;
    7166                }
    7267
    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 );
    7572        }
    7673
  • synapost/trunk/includes/class-synapost-llm-router.php

    r3670985 r3719914  
    436436         * Providers with confirmed, currently-documented native web search
    437437         * 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.
    440443         *  - Anthropic: the web_search_20250305 tool, works with standard
    441444         *    Claude models, no model switch needed.
    … …  
    467470
    468471                // Web search in Chat Completions only works on OpenAI's dedicated
    469                 // search-preview models - passing web_search_options to any other
     472                // gpt-5-search-api model - passing web_search_options to any other
    470473                // model either errors or is silently ignored, so the model is
    471474                // swapped for this call specifically rather than trusting whatever
    472475                // 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';
    475481                }
    476482
  • synapost/trunk/includes/class-synapost-strategy-planner.php

    r3670985 r3719914  
    3030        public function ranges() {
    3131                return array(
     32                        '7'   => __( '7-day plan', 'synapost' ),
    3233                        '30'  => __( '30-day plan', 'synapost' ),
    3334                        '90'  => __( '90-day plan', 'synapost' ),
    … …  
    3738
    3839        /**
     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        /**
    3958         * @param int    $profile_id
    40          * @param string $range     '30' | '90' | '365'
     59         * @param string $range     '7' | '30' | '90' | '365'
    4160         * @param int    $rate      Desired publishing cadence, used to size the topic count.
    4261         * @param string $rate_unit 'day' or 'week' - which unit $rate is expressed in.
    … …  
    5372                $context = SYNAPOST_Business_Profile::instance()->to_prompt_context( $profile_id );
    5473
     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 ) {
    55156                // LLMs have no built-in sense of the current date - left
    56157                // ungrounded, a model tends to reason as if "today" is somewhere
    … …  
    58159                // in the past. Asking for dates "starting tomorrow" without ever
    59160                // 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' );
    69165
    70166                $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."
    … …  
    87183                // better to have the model choose real categories from the
    88184                // start than to catch the mismatch after the fact.
    89                 $category_names = $this->existing_category_names();
    90185                $category_instruction = $this->build_category_instruction( $category_names );
    91186
    … …  
    94189                // to simply not propose a repeat in the first place than to have
    95190                // 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.
    97195                $duplicate_instruction = $this->build_duplicate_instruction();
    98196
    … …  
    110208                        . "{$research_instruction}\n"
    111209                        . "\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"
    113211                        . "\n"
    114212                        . "Return STRICT JSON only, no markdown fences, no commentary, matching this shape exactly:\n"
    … …  
    136234                                'prompt'      => $prompt,
    137235                                'temperature' => 0.6,
    138                                 'max_tokens'  => max( 4000, min( 16000, $topic_count * 180 ) ),
     236                                'max_tokens'  => max( 4000, min( 16000, $count * 180 ) ),
    139237                                'json'        => true,
    140238                                'web_search'  => $research_enabled,
    … …  
    143241
    144242                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() );
    146244                }
    147245
    … …  
    149247
    150248                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() );
    152250                }
    153251
    … …  
    158256                $parsed['topics'] = $this->sanitize_topic_content_style( $parsed['topics'] );
    159257
    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'] );
    166262
    167263                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(),
    175269                );
    176270        }
  • synapost/trunk/includes/class-synapost-topics.php

    r3670985 r3719914  
    292292        }
    293293
    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
    296320                return $wpdb->delete( $this->table(), array( 'id' => (int) $id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
    297321        }
  • synapost/trunk/readme.txt

    r3706961 r3719914  
    55Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 3.6.3
     7Stable tag: 3.6.4
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    146146== Changelog ==
    147147
     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
    148154= 3.6.3 =
    149155* 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  
    44 * Plugin URI:        https://synapost.ufirstdev.com
    55 * Description:       AI-Powered Content Operating System for WordPress. Plan, research, write, optimize, approve and publish content without leaving your dashboard.
    6  * Version:           3.6.3
     6 * Version:           3.6.4
    77 * Requires at least: 6.0
    88 * Requires PHP:      7.4
    … …  
    2525 * Core constants.
    2626 */
    27 define( 'SYNAPOST_VERSION', '3.6.3' );
     27define( 'SYNAPOST_VERSION', '3.6.4' );
    2828define( 'SYNAPOST_PLUGIN_FILE', __FILE__ );
    2929define( 'SYNAPOST_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
Note: See TracChangeset for help on using the changeset viewer.