<?php
/**
* Ollama integration
*/
namespace Classifai\Providers\Localhost;
use Classifai\Providers\Provider;
use Classifai\Providers\OpenAI\APIRequest;
use Classifai\Features\ContentResizing;
use Classifai\Features\ExcerptGeneration;
use Classifai\Features\TitleGeneration;
use Classifai\Features\KeyTakeaways;
use Classifai\Normalizer;
use WP_Error;
use function Classifai\get_default_prompt;
/**
* Ollama class
*/
class Ollama extends Provider {
/**
* The Provider ID.
*/
const ID = 'ollama';
/**
* Ollama constructor.
*
* @param \Classifai\Features\Feature $feature_instance The feature instance.
*/
public function __construct( $feature_instance = null ) {
$this->feature_instance = $feature_instance;
}
/**
* Returns the default settings for this provider.
*
* @return array
*/
public function get_default_provider_settings(): array {
$common_settings = [
'endpoint_url' => 'http://localhost:11434/',
'authenticated' => false,
'model' => '',
'models' => [],
];
return $common_settings;
}
/**
* Sanitize the settings for this provider.
*
* @param array $new_settings The settings array.
* @return array
*/
public function sanitize_settings( array $new_settings ): array {
$settings = $this->feature_instance->get_settings();
$credentials_changed = false;
$new_settings[ static::ID ]['authenticated'] = $settings[ static::ID ]['authenticated'];
if ( ! empty( $new_settings[ static::ID ]['endpoint_url'] ) ) {
$new_url = trailingslashit( esc_url_raw( $new_settings[ static::ID ]['endpoint_url'] ) );
$new_settings[ static::ID ]['models'] = $this->get_models(
[
'endpoint_url' => $new_url,
]
);
// Check to see if credentials have changed.
if ( $new_url !== $settings[ static::ID ]['endpoint_url'] || ! $new_settings[ static::ID ]['authenticated'] ) {
$credentials_changed = true;
}
// If they have changed, make a request to get models and ensure the connection works.
if ( $credentials_changed ) {
$new_settings[ static::ID ]['endpoint_url'] = $new_url;
if ( ! empty( $new_settings[ static::ID ]['models'] ) ) {
$new_settings[ static::ID ]['authenticated'] = true;
} else {
$new_settings[ static::ID ]['models'] = [];
$new_settings[ static::ID ]['authenticated'] = false;
}
}
} else {
$new_settings[ static::ID ]['endpoint_url'] = $settings[ static::ID ]['endpoint_url'];
add_settings_error(
$this->feature_instance->get_option_name(),
'classifai-auth-empty',
esc_html__( 'Please enter a valid endpoint URL in order to connect.', 'classifai' ),
'error'
);
}
$new_settings[ static::ID ]['model'] = sanitize_text_field( $new_settings[ static::ID ]['model'] ?? $settings[ static::ID ]['model'] );
return $new_settings;
}
/**
* Connects to Ollama and retrieves supported models.
*
* @param array $args Overridable args.
* @return array
*/
public function get_models( array $args = [] ): array {
$settings = $this->feature_instance->get_settings( static::ID );
$default = [
'endpoint_url' => $settings[ static::ID ]['endpoint_url'] ?? '',
];
$default = wp_parse_args( $args, $default );
// Return if credentials don't exist.
if ( empty( $default['endpoint_url'] ) ) {
return [];
}
// Make our request.
$request = new APIRequest( 'test' );
$response = $request->get(
$this->get_api_model_url( $default['endpoint_url'] ),
[
'timeout' => 30, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout
]
);
if ( is_wp_error( $response ) ) {
add_settings_error(
$this->feature_instance->get_option_name(),
'ollama-request-failed',
sprintf(
/* translators: %s is replaced with the error message */
esc_html__( 'Error making request, please ensure the Ollama service is running: %s', 'classifai' ),
$response->get_error_message()
),
'error'
);
return [];
}
$sanitized_models = [];
if ( is_array( $response['models'] ) ) {
foreach ( $response['models'] as $model ) {
$sanitized_models[ $model['model'] ] = $model['name'];
}
}
return $sanitized_models;
}
/**
* Generate an excerpt.
*
* @param int $post_id The Post ID we're processing
* @param array $args Arguments passed in.
* @return string|WP_Error
*/
public function generate_excerpt( int $post_id = 0, array $args = [] ) {
if ( ! $post_id || ! get_post( $post_id ) ) {
return new WP_Error( 'post_id_required', esc_html__( 'A valid post ID is required to generate an excerpt.', 'classifai' ) );
}
$feature = new ExcerptGeneration();
$settings = $feature->get_settings();
$args = wp_parse_args(
array_filter( $args ),
[
'content' => '',
'title' => get_the_title( $post_id ),
]
);
// These checks (and the one above) happen in the REST permission_callback,
// but we run them again here in case this method is called directly.
if ( empty( $settings ) || ( isset( $settings[ static::ID ]['authenticated'] ) && false === $settings[ static::ID ]['authenticated'] ) || ( ! $feature->is_feature_enabled() && ( ! defined( 'WP_CLI' ) || ! WP_CLI ) ) ) {
return new WP_Error( 'not_enabled', esc_html__( 'Excerpt generation is disabled or Ollama authentication failed. Please check your settings.', 'classifai' ) );
}
$excerpt_length = absint( $settings['length'] ?? 55 );
$excerpt_prompt = esc_textarea( get_default_prompt( $settings['generate_excerpt_prompt'] ) ?? $feature->prompt );
// Replace our variables in the prompt.
$prompt_search = array( '{{WORDS}}', '{{TITLE}}' );
$prompt_replace = array( $excerpt_length, $args['title'] );
$prompt = str_replace( $prompt_search, $prompt_replace, $excerpt_prompt );
/**
* Filter the prompt we will send to Ollama.
*
* @since 3.3.0
* @hook classifai_ollama_excerpt_prompt
*
* @param {string} $prompt Prompt we are sending to Ollama. Gets added before post content.
* @param {int} $post_id ID of post.
* @param {int} $excerpt_length Length of final excerpt.
*
* @return {string} Prompt.
*/
$prompt = apply_filters( 'classifai_ollama_excerpt_prompt', $prompt, $post_id, $excerpt_length );
/**
* Filter the request body before sending to Ollama.
*
* @since 3.3.0
* @hook classifai_ollama_excerpt_request_body
*
* @param {array} $body Request body that will be sent to Ollama.
* @param {int} $post_id ID of post.
*
* @return {array} Request body.
*/
$body = apply_filters(
'classifai_ollama_excerpt_request_body',
[
'model' => $settings[ static::ID ]['model'] ?? '',
'messages' => [
[
'role' => 'system',
'content' => 'You will be provided with content delimited by triple quotes. ' . $prompt,
],
[
'role' => 'user',
'content' => '"""' . $this->get_content( $post_id, false, $args['content'] ) . '"""',
],
],
'stream' => false,
],
$post_id
);
// Make our API request.
$request = new APIRequest( 'test' );
$response = $request->post(
$this->get_api_chat_url( $settings[ static::ID ]['endpoint_url'] ?? '' ),
[
'body' => wp_json_encode( $body ),
]
);
if ( is_wp_error( $response ) ) {
return $response;
}
// If we have a message, return it.
$return = '';
if ( isset( $response['message'], $response['message']['content'] ) ) {
$return = sanitize_text_field( trim( $response['message']['content'], ' "\'' ) );
}
return $return;
}
/**
* Generate a title.
*
* @param int $post_id The Post ID we're processing
* @param array $args Arguments passed in.
* @return string|WP_Error
*/
public function generate_title( int $post_id = 0, array $args = [] ) {
if ( ! $post_id || ! get_post( $post_id ) ) {
return new WP_Error( 'post_id_required', esc_html__( 'Post ID is required to generate titles.', 'classifai' ) );
}
$feature = new TitleGeneration();
$settings = $feature->get_settings();
$args = wp_parse_args(
array_filter( $args ),
[
'content' => '',
]
);
// These checks happen in the REST permission_callback,
// but we run them again here in case this method is called directly.
if ( empty( $settings ) || ( isset( $settings[ static::ID ]['authenticated'] ) && false === $settings[ static::ID ]['authenticated'] ) || ! $feature->is_feature_enabled() ) {
return new WP_Error( 'not_enabled', esc_html__( 'Title generation is disabled or Ollama authentication failed. Please check your settings.', 'classifai' ) );
}
/**
* Filter the prompt we will send to Ollama.
*
* @since 3.3.0
* @hook classifai_ollama_title_prompt
*
* @param {string} $prompt Prompt we are sending to Ollama. Gets added before post content.
* @param {int} $post_id ID of post.
* @param {array} $args Arguments passed to endpoint.
*
* @return {string} Prompt.
*/
$prompt = apply_filters( 'classifai_ollama_title_prompt', esc_textarea( get_default_prompt( $settings['generate_title_prompt'] ) ?? $feature->prompt ), $post_id, $args );
/**
* Filter the request body before sending to Ollama.
*
* @since 3.3.0
* @hook classifai_ollama_title_request_body
*
* @param {array} $body Request body that will be sent to Ollama.
* @param {int} $post_id ID of post.
*
* @return {array} Request body.
*/
$body = apply_filters(
'classifai_ollama_title_request_body',
[
'model' => $settings[ static::ID ]['model'] ?? '',
'messages' => [
[
'role' => 'system',
'content' => 'You will be provided with content delimited by triple quotes. ' . $prompt,
],
[
'role' => 'user',
'content' => '"""' . $this->get_content( $post_id, false, $args['content'] ) . '"""',
],
],
'stream' => false,
],
$post_id
);
// Make our API request.
$request = new APIRequest( 'test' );
$response = $request->post(
$this->get_api_chat_url( $settings[ static::ID ]['endpoint_url'] ?? '' ),
[
'body' => wp_json_encode( $body ),
]
);
if ( is_wp_error( $response ) ) {
return $response;
}
// If we have a message, return it.
$return = [];
if ( isset( $response['message'], $response['message']['content'] ) ) {
$return[] = sanitize_text_field( trim( $response['message']['content'], ' "\'' ) );
}
return $return;
}
/**
* Resizes content.
*
* @param int $post_id The Post ID we're processing
* @param array $args Arguments passed in.
* @return string|WP_Error
*/
public function resize_content( int $post_id, array $args = array() ) {
if ( ! $post_id || ! get_post( $post_id ) ) {
return new WP_Error( 'post_id_required', esc_html__( 'Post ID is required to resize content.', 'classifai' ) );
}
$feature = new ContentResizing();
$settings = $feature->get_settings();
$args = wp_parse_args(
array_filter( $args ),
[
'content' => '',
]
);
if ( 'shrink' === $args['resize_type'] ) {
$prompt = esc_textarea( get_default_prompt( $settings['condense_text_prompt'] ) ?? $feature->condense_prompt );
} else {
$prompt = esc_textarea( get_default_prompt( $settings['expand_text_prompt'] ) ?? $feature->expand_prompt );
}
/**
* Filter the resize prompt we will send to Ollama.
*
* @since 3.3.0
* @hook classifai_ollama_' . $args['resize_type'] . '_content_prompt
*
* @param {string} $prompt Resize prompt we are sending to Ollama. Gets added as a system prompt.
* @param {int} $post_id ID of post.
* @param {array} $args Arguments passed to endpoint.
*
* @return {string} Prompt.
*/
$prompt = apply_filters( 'classifai_ollama_' . $args['resize_type'] . '_content_prompt', $prompt, $post_id, $args );
/**
* Filter the resize request body before sending to Ollama.
*
* @since 3.3.0
* @hook classifai_ollama_resize_content_request_body
*
* @param {array} $body Request body that will be sent to Ollama.
* @param {int} $post_id ID of post.
*
* @return {array} Request body.
*/
$body = apply_filters(
'classifai_ollama_resize_content_request_body',
[
'model' => $settings[ static::ID ]['model'] ?? '',
'messages' => [
[
'role' => 'system',
'content' => 'You will be provided with content delimited by triple quotes. ' . $prompt,
],
[
'role' => 'user',
'content' => '"""' . esc_html( $args['content'] ) . '"""',
],
],
'stream' => false,
],
$post_id
);
// Make our API request.
$request = new APIRequest( 'test' );
$response = $request->post(
$this->get_api_chat_url( $settings[ static::ID ]['endpoint_url'] ?? '' ),
[
'body' => wp_json_encode( $body ),
]
);
if ( is_wp_error( $response ) ) {
return $response;
}
// If we have a message, return it.
$return = [];
if ( isset( $response['message'], $response['message']['content'] ) ) {
$return[] = sanitize_text_field( trim( $response['message']['content'], ' "\'' ) );
}
return $return;
}
/**
* Generate key takeaways from content.
*
* @param int $post_id The Post ID we're processing
* @param array $args Arguments passed in.
* @return string|WP_Error
*/
public function generate_key_takeaways( int $post_id = 0, array $args = [] ) {
if ( ! $post_id || ! get_post( $post_id ) ) {
return new WP_Error( 'post_id_required', esc_html__( 'A valid post ID is required to generate key takeaways.', 'classifai' ) );
}
$feature = new KeyTakeaways();
$settings = $feature->get_settings();
$args = wp_parse_args(
array_filter( $args ),
[
'content' => '',
'title' => get_the_title( $post_id ),
'render' => 'list',
]
);
// These checks (and the one above) happen in the REST permission_callback,
// but we run them again here in case this method is called directly.
if ( empty( $settings ) || ( isset( $settings[ static::ID ]['authenticated'] ) && false === $settings[ static::ID ]['authenticated'] ) || ( ! $feature->is_feature_enabled() && ( ! defined( 'WP_CLI' ) || ! WP_CLI ) ) ) {
return new WP_Error( 'not_enabled', esc_html__( 'Key Takeaways generation is disabled or authentication failed. Please check your settings.', 'classifai' ) );
}
$prompt = esc_textarea( get_default_prompt( $settings['key_takeaways_prompt'] ) ?? $feature->prompt );
// Replace our variables in the prompt.
$prompt_search = array( '{{TITLE}}' );
$prompt_replace = array( $args['title'] );
$prompt = str_replace( $prompt_search, $prompt_replace, $prompt );
/**
* Filter the prompt we will send to Ollama.
*
* @since 3.3.0
* @hook classifai_ollama_key_takeaways_prompt
*
* @param {string} $prompt Prompt we are sending to Ollama. Gets added before post content.
* @param {int} $post_id ID of post we are summarizing.
*
* @return {string} Prompt.
*/
$prompt = apply_filters( 'classifai_ollama_key_takeaways_prompt', $prompt, $post_id );
/**
* Filter the request body before sending to Ollama.
*
* @since 3.3.0
* @hook classifai_ollama_key_takeaways_request_body
*
* @param {array} $body Request body that will be sent to Ollama.
* @param {int} $post_id ID of post we are summarizing.
*
* @return {array} Request body.
*/
$body = apply_filters(
'classifai_ollama_key_takeaways_request_body',
[
'model' => $settings[ static::ID ]['model'] ?? '',
'messages' => [
[
'role' => 'system',
'content' => 'You will be provided with content delimited by triple quotes. Ensure the response you return is valid JSON, in the structure {"takeaways":["first","second"]}. ' . $prompt,
],
[
'role' => 'user',
'content' => '"""' . $this->get_content( $post_id, false, $args['content'] ) . '"""',
],
],
'format' => 'json',
'stream' => false,
],
$post_id
);
// Make our API request.
$request = new APIRequest( 'test' );
$response = $request->post(
$this->get_api_chat_url( $settings[ static::ID ]['endpoint_url'] ?? '' ),
[
'body' => wp_json_encode( $body ),
]
);
if ( is_wp_error( $response ) ) {
return $response;
}
// Parse out the response and return it.
if ( isset( $response['message'], $response['message']['content'] ) ) {
$takeaways = json_decode( $response['message']['content'], true );
if ( isset( $takeaways['takeaways'] ) && is_array( $takeaways['takeaways'] ) ) {
$response = array_map(
function ( $takeaway ) {
return sanitize_text_field( trim( $takeaway, ' "\'' ) );
},
$takeaways['takeaways']
);
} else {
return new WP_Error( 'refusal', esc_html__( 'Ollama request failed', 'classifai' ) );
}
} else {
return new WP_Error( 'refusal', esc_html__( 'Ollama request failed', 'classifai' ) );
}
return $response;
}
/**
* Common entry point for all REST endpoints for this provider.
*
* @param int $post_id The post ID we're processing.
* @param string $route_to_call The name of the route we're going to be processing.
* @param array $args Optional arguments to pass to the route.
* @return array|string|WP_Error
*/
public function rest_endpoint_callback( $post_id, string $route_to_call = '', array $args = [] ) {
if ( ! $post_id || ! get_post( $post_id ) ) {
return new WP_Error( 'post_id_required', esc_html__( 'A valid post ID is required.', 'classifai' ) );
}
$route_to_call = strtolower( $route_to_call );
$return = '';
// Handle all of our routes.
switch ( $route_to_call ) {
case 'excerpt':
$return = $this->generate_excerpt( $post_id, $args );
break;
case 'title':
$return = $this->generate_title( $post_id, $args );
break;
case 'resize_content':
$return = $this->resize_content( $post_id, $args );
break;
case 'key_takeaways':
$return = $this->generate_key_takeaways( $post_id, $args );
break;
}
return $return;
}
/**
* Get our content.
*
* @param int $post_id Post ID to get content from.
* @param bool $use_title Whether to use the title or not.
* @param string $post_content The post content.
* @return string
*/
public function get_content( int $post_id = 0, bool $use_title = true, string $post_content = '' ): string {
$normalizer = new Normalizer();
if ( empty( $post_content ) ) {
$post = get_post( $post_id );
$post_content = apply_filters( 'the_content', $post->post_content );
}
$post_content = preg_replace( '#\[.+\](.+)\[/.+\]#', '$1', $post_content );
if ( $use_title ) {
$content = $normalizer->normalize( $post_id, $post_content );
} else {
$content = $normalizer->normalize_content( $post_content, '', $post_id );
}
/**
* Filter content that will get sent to Ollama.
*
* @since 3.3.0
* @hook classifai_ollama_content
*
* @param {string} $content Content that will be sent to Ollama.
* @param {int} $post_id ID of post.
*
* @return {string} Content.
*/
return apply_filters( 'classifai_ollama_content', $content, $post_id );
}
/**
* Chunk content into smaller pieces with an overlap.
*
* @param string $content Content to chunk.
* @param int $chunk_size Size of each chunk, in words.
* @param int $overlap_size Overlap size for each chunk, in words.
* @return array
*/
public function chunk_content( string $content = '', int $chunk_size = 150, $overlap_size = 25 ): array {
// Remove multiple whitespaces.
$content = preg_replace( '/\s+/', ' ', $content );
// Split text by single whitespace.
$words = explode( ' ', $content );
$chunks = [];
$text_count = count( $words );
// Iterate through and chunk data with an overlap.
for ( $i = 0; $i < $text_count; $i += $chunk_size ) {
// Join a set of words into a string.
$chunk = implode(
' ',
array_slice(
$words,
max( $i - $overlap_size, 0 ),
$chunk_size + $overlap_size
)
);
array_push( $chunks, $chunk );
}
return $chunks;
}
/**
* Builds the API Model URL.
*
* @param string $endpoint_url The endpoint URL.
* @return string
*/
public function get_api_model_url( string $endpoint_url ): string {
return sprintf( '%s%s', trailingslashit( $endpoint_url ), 'api/tags' );
}
/**
* Builds the API Chat URL.
*
* @param string $endpoint_url The endpoint URL.
* @return string
*/
public function get_api_chat_url( string $endpoint_url ): string {
return sprintf( '%s%s', trailingslashit( $endpoint_url ), 'api/chat' );
}
/**
* Builds the API Embeddings URL.
*
* @param string $endpoint_url The endpoint URL.
* @return string
*/
public function get_api_embeddings_url( string $endpoint_url ): string {
return sprintf( '%s%s', trailingslashit( $endpoint_url ), 'api/embed' );
}
/**
* Builds the API Generate URL.
*
* @param string $endpoint_url The endpoint URL.
* @return string
*/
public function get_api_generate_url( string $endpoint_url ): string {
return sprintf( '%s%s', trailingslashit( $endpoint_url ), 'api/generate' );
}
}