Viewing File: /home/rtsgrob/ww5/wp-content/plugins/learnpress/inc/AI/Assistant/ResponseNormalizer.php

<?php

namespace LearnPress\AI\Assistant;

/**
 * ResponseNormalizer — decodes JSON model output and extracts human-readable text.
 *
 * Handles the common pattern where OpenAI returns JSON-wrapped text such as
 * {"message":"..."} and extracts the best text candidate for frontend rendering.
 *
 * @package LearnPress\AI\Assistant
 * @since 4.3.5
 */
class ResponseNormalizer {

	/**
	 * Decode JSON content safely without throwing exceptions.
	 *
	 * Accepts either pure JSON or content containing a JSON object substring.
	 * Returns empty array when content is plain text or invalid JSON.
	 *
	 * @param string $content Raw model content.
	 *
	 * @return array
	 */
	public function decode_json( string $content ): array {
		$content = trim( $content );
		if ( '' === $content ) {
			return array();
		}

		$decoded = json_decode( $content, true );
		if ( is_array( $decoded ) && JSON_ERROR_NONE === json_last_error() ) {
			return $decoded;
		}

		$first_brace = strpos( $content, '{' );
		$last_brace  = strrpos( $content, '}' );
		if ( false === $first_brace || false === $last_brace || $last_brace <= $first_brace ) {
			return array();
		}

		$json_slice = substr( $content, $first_brace, $last_brace - $first_brace + 1 );
		if ( ! is_string( $json_slice ) || '' === $json_slice ) {
			return array();
		}

		$decoded = json_decode( $json_slice, true );

		return ( is_array( $decoded ) && JSON_ERROR_NONE === json_last_error() ) ? $decoded : array();
	}

	/**
	 * Convert model JSON output into readable assistant text.
	 *
	 * @param string $content Raw model content.
	 *
	 * @return string
	 */
	public function normalize( string $content ): string {
		$content = trim( $content );
		if ( '' === $content ) {
			return '';
		}

		$decoded = $this->decode_json( $content );
		if ( empty( $decoded ) ) {
			if ( $this->looks_like_metadata_value( $content ) ) {
				return __( 'I could not generate a complete answer. Please try rephrasing your request.', 'learnpress' );
			}

			return $content;
		}

		$extracted = $this->extract_text_from_array( $decoded );

		if ( '' !== $extracted ) {
			return $extracted;
		}

		return $this->looks_like_metadata_value( $content )
			? __( 'I could not generate a complete answer. Please try rephrasing your request.', 'learnpress' )
			: $content;
	}

	/**
	 * Build normalized response from assistant content.
	 *
	 * @param string $content The raw assistant text content.
	 *
	 * @return array{type: string, message: string, quiz: array|null}
	 */
	public function build_response( string $content ): array {
		return array(
			'type'    => 'text',
			'message' => $this->normalize( $content ),
			'quiz'    => null,
		);
	}

	/**
	 * Extract first meaningful text value from a decoded JSON object.
	 *
	 * @param array $data Decoded JSON.
	 *
	 * @return string
	 */
	private function extract_text_from_array( array $data ): string {
		$preferred_keys = array(
			'message',
			'answer',
			'content',
			'summary',
			'explanation',
			'text',
			'response',
			'result',
			'key_points',
			'main_points',
			'bullet_points',
		);

		foreach ( $preferred_keys as $key ) {
			if ( empty( $data[ $key ] ) ) {
				continue;
			}

			$value = $data[ $key ];
			if ( is_string( $value ) && '' !== trim( $value ) ) {
				return trim( $value );
			}

			if ( is_array( $value ) ) {
				$nested = $this->extract_text_from_array( $value );
				if ( '' !== $nested ) {
					return $nested;
				}
			}
		}

		foreach ( $data as $key => $value ) {
			if ( $this->is_metadata_text_key( (string) $key ) ) {
				continue;
			}

			if ( is_array( $value ) ) {
				$nested = $this->extract_text_from_array( $value );
				if ( '' !== $nested ) {
					return $nested;
				}
			}
		}

		foreach ( $data as $key => $value ) {
			if ( $this->is_metadata_text_key( (string) $key ) || ! is_string( $value ) ) {
				continue;
			}

			$candidate = trim( $value );
			if ( '' === $candidate || $this->looks_like_metadata_value( $candidate ) ) {
				continue;
			}

			if ( $this->is_substantial_text( $candidate ) ) {
				return $candidate;
			}
		}

		return '';
	}

	/**
	 * Determine whether a decoded JSON key is metadata and should not be shown as answer text.
	 *
	 * @param string $key JSON key.
	 *
	 * @return bool
	 */
	private function is_metadata_text_key( string $key ): bool {

		$normalized = strtolower( trim( $key ) );
		if ( $normalized === '' ) {
			return false;
		}

		return in_array(
			$normalized,
			array(
				'language',
				'locale',
				'intent',
				'action',
				'type',
				'status',
				'model',
				'provider',
				'metadata',
			),
			true
		);
	}

	/**
	 * Check whether a text candidate looks like metadata output.
	 *
	 * @param string $value Text candidate.
	 *
	 * @return bool
	 */
	private function looks_like_metadata_value( string $value ): bool {

		$trimmed = trim( $value );
		if ( $trimmed === '' ) {
			return true;
		}

		if ( preg_match( '/^(language|locale|intent|action|type|status)\s*[:=]\s*[\w\-]+$/ui', $trimmed ) ) {
			return true;
		}

		return preg_match( '/^[a-z]{2}[-_][a-z]{2}$/i', $trimmed ) === 1;
	}

	/**
	 * Determine whether candidate text is substantial enough for user-facing response.
	 *
	 * @param string $value Text candidate.
	 *
	 * @return bool
	 */
	private function is_substantial_text( string $value ): bool {

		$trimmed = trim( $value );
		if ( $trimmed === '' ) {
			return false;
		}

		if ( preg_match( '/[.!?。!?]/u', $trimmed ) ) {
			return true;
		}

		$length = mb_strlen( $trimmed, 'UTF-8' );
		if ( $length >= 20 ) {
			return true;
		}

		return $length >= 14 && preg_match( '/\s/u', $trimmed ) === 1;
	}
}
Back to Directory File Manager