/home/edulekha/crm.edulekha.com/modules/openai/src/OpenAiProvider.php
<?php

namespace Perfexcrm\Openai;

use app\services\ai\Contracts\AiProviderInterface;
use Exception;
use League\HTMLToMarkdown\Converter\TableConverter;
use League\HTMLToMarkdown\HtmlConverter;
use OpenAI;

defined('BASEPATH') or exit('No direct script access allowed');

class OpenAiProvider implements AiProviderInterface
{
    private string $model;
    private string $systemPrompt;
    private OpenAI\Client $client;
    private int $maxToken;
    private bool $useFineTuning;
    private string $fineTunedModel;
    private string $fineTuningBaseModel;
    private static ?array $fineTunedModels = null;

    /**
     * OpenAiProvider constructor.
     */
    public function __construct()
    {
        $this->model               = get_option('openai_model');
        $this->systemPrompt        = get_option('ai_system_prompt');
        $this->maxToken            = intval(get_option('openai_max_token'));
        $this->useFineTuning       = get_option('openai_use_fine_tuning') == '1';
        $this->fineTunedModel      = get_option('openai_fine_tuned_model') ?: '';
        $this->fineTuningBaseModel = get_option('openai_fine_tuning_base_model') ?: static::defaultFineTuningModel();

        $this->client = OpenAI::factory()
            ->withApiKey(get_option('openai_api_key'))
            ->withHttpHeader('Content-Type', 'application/json')
            ->withHttpHeader('Accept', 'application/json')
            ->make();
    }

    /**
     * Get the list of available models for selection.
     */
    public static function getModels(): array
    {
        return hooks()->apply_filters('openai_models', [
            [
                'id'   => 'gpt-3.5-turbo',
                'name' => 'GPT-3.5 Turbo',
            ],
            [
                'id'   => 'gpt-4o',
                'name' => 'GPT-4o',
            ],
            [
                'id'   => 'gpt-4o-mini',
                'name' => 'GPT-4o Mini',
            ],
            [
                'id'   => 'o1-mini',
                'name' => 'o1 Mini',
            ],
        ]);
    }

    /**
     * Get the default fine-tuning model.
     */
    public static function defaultFineTuningModel(): string
    {
        return 'gpt-4o-mini-2024-07-18';
    }

    /**
     * Get the list of fine-tuning models available for selection.
     */
    public static function getFineTuningModels(): array
    {
        return hooks()->apply_filters('openai_fine_tuning_models', [
            [
                'id'   => 'gpt-4.1-2025-04-14',
                'name' => 'GPT-4.1 (2025-04-14)',
            ],
            [
                'id'   => 'gpt-4.1-mini-2025-04-14',
                'name' => 'GPT-4.1 Mini (2025-04-14)',
            ],
            [
                'id'   => 'gpt-4o-2024-08-06',
                'name' => 'GPT-4o (2024-08-06)',
            ],
            [
                'id'   => 'gpt-4o-mini-2024-07-18',
                'name' => 'GPT-4o Mini (2024-07-18)',
            ],
            [
                'id'   => 'gpt-4-0613',
                'name' => 'GPT-4 (0613)',
            ],
            [
                'id'   => 'gpt-3.5-turbo-0125',
                'name' => 'GPT-3.5 Turbo (0125)',
            ],
            [
                'id'   => 'gpt-3.5-turbo-1106',
                'name' => 'GPT-3.5 Turbo (1106)',
            ],
            [
                'id'   => 'gpt-3.5-turbo-0613',
                'name' => 'GPT-3.5 Turbo (0613)',
            ],
        ]);
    }

    /**
     * Get the name of the AI provider.
     */
    public function getName(): string
    {
        return 'OpenAI';
    }

    /**
     * Initiate a chat with the OpenAI API using the provided prompt.
     *
     * @param mixed $prompt
     */
    public function chat($prompt): string
    {
        // If fine-tuning is enabled and we have a fine-tuned model, use it
        $model = $this->useFineTuning && ! empty($this->fineTunedModel)
            ? $this->fineTunedModel
            : $this->model;

        $response = $this->client->chat()->create([
            'model'    => $model,
            'store'    => true,
            'messages' => [
                ['role' => 'developer', 'content' => $this->systemPrompt],
                ['role' => 'user', 'content' => $prompt],
            ],
            'max_tokens' => $this->maxToken,
        ]);

        return rtrim(ltrim($response->choices[0]->message->content ?? '', '```html'), '```');
    }

