File "Chat.php"

Full Path: /home/buyiwexj/public_html/wp-content/plugins/wpforms-lite/src/Integrations/AI/Admin/Ajax/Chat/Chat.php
File size: 24.5 KB
MIME-type: text/x-php
Charset: utf-8

<?php

namespace WPForms\Integrations\AI\Admin\Ajax\Chat;

use WPForms\Integrations\AI\Admin\Ajax\Base;
use WPForms\Integrations\AI\Admin\Chat\ScopeRegistry;
use WPForms\Integrations\AI\API\Chat as ChatAPI;

/**
 * AI Chat AJAX handler.
 *
 * Validates the request, assembles the initial context from each active scope's
 * `page_state_builder`, drives the middleware exchange loop against `API\Chat`,
 * and returns the final `message` envelope.
 *
 * Admin-only — no nopriv variant. Read-only by design.
 *
 * @since 2.0.0
 */
class Chat extends Base {

	/**
	 * AJAX action name. Generic across all scopes; also used as the nonce action.
	 *
	 * @since 2.0.0
	 */
	public const AJAX_ACTION = 'wpforms_ai_chat';

	/**
	 * Maximum chat <-> middleware rounds per request.
	 *
	 * Comparison queries (e.g. "April errors vs May errors") legitimately
	 * consume 2 rounds for sequential data fetches plus 1 answer round.
	 * Allow headroom for one wasted round (model occasionally re-emits
	 * request_data after sufficient data has been gathered) before
	 * surfacing `rounds_exceeded`.
	 *
	 * @since 2.0.0
	 */
	private const MAX_ROUNDS = 5;

	/**
	 * Max server-side reclassify attempts per turn.
	 *
	 * @since 2.0.0
	 */
	private const MAX_RECLASSIFY = 1;

	/**
	 * Generic error response message.
	 *
	 * @since 2.0.0
	 */
	private const ERROR_GENERIC = 'Unable to retrieve the requested data.';

	/**
	 * Chat API client. Replaced via `set_api_for_tests()` for unit tests.
	 *
	 * @since 2.0.0
	 *
	 * @var object Either API\Chat or a test stub exposing `chat()`.
	 */
	protected $api_chat;

	/**
	 * Request context helper for validation and resolution.
	 *
	 * @since 2.0.0
	 *
	 * @var RequestContext
	 */
	private $request_context;

	/**
	 * Turn-scoped tool-call processor. Created at the start of `run_round_loop()`.
	 *
	 * @since 2.0.0
	 *
	 * @var ToolCallProcessor
	 */
	private $tool_processor;

	/**
	 * Initialize.
	 *
	 * @since 2.0.0
	 */
	public function init() {

		parent::init();

		$this->api_chat        = new ChatAPI();
		$this->request_context = new RequestContext();

		$this->api_chat->init();
		$this->hooks();
	}

	/**
	 * Register hooks.
	 *
	 * @since 2.0.0
	 */
	private function hooks(): void {

		add_action( 'wp_ajax_' . self::AJAX_ACTION, [ $this, 'process' ] );
	}

