post_type, Semantic_Settings::post_types(), true ) ) return; if ( wp_is_post_revision( $post ) || wp_is_post_autosave( $post ) ) return; if ( Semantic_Settings::is_excluded( $post->ID ) ) return; // excluded from the data layer if ( empty( Semantic_Settings::license_key() ) ) return; // no key, skip silently self::enqueue( (int) $post->ID ); } public static function deactivate() { wp_clear_scheduled_hook( self::HOOK ); wp_clear_scheduled_hook( self::DISPATCH_HOOK ); } /** * Create the local 'pending' analysis row for a post. No remote call — fast and * safe to do in bulk. Returns the analysis id (a new one, or an existing * pending/processing one), or WP_Error/false. Dispatch is a separate step. * * @param int $post_id * @param string $phase 'full' * @return int|WP_Error|false */ public static function create_pending( $post_id, $phase = 'full' ) { global $wpdb; $post_id = absint( $post_id ); if ( ! $post_id ) return false; // Don't double-queue. $existing = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$wpdb->prefix}semantic_analyses WHERE post_id = %d AND phase = %s AND status IN ('pending','processing') LIMIT 1", $post_id, $phase ) ); if ( $existing ) return (int) $existing; $post = get_post( $post_id ); if ( ! $post ) return new WP_Error( 'not_found', 'Post not found.' ); if ( empty( Semantic_Settings::license_key() ) ) { return new WP_Error( 'no_license', 'License key is not configured.' ); } $wpdb->insert( $wpdb->prefix . 'semantic_analyses', [ 'post_id' => $post_id, 'phase' => $phase, 'status' => 'pending', 'created_at' => current_time( 'mysql' ), 'updated_at' => current_time( 'mysql' ), ], [ '%d', '%s', '%s', '%s', '%s' ] ); $analysis_id = $wpdb->insert_id; return $analysis_id ? (int) $analysis_id : false; } /** * Dispatch one pending analysis row to the queue-analysis edge function. This is * the single blocking remote call — run inline for single analyses, and from the * background cron for bulk runs (so a large "Analyse All" never blocks a request). * * @param int $analysis_id * @return int|WP_Error|false */ public static function dispatch_one( $analysis_id ) { global $wpdb; $analysis_id = absint( $analysis_id ); if ( ! $analysis_id ) return false; $row = $wpdb->get_row( $wpdb->prepare( "SELECT post_id, external_job_id FROM {$wpdb->prefix}semantic_analyses WHERE id = %d", $analysis_id ) ); if ( ! $row ) return false; if ( ! empty( $row->external_job_id ) ) return $analysis_id; // already dispatched $post = get_post( (int) $row->post_id ); if ( ! $post ) { self::fail( $analysis_id, 'Post not found.' ); return new WP_Error( 'not_found', 'Post not found.' ); } $license_key = Semantic_Settings::license_key(); if ( empty( $license_key ) ) { self::fail( $analysis_id, 'License key is not configured.' ); return new WP_Error( 'no_license', 'License key is not configured.' ); } $content = wp_strip_all_tags( $post->post_content ); $domain = wp_parse_url( home_url(), PHP_URL_HOST ); $response = wp_remote_post( SEMANTIC_SUPABASE_URL . '/functions/v1/queue-analysis', [ 'timeout' => 20, 'headers' => [ 'Content-Type' => 'application/json' ], 'body' => wp_json_encode( [ 'license_key' => $license_key, 'domain' => $domain, 'post_id' => (int) $post->ID, 'title' => $post->post_title, 'content' => mb_strlen( $content ) > 10000 ? mb_substr( $content, 0, 10000 ) . '...' : $content, 'url' => get_permalink( $post ), ] ), ] ); if ( is_wp_error( $response ) ) { self::fail( $analysis_id, $response->get_error_message() ); return $response; } $code = wp_remote_retrieve_response_code( $response ); $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( $code !== 200 || empty( $body['job_id'] ) ) { $msg = $body['message'] ?? ( 'Edge function error ' . $code ); self::fail( $analysis_id, $msg ); return new WP_Error( $body['error'] ?? 'ef_error', $msg ); } // Store the external job_id so we can poll for it. $wpdb->update( $wpdb->prefix . 'semantic_analyses', [ 'external_job_id' => $body['job_id'], 'status' => 'processing', 'started_at' => current_time( 'mysql' ), 'updated_at' => current_time( 'mysql' ) ], [ 'id' => $analysis_id ], [ '%s', '%s', '%s', '%s' ], [ '%d' ] ); // Schedule a poll for the result. wp_schedule_single_event( time() + 30, self::HOOK ); return $analysis_id; } /** * Single-post enqueue (manual or publish-time): create the row + dispatch inline. * One remote call is fast enough to keep the UI responsive. * * @param int $post_id * @param string $phase * @return int|WP_Error|false */ public static function enqueue( $post_id, $phase = 'full' ) { $analysis_id = self::create_pending( $post_id, $phase ); if ( ! is_int( $analysis_id ) ) return $analysis_id; // WP_Error or false self::dispatch_one( $analysis_id ); return $analysis_id; } /** * Bulk enqueue: create all the local 'pending' rows immediately (fast, no remote * calls) and hand dispatch to a background cron. This replaces the old synchronous * loop, which fired a blocking 20s remote POST per post inside one request and * 504'd on large sites. */ public static function enqueue_all( $post_ids ) { $ids = []; foreach ( $post_ids as $post_id ) { $ids[] = self::create_pending( $post_id ); // local rows only — no remote calls } self::schedule_dispatch(); return $ids; } /** Ensure the background dispatcher is queued to drain pending rows. */ private static function schedule_dispatch() { if ( ! wp_next_scheduled( self::DISPATCH_HOOK ) ) { wp_schedule_single_event( time(), self::DISPATCH_HOOK ); } } /** * Cron worker: dispatch a paced batch of pending rows to the edge function, then * reschedule until the backlog is drained. N-per-tick keeps each run well under * max_execution_time and the dispatch rate under the EF limits. */ public static function dispatch_pending_batch() { global $wpdb; if ( empty( Semantic_Settings::license_key() ) ) return; $ids = $wpdb->get_col( $wpdb->prepare( "SELECT id FROM {$wpdb->prefix}semantic_analyses WHERE status = 'pending' AND external_job_id IS NULL ORDER BY created_at ASC LIMIT %d", self::DISPATCH_BATCH ) ); foreach ( $ids as $id ) { self::dispatch_one( (int) $id ); } // More pending? Reschedule to drain the rest, paced. $remaining = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}semantic_analyses WHERE status = 'pending' AND external_job_id IS NULL" ); if ( $remaining > 0 ) { wp_schedule_single_event( time() + 20, self::DISPATCH_HOOK ); } } /** * WP-Cron callback — polls check-job for all processing analyses. */ public static function poll_pending_jobs() { global $wpdb; $license_key = Semantic_Settings::license_key(); if ( empty( $license_key ) ) return; $rows = $wpdb->get_results( "SELECT id, external_job_id FROM {$wpdb->prefix}semantic_analyses WHERE status = 'processing' AND external_job_id IS NOT NULL ORDER BY created_at ASC LIMIT 20" ); if ( empty( $rows ) ) return; foreach ( $rows as $row ) { self::check_and_store( (int) $row->id, $row->external_job_id, $license_key ); } // If any are still processing, reschedule $still_pending = $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}semantic_analyses WHERE status = 'processing' AND external_job_id IS NOT NULL" ); if ( $still_pending > 0 ) { wp_schedule_single_event( time() + 15, self::HOOK ); } } /** * Check a single job via check-job edge function and store results if complete. * Also callable directly from the status REST endpoint for faster UI feedback. */ const TIMEOUT_SECONDS = 360; // 6 minutes — matches Trigger.dev maxDuration (300s) + buffer public static function check_and_store( $analysis_id, $external_job_id, $license_key = null ) { global $wpdb; $license_key = $license_key ?: Semantic_Settings::license_key(); if ( empty( $license_key ) || empty( $external_job_id ) ) return; // Auto-timeout: if job has been processing/validating longer than TIMEOUT_SECONDS, mark failed $row = $wpdb->get_row( $wpdb->prepare( "SELECT started_at FROM {$wpdb->prefix}semantic_analyses WHERE id = %d AND status IN ('processing','validating')", $analysis_id ) ); if ( $row && ! empty( $row->started_at ) ) { $elapsed = time() - strtotime( $row->started_at ); if ( $elapsed > self::TIMEOUT_SECONDS ) { self::fail( $analysis_id, 'Analysis timed out. The cloud task did not complete within the expected time. Please try again.' ); return; } } $response = wp_remote_post( SEMANTIC_SUPABASE_URL . '/functions/v1/check-job', [ 'timeout' => 15, 'headers' => [ 'Content-Type' => 'application/json' ], 'body' => wp_json_encode( [ 'job_id' => $external_job_id, 'license_key' => $license_key, ] ), ] ); if ( is_wp_error( $response ) ) return; $code = wp_remote_retrieve_response_code( $response ); if ( $code !== 200 ) return; $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( empty( $body['status'] ) ) return; if ( $body['status'] === 'completed' && ! empty( $body['results'] ) ) { $r = $body['results']; // Map field name used by Trigger.dev task $r['suggested_internal_links'] = $r['suggested_internal_links'] ?? $r['suggested_links'] ?? []; $post_id = (int) $wpdb->get_var( $wpdb->prepare( "SELECT post_id FROM {$wpdb->prefix}semantic_analyses WHERE id = %d", $analysis_id ) ); self::store_results( $analysis_id, $post_id, $r ); self::supersede_old_analyses( $post_id, $analysis_id ); // retire prior analyses for this post Semantic_Entity_Registry::aggregate_from_analysis( $analysis_id ); delete_transient( 'semantic_unanalysed_count' ); // refresh admin bar count delete_transient( 'semantic_quota_cache' ); // quota changed (a credit was consumed) } elseif ( $body['status'] === 'validating' ) { // Wikidata validation step is running — update local status so the UI can show it $wpdb->update( $wpdb->prefix . 'semantic_analyses', [ 'status' => 'validating', 'updated_at' => current_time( 'mysql' ) ], [ 'id' => $analysis_id ] ); } elseif ( $body['status'] === 'failed' ) { self::fail( $analysis_id, $body['job']['error_message'] ?? 'Analysis failed.' ); } } // ------------------------------------------------------------------------- public static function store_results( $analysis_id, $post_id, array $r ) { global $wpdb; $t = $wpdb->prefix; if ( ! empty( $r['entities'] ) ) { foreach ( $r['entities'] as $entity ) { $wpdb->insert( "{$t}semantic_suggested_entities", [ 'analysis_id' => $analysis_id, 'post_id' => $post_id, 'name' => $entity['name'], 'type' => $entity['type'], 'definition' => $entity['definition'] ?? '', 'relevance_score' => $entity['relevance_score'] ?? 0, 'model_confidence' => $entity['model_confidence'] ?? 0, 'contextual_confidence'=> $entity['contextual_confidence'] ?? 0, 'related_entities' => wp_json_encode( $entity['related_entities'] ?? [] ), 'mentions' => wp_json_encode( $entity['mentions'] ?? [] ), 'schema_reasoning' => $entity['schema_reasoning'] ?? '', 'exclusion_reasoning' => $entity['exclusion_reasoning'] ?? null, 'wikidata_qid' => $entity['wikidata_qid'] ?? null, 'wikidata_verified' => ! empty( $entity['wikidata_verified'] ) ? 1 : 0, 'aliases' => wp_json_encode( $entity['aliases'] ?? [] ), 'same_as' => wp_json_encode( $entity['sameAs'] ?? [] ), 'roles' => wp_json_encode( $entity['roles'] ?? [] ), 'canonical_entity_id' => $entity['canonical_entity_id'] ?? null, 'salience' => $entity['relevance_score'] ?? 0.5, 'approval_status' => 'pending', ] ); } } if ( ! empty( $r['suggested_schema'] ) ) { foreach ( $r['suggested_schema'] as $schema ) { $wpdb->insert( "{$t}semantic_suggested_schema", [ 'analysis_id' => $analysis_id, 'post_id' => $post_id, 'entity' => $schema['entity'], 'type' => $schema['type'], 'detection_method' => $schema['detection_method'], 'use_in_schema' => ! empty( $schema['use_in_schema'] ) ? 1 : 0, 'schema_properties'=> wp_json_encode( $schema['schema_properties'] ?? [] ), 'reasoning' => $schema['reasoning'] ?? '', 'approval_status' => 'pending', ] ); } } // Internal-link suggestions are no longer generated. The cloud model // invented target URLs with no knowledge of which pages actually exist, // so it routinely suggested links to non-existent pages. Page-to-page // interconnection is now surfaced from real entity co-occurrence via the // "Related pages" review tab (Semantic_Cohesion::related_pages()). The // semantic_suggested_links table is retained so any previously approved // links keep working; we simply stop writing new rows to it. if ( ! empty( $r['suggested_faqs'] ) ) { foreach ( $r['suggested_faqs'] as $faq ) { $wpdb->insert( "{$t}semantic_suggested_faqs", [ 'analysis_id' => $analysis_id, 'post_id' => $post_id, 'question' => $faq['question'], 'answer' => $faq['answer'], 'ai_confidence' => $faq['ai_confidence'] ?? 0, 'ai_optimization'=> wp_json_encode( $faq['ai_optimization'] ?? [] ), 'entity_mentions'=> wp_json_encode( $faq['entity_mentions'] ?? [] ), 'reasoning' => $faq['reasoning'] ?? '', 'approval_status'=> 'pending', ] ); } } $wpdb->update( "{$t}semantic_analyses", [ 'status' => 'completed', 'content_analysis' => wp_json_encode( $r['content_analysis'] ?? [] ), 'summary' => $r['summary'] ?? '', 'citable_snippet' => $r['citable_snippet'] ?? '', 'llm_tags' => $r['llm_tags'] ?? '', 'author_credentials' => $r['author_credentials'] ?? '', 'audience' => $r['audience'] ?? '', 'compact_howto' => $r['compact_howto'] ?? '', 'summaries' => wp_json_encode( $r['summaries'] ?? null ), 'llm_context_block' => $r['llm_context_block'] ?? '', 'llm_title' => $r['llm_title'] ?? '', 'llm_description' => $r['llm_description'] ?? '', 'content_sections' => wp_json_encode( $r['content_sections'] ?? [] ), 'cleaned_content' => $r['cleaned_content'] ?? '', 'primary_topic_entity' => $r['primary_topic_entity'] ?? '', 'page_type' => $r['semantic_intent']['page_type'] ?? '', 'primary_intent' => $r['semantic_intent']['primary_intent'] ?? '', 'secondary_intents' => wp_json_encode( $r['semantic_intent']['secondary_intents'] ?? [] ), 'user_stage' => $r['semantic_intent']['user_stage'] ?? '', 'difficulty' => $r['semantic_intent']['difficulty'] ?? '', 'entity_relationships' => wp_json_encode( $r['entity_relationships'] ?? [] ), 'finished_at' => current_time( 'mysql' ), 'updated_at' => current_time( 'mysql' ), ], [ 'id' => $analysis_id ] ); } /** * Retire any earlier analyses for a post once a new one completes. * Removes their suggested_* rows (which would otherwise linger as orphaned * 'pending' items) and marks the old analysis rows 'superseded' so they * stop counting toward coverage / pending-review totals. */ private static function supersede_old_analyses( $post_id, $keep_analysis_id ) { global $wpdb; $t = $wpdb->prefix; $old_ids = $wpdb->get_col( $wpdb->prepare( "SELECT id FROM {$t}semantic_analyses WHERE post_id = %d AND id <> %d AND status IN ( 'completed', 'validating', 'processing', 'pending' )", $post_id, $keep_analysis_id ) ); if ( empty( $old_ids ) ) return; $ids = array_map( 'intval', $old_ids ); $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); foreach ( [ 'semantic_suggested_entities', 'semantic_suggested_schema', 'semantic_suggested_links', 'semantic_suggested_faqs' ] as $tbl ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $t is $wpdb->prefix and $tbl is a hardcoded internal table-name whitelist (not user input); the analysis_id list is bound with %d placeholders. $wpdb->query( $wpdb->prepare( "DELETE FROM {$t}{$tbl} WHERE analysis_id IN ($placeholders)", $ids ) ); } // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $t is $wpdb->prefix (table-name interpolation); the id list is bound with %d placeholders. $wpdb->query( $wpdb->prepare( "UPDATE {$t}semantic_analyses SET status = 'superseded' WHERE id IN ($placeholders)", $ids ) ); } private static function fail( $analysis_id, $message ) { global $wpdb; $wpdb->update( $wpdb->prefix . 'semantic_analyses', [ 'status' => 'failed', 'error_message' => $message, 'finished_at' => current_time( 'mysql' ), 'updated_at' => current_time( 'mysql' ), ], [ 'id' => $analysis_id ] ); } }