[content_id,...]` map. * @return bool True when the pull succeeded (regardless of how many pages were ready). */ public static function pull_generated( $chat_id, $process_id, $session_id, $ai_page_ids, $api_key = '' ): bool { $response = self::with_api_key( $api_key, function () use ( $chat_id ) { return Helper::make_api_get_request( "v2/chatbot/generated/{$chat_id}", [], [ 'Accept' => 'application/json' ], 30 ); } ); if ( is_wp_error( $response ) ) { Helper::log( sprintf( 'chat_provider[%s] pull failed: %s', $chat_id, $response->get_error_message() ), 'ai-import', 'error' ); return false; } $response_code = wp_remote_retrieve_response_code( $response ); if ( $response_code !== 200 ) { Helper::log( sprintf( 'chat_provider[%s] pull HTTP %d', $chat_id, $response_code ), 'ai-import', 'error' ); return false; } $data = json_decode( wp_remote_retrieve_body( $response ), true ); // The chatbot API signals application-level failure INSIDE an HTTP 200 as // `{status:'error', message}` — the same contract chatbot_request() in // ai-fsi's REST\AIContent enforces. This call site cannot use that seam // (different module, and the Finalizer context needs the raw-transport // with_api_key override), so the rule is enforced inline: an error body is // a FAILED pull, never "0 pages ready". Without this the error fell // through to the normal wrote=0 info line and the wait ran to its 420s // timeout with nothing in the log naming the actual cause. if ( ! is_array( $data ) || 'error' === ( $data['status'] ?? '' ) ) { $message = is_array( $data ) && ! empty( $data['message'] ) ? $data['message'] : 'invalid response body'; Helper::log( sprintf( 'chat_provider[%s] pull rejected: %s', $chat_id, $message ), 'ai-import', 'error' ); return false; } $generated = ( isset( $data['data'] ) && is_array( $data['data'] ) ) ? $data['data'] : []; // Access gate — mirrors the chatbot-import-prepare endpoint. A free user // past their 7-day window must not have content pulled mid-import. if ( isset( $generated['can_import'] ) && ! $generated['can_import'] ) { Helper::log( sprintf( 'chat_provider[%s] can_import=false, skipping pull', $chat_id ), 'ai-import', 'error' ); return false; } $templates = ( isset( $generated['templates'] ) && is_array( $generated['templates'] ) ) ? $generated['templates'] : []; $skipped = ( isset( $generated['skipped_pages'] ) && is_array( $generated['skipped_pages'] ) ) ? array_map( 'strval', $generated['skipped_pages'] ) : []; // Write each newly-generated page to its .ai.json location for the runners. // Pages already on disk are skipped — this runs on every finalize tick and // re-serialising the whole bundle each time is pure waste. $written = 0; foreach ( $templates as $content_id => $template ) { if ( empty( $template ) ) { continue; } if ( self::page_file_exists( $session_id, $content_id, $ai_page_ids ) ) { continue; } // The runners read JSON strings; normalize arrays/objects to a string. $payload = is_string( $template ) ? $template : wp_json_encode( $template ); AIUtils::save_template_to_file( $process_id, $session_id, $content_id, $payload, $ai_page_ids, false ); $written++; } // Backend-skipped pages → explicit `{"isSkipped":true}` markers so finalize // falls back to the pack's default content instead of waiting forever. foreach ( $skipped as $skipped_id ) { if ( array_key_exists( $skipped_id, $templates ) || array_key_exists( (int) $skipped_id, $templates ) ) { continue; } if ( self::page_file_exists( $session_id, $skipped_id, $ai_page_ids ) ) { continue; } AIUtils::save_template_to_file( $process_id, $session_id, $skipped_id, '', $ai_page_ids, true ); $written++; } $expected = AIUtils::flatten_ai_page_ids( $ai_page_ids ); $missing = array_values( array_filter( $expected, function ( $id ) use ( $templates, $skipped ) { return ! array_key_exists( $id, $templates ) && ! array_key_exists( (int) $id, $templates ) && ! in_array( (string) $id, $skipped, true ); } ) ); // Generation is FINISHED but some expected pages were never delivered and // were never listed in `skipped_pages` either — the backend silently did // not produce them (seen live: a pack's "Product Roadmap" page absent from // a `status: READY` bundle with `skipped_pages: []`). // // Nothing will ever arrive for these, so treat them as skipped and write // the marker now. Without this the Finalizer waits the full 420s on each // one before falling back to the pack's default content anyway — a // multi-minute stall with a frozen progress bar for an identical result. if ( ! empty( $missing ) && self::is_generation_finished( $generated ) ) { foreach ( $missing as $undelivered_id ) { if ( self::page_file_exists( $session_id, $undelivered_id, $ai_page_ids ) ) { continue; } AIUtils::save_template_to_file( $process_id, $session_id, $undelivered_id, '', $ai_page_ids, true ); $written++; } Helper::log( sprintf( 'chat_provider[%s] generation finished but %d page(s) never delivered (%s) — marked skipped, importing pack defaults', $chat_id, count( $missing ), implode( ',', $missing ) ), 'ai-import', 'error' ); $missing = []; } // Record credits consumed ONLY once generation is genuinely complete. // // `credit_cost` is the pipeline's "generation finished, stop waiting" // signal: its mere presence makes handle_sse_wait_with_timeout() return // true immediately. The backend reports `credits_consumed` incrementally, // so writing it on the first pull would disable waiting for the entire // import and every still-generating page would fall back to pack defaults. $credit_cost = $generated['credits_consumed'] ?? ( $generated['credit_cost'] ?? 0 ); if ( empty( $missing ) && $credit_cost > 0 ) { $processed_pages = get_option( 'templately_ai_processed_pages', [] ); $processed_pages[ $process_id ] = $processed_pages[ $process_id ] ?? []; $processed_pages[ $process_id ]['credit_cost'] = $credit_cost; update_option( 'templately_ai_processed_pages', $processed_pages, false ); } Helper::log( sprintf( 'chat_provider[%s] pull: wrote=%d ready=%d/%d missing=%d skipped=%d', $chat_id, $written, count( $expected ) - count( $missing ), count( $expected ), count( $missing ), count( $skipped ) ), 'ai-import', 'info' ); return true; } /** * Whether the backend considers generation for this conversation FINISHED. * * Mirrors the frontend's `GENERATED_READINESS_READY` (see * AiContentSidebar/AIConversation.js — backend enum * `App\Enums\ChatbotGeneratedReadiness`: PENDING = 0, READY = 1). Once READY, * a page absent from both `templates` and `skipped_pages` is never coming. * * Deliberately strict: an unknown/missing `status` is treated as NOT finished, * so an unrecognised payload makes us keep waiting rather than prematurely * degrade pages to pack default content. * * @param array $generated The bundle's `data` payload. * @return bool */ private static function is_generation_finished( array $generated ): bool { return isset( $generated['status'] ) && (int) $generated['status'] === self::READINESS_READY; } /** * Whether a page's `.ai.json` is already on disk, so the pull can skip * rewriting it. Thin wrapper over the resolver's staged check. * * @param string $session_id * @param string|int $content_id * @param array $ai_page_ids * @return bool */ private static function page_file_exists( $session_id, $content_id, $ai_page_ids ): bool { return AIContentResolver::page_staged( [ 'session_id' => $session_id, 'content_id' => $content_id, 'ai_page_ids' => $ai_page_ids, ] ); } }