	/**
	 * Process an AJAX request from the live admin context.
	 *
	 * Emits the response as an NDJSON stream over chunked transfer:
	 * intermediate `tool_call_start` / `tool_call_end` frames during the
	 * round loop, followed by a terminal `message` or `error` frame.
	 *
	 * @since 2.0.0
	 */
	public function process(): void {

		// Tool-calling can issue multiple sequential middleware round-trips
		// (classify + up to MAX_ROUNDS answer rounds). Each leg is bound to
		// Http\Request::TIMEOUT (60s), so the cumulative AJAX time can exceed
		// hosts' default PHP max_execution_time (often 30s). Raise the floor
		// so long multi-tool turns don't die mid-stream — the symptom is the
		// browser's "Stream closed unexpectedly" protocol error when PHP
		// terminates the script before `flush_frame()` writes the terminal
		// message frame. 120s headroom covers the 4-round case (classify +
		// forms_ranking + parallel form_stats_trend + final answer) we've
		// observed in the wild; bump higher if hosts in the field need it.
		if ( function_exists( 'set_time_limit' ) ) {
			@set_time_limit( 120 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Missing
		$payload = (array) wp_unslash( $_POST );

		$this->send_streaming_headers();
		$this->disable_output_buffering();

		$response = $this->process_for_tests( $payload, [ $this, 'flush_frame' ] );

		if ( $response['status'] === 'success' ) {
			$this->flush_frame(
				[
					'v'    => 1,
					'type' => 'message',
				] + (array) $response['data']
			);
		} else {
			$this->flush_frame(
				[
					'v'       => 1,
					'type'    => 'error',
					'code'    => (string) ( $response['code'] ?? 'unknown' ),
					'message' => (string) ( $response['error'] ?? '' ),
				]
			);
		}

		exit;
	}

	/**
	 * Process a request payload. Pure function — does not call wp_send_json_*.
	 *
	 * The live `process()` wraps this for the production AJAX path; tests call
	 * it directly to assert the response shape without going through wp-die.
	 *
	 * @since 2.0.0
	 *
	 * @param array         $payload Request payload (sanitized).
	 * @param callable|null $sink    Optional frame sink invoked on tool-call
	 *                               brackets during the answer-round loop.
	 *                               Receives a single associative array per call.
	 *                               Defaults to a no-op so non-streaming callers
	 *                               (tests, classify-only paths) are unaffected.
	 *
	 * @return array Uniform response envelope `{ status, code, error, data }`.
	 */
	public function process_for_tests( array $payload, ?callable $sink = null ): array {

		$sink = $sink ?? $this->default_frame_sink();

		if ( $this->request_context === null ) {
			$this->request_context = new RequestContext();
		}

		$validation = $this->request_context->validate( $payload, self::AJAX_ACTION );

		if ( $validation !== null ) {
			return $validation;
		}

		$payload = $this->request_context->decode_page_state( $payload );
		$surface = $this->request_context->resolve_surface( $payload );

		// `resolve_surface()` returns an error envelope (has a non-null `status`)
		// when the surface is unknown or the user lacks its capability.
		if ( isset( $surface['status'] ) ) {
			return $surface;
		}

		$requested_scopes = $this->request_context->sanitize_scope_list( $payload['scopes'] ?? [] );
		$active_scopes    = $this->request_context->filter_scopes_by_capability( $requested_scopes );

		if ( $active_scopes === [] ) {
			return $this->error( 'scopes_empty', 'No accessible scopes for this request.' );
		}

		$context = $this->request_context->build_initial_context( $surface, $active_scopes, $payload );

		return $this->run_round_loop( $context, $payload, $active_scopes, $sink );
	}

	/**
	 * Build the no-op frame sink used when no streaming sink is supplied.
	 *
	 * Buffered call sites (tests, classify-only paths) pass no sink, so frames
	 * emitted during the answer-round loop are silently discarded.
	 *
	 * @since 2.0.0
	 *
	 * @return callable Sink that accepts a single frame array and does nothing.
	 */
	private function default_frame_sink(): callable {

		return static function (): void {};
	}

	/**
	 * Emit response headers for chunked NDJSON streaming.
	 *
	 * @since 2.0.0
	 */
	private function send_streaming_headers(): void {

		nocache_headers();
		header( 'Content-Type: application/x-ndjson; charset=utf-8' );

		// Disable nginx response buffering — chunks must reach the client immediately.
		header( 'X-Accel-Buffering: no' );
	}

	/**
	 * Disable output buffering so flushed frames reach the client immediately.
	 *
	 * @since 2.0.0
	 */
	private function disable_output_buffering(): void {

		// Use ob_end_clean (not ob_end_flush) so any HTML/output buffered by other
		// plugins above us is *discarded*, not leaked into our NDJSON stream.
		// Admin AJAX shouldn't accumulate page-render output, but defensive plugins
		// (caching, minification) sometimes open buffers anyway.
		while ( ob_get_level() > 0 ) {
			ob_end_clean();
		}

		// phpcs:ignore WordPress.PHP.IniSet.Risky, WordPress.PHP.NoSilencedErrors.Discouraged
		@ini_set( 'zlib.output_compression', '0' );
	}

	/**
	 * Write one NDJSON frame to the output stream and flush.
	 *
	 * Acts as the frame sink passed into `process_for_tests()` during the live
	 * AJAX path. Aborts the request when the client disconnects.
	 *
	 * Visibility is `public` because it is passed as a callable via
	 * `[ $this, 'flush_frame' ]` — narrowing would break the sink injection.
	 *
	 * @since 2.0.0
	 *
	 * @param array $frame Frame payload.
	 */
	public function flush_frame( array $frame ): void {

		$encoded = wp_json_encode( $frame );

		// Guard against encode failures (UTF-8 issues, etc.). Dropping a terminal frame
		// silently would hang the client; substitute a generic error so the stream
		// always closes with a parseable terminal.
		if ( $encoded === false ) {
			$encoded = wp_json_encode(
				[
					'v'       => 1,
					'type'    => 'error',
					'code'    => 'encode_failed',
					'message' => 'Frame could not be encoded.',
				]
			);
		}

		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $encoded is the return value of wp_json_encode(); JSON is the wire format for this streaming endpoint, not HTML.
		echo $encoded . "\n";
		flush();

		if ( connection_aborted() ) {
			exit;
		}
	}

	/**
	 * Inject a stub API for tests.
	 *
	 * Type-hint is intentionally loose so tests can pass anonymous classes that
	 * expose the same `chat()` signature without inheriting from `API\Chat`.
	 *
	 * @since 2.0.0
	 *
	 * @param object $api Anything with a `chat()` method matching `API\Chat::chat()`.
	 */
	public function set_api_for_tests( $api ): void {

		$this->api_chat = $api;
	}

	/**
	 * Drive the chat <-> middleware exchange loop.
	 *
	 * Round 1: classify (unchanged — picks the minimum scope subset).
	 * Rounds 2..N: answer rounds. Middleware returns `{tool_calls | message | error}`.
	 *   - `message` → terminal, emit and return.
	 *   - `tool_calls` → execute each sequentially, append `{role: tool}` messages
	 *     to history, continue.
	 *   - `error` → terminal, emit and return.
	 *
	 * @since 2.0.0
	 *
	 * @param array    $context       Initial assembled context.
	 * @param array    $payload       Original request payload.
	 * @param array    $active_scopes Active scope slugs.
	 * @param callable $sink          Frame sink invoked on tool-call brackets.
	 *
	 * @return array
	 */
	private function run_round_loop( array $context, array $payload, array $active_scopes, callable $sink ): array { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh

		$user_prompt = sanitize_textarea_field( $this->request_context->payload_string( $payload, 'prompt', 'userPrompt' ) );
		$session_id  = sanitize_text_field( $this->request_context->payload_string( $payload, 'sessionId' ) );
		$history     = $this->request_context->sanitize_history( $payload['history'] ?? [] );

		// Per-turn diagnostics attached to the terminal success envelope as `processingData`.
		// The chat element logs it to the console via `wpf.debug()` (debug mode only),
		// mirroring the form editor. `rounds` holds one entry per OpenAI request (the
		// breakdown — wall-clock + middleware time + token usage + tool calls); `time` is
		// the whole-turn wall-clock, which (unlike a sum of per-request server times)
		// includes plugin<->middleware latency and the plugin-side tool execution between
		// rounds.
		$turn_start = microtime( true );
		$telemetry  = [
			'time'   => 0.0,
			'rounds' => [],
			'scopes' => [],
			'tools'  => [],
		];

		// Classify round — picks minimum scope subset.
		$classify = $this->run_classify_round( $context, $active_scopes, $user_prompt, $session_id, $history );

		$telemetry['rounds'][] = $classify['round'];

		if ( $classify['terminal'] !== null ) {
			return $classify['terminal'];
		}

		$context    = $classify['context'];
		$session_id = $classify['session_id'];
		$batch_id   = $classify['batch_id'];

		// Scopes the classify round narrowed the turn to.
		$telemetry['scopes'] = array_values( array_map( 'strval', (array) ( $context['scopes'] ?? [] ) ) );

		$registry = wpforms()->obj( 'ai_chat_scope_registry' );

		$this->tool_processor = $registry instanceof ScopeRegistry
			? new ToolCallProcessor( $registry, $payload, $sink )
			: null;

		// A non-empty $active_scopes guarantees the registry resolved upstream, so this
		// is effectively unreachable today. Guard locally anyway so process_answer_round()
		// can never dereference a null processor if that invariant changes.
		if ( $this->tool_processor === null ) {
			return $this->error( 'registry_unavailable', self::ERROR_GENERIC );
		}

		$reclassify_used = 0;

		// Track every tool-call set hash we've already executed this turn so
		// `process_answer_round()` can fire the stuck-loop short-circuit on any
		// repeat (not just consecutive A→A but also A→B→…→A). The model has the
		// prior tool results in its history, so re-emitting an identical set is
		// always indicative of being stuck.
		$seen_set_hashes = [];

		for ( $round = 1; $round <= self::MAX_ROUNDS; $round++ ) {
			$result = $this->process_answer_round(
				$context,
				$user_prompt,
				$session_id,
				$history,
				$batch_id,
				$seen_set_hashes
			);

			// Sync out-vars updated inside the helper.
			$session_id      = $result['session_id'];
			$seen_set_hashes = $result['seen_set_hashes'];
			$history         = $result['history'];

			$telemetry['rounds'][] = $result['round'];
			$telemetry['tools']    = array_merge( $telemetry['tools'], (array) ( $result['round']['tools'] ?? [] ) );

			if ( $result['terminal'] === null ) {
				continue;
			}

			// Server-side reclassify: the model called the synthetic `reclassify`
			// tool. Re-run classify with the hint and continue the answer loop
			// with the updated scope set instead of punting to the browser.
			if ( isset( $result['terminal'][ ToolCallProcessor::RECLASSIFY_SENTINEL ] ) ) {
				$reclassify = $this->handle_reclassify(
					(string) $result['terminal'][ ToolCallProcessor::RECLASSIFY_SENTINEL ],
					$reclassify_used,
					$context,
					$active_scopes,
					$user_prompt,
					$session_id,
					$history
				);

				// Budget exhausted or classify failed.
				if ( $reclassify === null || ! isset( $reclassify['context'] ) ) {
					$telemetry['time'] = microtime( true ) - $turn_start;

					return $this->attach_processing_data(
						is_array( $reclassify ) ? $reclassify : $this->error( 'reclassify_exhausted', self::ERROR_GENERIC ),
						$telemetry
					);
				}

				$telemetry['rounds'][] = $reclassify['round'];

				++$reclassify_used;
				$context    = $reclassify['context'];
				$session_id = $reclassify['session_id'];
				$batch_id   = $reclassify['batch_id'];

				$telemetry['scopes'] = array_values( array_map( 'strval', (array) ( $context['scopes'] ?? [] ) ) );

				continue;
			}

			$telemetry['time'] = microtime( true ) - $turn_start;

			return $this->attach_processing_data( $result['terminal'], $telemetry );
		}

		return $this->error( 'rounds_exceeded', self::ERROR_GENERIC );
	}

	/**
	 * Handle a server-side reclassify attempt.
	 *
	 * Returns the new classify state bag on success, or null when the
	 * reclassify budget is exhausted. On classify failure the error terminal
	 * is returned directly (the caller should propagate it).
	 *
	 * @since 2.0.0
	 *
	 * @param string $hint            Scope hint from the reclassify tool call.
	 * @param int    $reclassify_used Number of reclassifies already consumed this turn.
	 * @param array  $context         Current assembled context.
	 * @param array  $active_scopes   Active scope slugs.
	 * @param string $user_prompt     Sanitized user prompt.
	 * @param string $session_id      Current session ID.
	 * @param array  $history         Conversation history so far.
	 *
	 * @return array|null Classify state bag on success, null when budget exhausted.
	 */
	private function handle_reclassify( string $hint, int $reclassify_used, array $context, array $active_scopes, string $user_prompt, string $session_id, array $history ): ?array {

		if ( $reclassify_used >= self::MAX_RECLASSIFY ) {
			return null;
		}

		return $this->run_classify_round( $context, $active_scopes, $user_prompt, $session_id, $history, $hint );
	}

	/**
	 * Run the classify round and resolve the narrowed scope context.
	 *
	 * Returns a state bag:
	 *   - `terminal`   (array|null) — non-null error envelope when classify fails.
	 *   - `context`    (array)      — context with `scopes` narrowed to the selection.
	 *   - `session_id` (string)     — updated session ID.
	 *   - `batch_id`   (string)     — rate-limit batch ID for subsequent rounds.
	 *   - `round`      (array)      — per-request diagnostics entry (see `build_round_entry()`).
	 *
	 * @since 2.0.0
	 *
	 * @param array  $context         Initial assembled context.
	 * @param array  $active_scopes   Active scope slugs.
	 * @param string $user_prompt     Sanitized user prompt.
	 * @param string $session_id      Current session ID.
	 * @param array  $history         Conversation history so far.
	 * @param string $reclassify_hint Scope hint from a prior reclassify tool call.
	 *
	 * @return array State bag.
	 */
	private function run_classify_round( array $context, array $active_scopes, string $user_prompt, string $session_id, array $history, string $reclassify_hint = '' ): array {

		$started  = microtime( true );
		$classify = $this->api_chat->chat( $context, $user_prompt, $session_id, $history, '', 'classify', $reclassify_hint );
		$round    = $this->build_round_entry( 'classify', microtime( true ) - $started, $classify );

		if ( ! empty( $classify['error'] ) ) {
			$error_code = ( (int) ( $classify['error_code'] ?? 0 ) ) === 429 ? 'rate_limit' : 'api_error';

			return [
				'terminal'   => $this->error( $error_code, (string) $classify['error'] ),
				'context'    => $context,
				'session_id' => $session_id,
				'batch_id'   => '',
				'round'      => $round,
			];
		}

		if ( ! empty( $classify['sessionId'] ) ) {
			$session_id = (string) $classify['sessionId'];
		}

		$selected = (array) ( $classify['selectedScopes'] ?? [] );
		$selected = array_values( array_intersect( $active_scopes, array_map( 'strval', $selected ) ) );

		if ( $selected !== [] ) {
			$context['scopes'] = $selected;
		}

		return [
			'terminal'   => null,
			'context'    => $context,
			'session_id' => $session_id,
			'batch_id'   => (string) ( $classify['responseId'] ?? '' ),
			'round'      => $round,
		];
	}

	/**
	 * Run one answer round and return a state bag for the caller.
	 *
	 * The returned array always has:
	 *   - `terminal`        (array|null) — non-null when this round ends the loop.
	 *   - `session_id`      (string)     — updated session ID.
	 *   - `seen_set_hashes` (array)      — accumulator of every accepted set hash this turn.
	 *   - `history`         (array)      — updated conversation history.
	 *   - `round`           (array)      — per-request diagnostics entry (see `build_round_entry()`); its `tools` are filled in `process_tool_calls()`.
	 *
	 * @since 2.0.0
	 *
	 * @param array    $context         Assembled context (passed by value — scopes already narrowed).
	 * @param string   $user_prompt     Sanitized user prompt.
	 * @param string   $session_id      Current session ID.
	 * @param array    $history         Conversation history so far.
	 * @param string   $batch_id        Rate-limit batch ID from the classify round.
	 * @param string[] $seen_set_hashes Tool-call set hashes already executed this turn (stuck-loop window).
	 *
	 * @return array State bag.
	 */
	private function process_answer_round( array $context, string $user_prompt, string $session_id, array $history, string $batch_id, array $seen_set_hashes ): array {

		$state = [
			'session_id'      => $session_id,
			'seen_set_hashes' => $seen_set_hashes,
			'history'         => $history,
		];

		$started  = microtime( true );
		$response = $this->api_chat->chat( $context, $user_prompt, $session_id, $history, $batch_id, 'answer' );

		$state['round'] = $this->build_round_entry( 'answer', microtime( true ) - $started, $response );

		if ( ! empty( $response['sessionId'] ) ) {
			$state['session_id'] = (string) $response['sessionId'];
		}

		$state['terminal'] = $this->resolve_answer_terminal( $response, $state['session_id'] );

		if ( $state['terminal'] !== null ) {
			return $state;
		}

		// Scopes the classify round pinned for this turn (capability-filtered upstream
		// in `filter_scopes_by_capability()`). Threaded into dispatch as the tool-call
		// allow-list so an LLM-supplied tool name cannot reach a scope outside the set.
		$allowed_scopes = array_map( 'strval', (array) ( $context['scopes'] ?? [] ) );

		return $this->tool_processor->process( $response, $state, $allowed_scopes );
	}

	/**
	 * Resolve an early terminal envelope from an answer-round response.
	 *
	 * Returns an error envelope when the middleware reports an error, a success
	 * envelope when it returns a final message, or null when the loop should
	 * continue into tool-call dispatch.
	 *
	 * @since 2.0.0
	 *
	 * @param array  $response   Raw answer-round API response.
	 * @param string $session_id Current session ID for the success envelope.
	 *
	 * @return array|null Terminal envelope, or null to continue.
	 */
	private function resolve_answer_terminal( array $response, string $session_id ): ?array {

		if ( ! empty( $response['error'] ) ) {
			$code = ( (int) ( $response['error_code'] ?? 0 ) ) === 429 ? 'rate_limit' : 'api_error';

			return $this->error( $code, (string) $response['error'] );
		}

		if ( ! empty( $response['message'] ) ) {
			return $this->success(
				[
					'message'    => $response['message'],
					'sessionId'  => $session_id,
					// Propagate the middleware's tracked response ID so the chat
					// element can set `data-response-id` on the answer — the rate
					// (like/dislike) endpoint requires it.
					'responseId' => (string) ( $response['responseId'] ?? '' ),
				]
			);
		}

		return null;
	}

	/**
	 * Build a uniform success response envelope.
	 *
	 * @since 2.0.0
	 *
	 * @param array $data Response payload.
	 *
	 * @return array
	 */
	private function success( array $data ): array {

		return [
			'status' => 'success',
			'code'   => null,
			'error'  => null,
			'data'   => $data,
		];
	}

	/**
	 * Build a uniform error response envelope.
	 *
	 * The `error` field name (not `message`) matches what api.js / chat-helpers-admin consume.
	 *
	 * @since 2.0.0
	 *
	 * @param string $code  Error code slug.
	 * @param string $error Human-readable error message.
	 *
	 * @return array
	 */
	private function error( string $code, string $error ): array {

		return [
			'status' => 'error',
			'code'   => $code,
			'error'  => $error,
			'data'   => null,
		];
	}

	/**
	 * Build a per-request diagnostics entry for one middleware round-trip.
	 *
	 * Each classify/answer round routes through the middleware's `aiHandler`, which stamps
	 * `processingData.time` (its own processing time, seconds) and `processingData.usage`
	 * (OpenAI token usage) on the response. `time` here is the plugin-measured wall-clock for
	 * the request (network + middleware), `serverTime` the middleware's self-reported figure.
	 *
	 * @since 2.0.0
	 *
	 * @param string $mode     Round mode — `classify` or `answer`.
	 * @param float  $wall     Plugin-measured wall-clock for the request, in seconds.
	 * @param array  $response Raw middleware chat response.
	 *
	 * @return array Round entry `{ mode, time, serverTime, usage, tools }`. `tools` starts empty and is filled by `process_tool_calls()` for answer rounds that dispatch calls.
	 */
	private function build_round_entry( string $mode, float $wall, array $response ): array {

		$processing = (array) ( $response['processingData'] ?? [] );

		return [
			'mode'       => $mode,
			'time'       => number_format( $wall, 3, '.', '' ),
			'serverTime' => isset( $processing['time'] ) ? (string) $processing['time'] : null,
			'usage'      => $processing['usage'] ?? null,
			'tools'      => [],
		];
	}

	/**
	 * Attach the aggregated per-turn diagnostics to a terminal success envelope.
	 *
	 * The chat element logs `response.processingData` to the console via `wpf.debug()`
	 * (debug mode only), matching the form editor. Error envelopes are returned untouched —
	 * only successful answers carry diagnostics. Shape:
	 *   - `time`   — whole-turn wall-clock (seconds): network + middleware + plugin-side tool execution.
	 *   - `rounds` — one entry per OpenAI request: `{ mode, time, serverTime, usage, tools }`.
	 *   - `scopes` — scopes the classify round pinned for the turn.
	 *   - `tools`  — flat list of every dispatched tool-call object across all rounds.
	 *
	 * @since 2.0.0
	 *
	 * @param array $terminal  Terminal response envelope.
	 * @param array $telemetry Aggregated `{ time, rounds, scopes, tools }` accumulator.
	 *
	 * @return array The envelope, with `processingData` added when it is a success.
	 */
	private function attach_processing_data( array $terminal, array $telemetry ): array {

		if ( ( $terminal['status'] ?? '' ) !== 'success' ) {
			return $terminal;
		}

		$terminal['data']['processingData'] = [
			'time'   => number_format( (float) ( $telemetry['time'] ?? 0 ), 3, '.', '' ),
			'rounds' => array_values( (array) $telemetry['rounds'] ),
			'scopes' => array_values( (array) $telemetry['scopes'] ),
			'tools'  => array_values( (array) $telemetry['tools'] ),
		];

		return $terminal;
	}
}