    /**
     * Create a fine-tuning job using the knowledge base content.
     *
     * @param array{title: string, content: string} $trainingData
     */
    public function createFineTuningJob(array $trainingData): ?string
    {
        if (empty($trainingData)) {
            return null;
        }

        $this->deletePreviousFineTunedModel();

        try {
            // Format knowledge base data into training examples
            $trainingData = $this->formatTrainingData($trainingData);

            // Create a temporary file with the training data
            $tempFilePath = sys_get_temp_dir() . '/pcrm_training_' . time() . '.jsonl';
            file_put_contents($tempFilePath, $trainingData);

            // Create a file with the training data
            $fileResponse = $this->client->files()->upload([
                'purpose' => 'fine-tune',
                'file'    => fopen($tempFilePath, 'r'),
            ]);

            // Remove the temporary file
            @unlink($tempFilePath);

            // Start a fine-tuning job using the selected fine-tuning base model
            $fineTuningResponse = $this->client->fineTuning()->createJob([
                'training_file' => $fileResponse->id,
                'model'         => $this->fineTuningBaseModel,
                'suffix'        => 'pcrm-' . date('Ymd'),
            ]);

            // Save the fine-tuning job ID for later reference
            update_option('openai_fine_tuning_last_job_id', $fineTuningResponse->id);
            update_option('openai_last_fine_tuning_file_id', $fileResponse->id);

            return $fineTuningResponse->id;
        } catch (Exception $e) {
            log_activity('OpenAI Fine-tuning Error: ' . $e->getMessage());

            if (isset($tempFilePath)) {
                // Clean up the temporary file if it exists
                @unlink($tempFilePath);
            }

            return null;
        }
    }

    /**
     * Format knowledge base data into the required training format for fine-tuning.
     */
    private function formatTrainingData(array $trainingData): string
    {
        $formattedData = [];

        foreach ($trainingData as $item) {
            $formattedData[] = [
                'messages' => [
                    ['role' => 'system', 'content' => $this->systemPrompt],
                    ['role' => 'user', 'content' => 'Tell me about ' . $item['title']],
                    ['role' => 'assistant', 'content' => $item['content']],
                ],
            ];
        }

        $formattedData = hooks()->apply_filters(
            'openai_fine_tuning_training_data',
            $formattedData,
            $this->systemPrompt
        );

        foreach ($formattedData as $key => $data) {
            if (empty($data)) {
                unset($formattedData[$key]);
            } else {
                $formattedData[$key] = json_encode($data, JSON_UNESCAPED_UNICODE);
            }
        }

        return implode("\n", array_values($formattedData));
    }

    /**
     * Retrieve the fine-tuning job.
     */
    public function retrieveFineTuningJob(string $jobId): array
    {
        $job = $this->client->fineTuning()->retrieveJob($jobId);

        return [
            'status'      => $job->status,
            'model'       => $job->fineTunedModel ?? null,
            'created_at'  => $job->createdAt,
            'finished_at' => $job->finishedAt ?? null,
            'error'       => $job->error ?? null,
        ];
    }

    /**
     * Check the status of a fine-tuning job.
     */
    public function checkFineTuningStatus(string $jobId): array
    {
        try {
            $job = $this->retrieveFineTuningJob($jobId);

            if ($job['status'] === 'succeeded' && ! empty($job['model']) && empty($this->fineTunedModel)) {
                // If the job completed successfully, save the fine-tuned model ID
                update_option('openai_fine_tuned_model', $job['model']);
                update_option('openai_use_fine_tuning', '1');
            }

            return $job;
        } catch (Exception $e) {
            return [
                'status' => 'error',
                'error'  => $e->getMessage(),
            ];
        }
    }

    /**
     * List all fine-tuned models for the current account.
     */
    public function getFineTunedModels(): array
    {
        if (! is_null(static::$fineTunedModels)) {
            return static::$fineTunedModels;
        }

        try {
            $response        = $this->client->models()->list();
            $fineTunedModels = [];

            foreach ($response->data as $model) {
                // Fine-tuned models typically have a format like ft:gpt-3.5-turbo:org:custom_suffix:id
                if (strpos($model->id, 'ft:') === 0) {
                    $fineTunedModels[] = [
                        'id'         => $model->id,
                        'created_at' => $model->created,
                        'owned_by'   => $model->ownedBy,
                        'is_our'     => static::isOurFineTunedModel($model->id),
                    ];
                }
            }

            // Sort by id that contains "pcrm-", should be considered on top, and sorted by created_at
            usort($fineTunedModels, function ($a, $b) {
                if (strpos($a['id'], 'pcrm-') !== false && strpos($b['id'], 'pcrm-') === false) {
                    return -1;
                }
                if (strpos($a['id'], 'pcrm-') === false && strpos($b['id'], 'pcrm-') !== false) {
                    return 1;
                }

                return $b['created_at'] <=> $a['created_at'];
            });

            return static::$fineTunedModels = $fineTunedModels;
        } catch (Exception $e) {
            return [];
        }
    }

    /**
     * Get the list of fine-tuned models that belong to us.
     */
    public function getOurFineTunedModels(): array
    {
        $fineTunedModels    = $this->getFineTunedModels();
        $ourFineTunedModels = [];

        foreach ($fineTunedModels as $model) {
            if ($model['is_our']) {
                $ourFineTunedModels[] = $model;
            }
        }

        return $ourFineTunedModels;
    }

    /**
     * Delete a fine-tuned model.
     */
    public function deleteFineTunedModel(string $modelId): bool
    {
        if (! static::isOurFineTunedModel($modelId)) {
            return false;
        }

        try {
            $this->client->models()->delete($modelId);

            // If we deleted the currently selected fine-tuned model, reset the settings
            if ($this->fineTunedModel === $modelId) {
                update_option('openai_fine_tuned_model', '');
                update_option('openai_use_fine_tuning', '0');
            }

            if (get_option('openai_our_fine_tuned_model') === $modelId) {
                update_option('openai_our_fine_tuned_model', '');

                $fileId = get_option('openai_last_fine_tuning_file_id');
                if (! empty($fileId)) {
                    try {
                        $this->client->files()->delete($fileId);
                    } catch (Exception $e) {
                        log_activity('OpenAI Fine-tuned Model File Deletion Error: ' . $e->getMessage());
                    }
                }
            }

            return true;
        } catch (Exception $e) {
            log_activity('OpenAI Fine-tuned Model Deletion Error: ' . $e->getMessage());

            return false;
        }
    }

    /**
     * Delete all previous fine-tuned models that belong to us.
     */
    protected function deletePreviousFineTunedModel(): void
    {
        $fineTunedModels = $this->getOurFineTunedModels();

        foreach ($fineTunedModels as $model) {
            $this->deleteFineTunedModel($model['id']);
        }
    }

    /**
     * Check if the model is a fine-tuned model that belongs to us.
     */
    public static function isOurFineTunedModel(string $model): bool
    {
        return strpos($model, 'pcrm-') !== false;
    }

    /**
     * Enhance the given text to be more polite, formal, or friendly.
     */
    public function enhanceText(string $text, string $enhancementType): string
    {
        $converter = new HtmlConverter();
        $converter->getConfig()->setOption('strip_tags', true);
        $converter->getEnvironment()->addConverter(new TableConverter());

        $prompt = <<<TICKET
                    Enhance the following text to be more {$enhancementType}. Only return the enhanced text without any explanations or introductions, the text should be TinyMCE 6 compatible HTML format:\n\n
                
                    {$converter->convert($text)}
            TICKET;

        $result = $this->chat(
            hooks()->apply_filters('before_ai_tickets_enhance_text', $prompt, $text, $enhancementType)
        );

        // Do not wrap the output in a `<p>` tag if the input is a single paragraph or a partial sentence.
        // Return plain inline HTML in those cases.
        if (startsWith($result, '<p>') && endsWith($result, '</p>') && substr_count($result, '<p>') === 1) {
            $result = strip_tags($result, '<strong><em><u><span><a><b><i>');
        }

        return $result;
    }
}