1<?php 2 3use dokuwiki\Extension\CLIPlugin; 4use dokuwiki\plugin\aichat\Model\AbstractModel; 5use dokuwiki\plugin\aichat\Chunk; 6use dokuwiki\plugin\aichat\Embeddings; 7use dokuwiki\plugin\aichat\Model\OpenAI\GPT35Turbo; 8use dokuwiki\plugin\aichat\Storage\AbstractStorage; 9use dokuwiki\plugin\aichat\Storage\PineconeStorage; 10use dokuwiki\plugin\aichat\Storage\SQLiteStorage; 11 12require_once __DIR__ . '/vendor/autoload.php'; 13 14/** 15 * DokuWiki Plugin aichat (Helper Component) 16 * 17 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html 18 * @author Andreas Gohr <gohr@cosmocode.de> 19 */ 20class helper_plugin_aichat extends \dokuwiki\Extension\Plugin 21{ 22 /** @var CLIPlugin $logger */ 23 protected $logger; 24 /** @var AbstractModel */ 25 protected $model; 26 /** @var Embeddings */ 27 protected $embeddings; 28 /** @var AbstractStorage */ 29 protected $storage; 30 31 /** 32 * Use the given CLI plugin for logging 33 * 34 * @param CLIPlugin $logger 35 * @return void 36 */ 37 public function setLogger($logger) 38 { 39 $this->logger = $logger; 40 } 41 42 /** 43 * Check if the current user is allowed to use the plugin (if it has been restricted) 44 * 45 * @return bool 46 */ 47 public function userMayAccess() 48 { 49 global $auth; 50 global $USERINFO; 51 global $INPUT; 52 53 if (!$auth) return true; 54 if (!$this->getConf('restrict')) return true; 55 if (!isset($USERINFO)) return false; 56 57 return auth_isMember($this->getConf('restrict'), $INPUT->server->str('REMOTE_USER'), $USERINFO['grps']); 58 } 59 60 /** 61 * Access the OpenAI client 62 * 63 * @return GPT35Turbo 64 */ 65 public function getModel() 66 { 67 if ($this->model === null) { 68 $class = '\\dokuwiki\\plugin\\aichat\\Model\\' . $this->getConf('model'); 69 70 if (!class_exists($class)) { 71 throw new \RuntimeException('Configured model not found: ' . $class); 72 } 73 // FIXME for now we only have OpenAI models, so we can hardcode the auth setup 74 $this->model = new $class([ 75 'key' => $this->getConf('openaikey'), 76 'org' => $this->getConf('openaiorg') 77 ]); 78 } 79 80 return $this->model; 81 } 82 83 /** 84 * Access the Embeddings interface 85 * 86 * @return Embeddings 87 */ 88 public function getEmbeddings() 89 { 90 if ($this->embeddings === null) { 91 // FIXME we currently have only one storage backend, so we can hardcode it 92 $this->embeddings = new Embeddings($this->getModel(), $this->getStorage()); 93 if ($this->logger) { 94 $this->embeddings->setLogger($this->logger); 95 } 96 } 97 98 return $this->embeddings; 99 } 100 101 /** 102 * Access the Storage interface 103 * 104 * @return AbstractStorage 105 */ 106 public function getStorage() 107 { 108 if ($this->storage === null) { 109 if ($this->getConf('pinecone_apikey')) { 110 $this->storage = new PineconeStorage(); 111 } else { 112 $this->storage = new SQLiteStorage(); 113 } 114 115 if ($this->logger) { 116 $this->storage->setLogger($this->logger); 117 } 118 } 119 120 return $this->storage; 121 } 122 123 /** 124 * Ask a question with a chat history 125 * 126 * @param string $question 127 * @param array[] $history The chat history [[user, ai], [user, ai], ...] 128 * @return array ['question' => $question, 'answer' => $answer, 'sources' => $sources] 129 * @throws Exception 130 */ 131 public function askChatQuestion($question, $history = []) 132 { 133 if ($history) { 134 $standaloneQuestion = $this->rephraseChatQuestion($question, $history); 135 } else { 136 $standaloneQuestion = $question; 137 } 138 return $this->askQuestion($standaloneQuestion); 139 } 140 141 /** 142 * Ask a single standalone question 143 * 144 * @param string $question 145 * @return array ['question' => $question, 'answer' => $answer, 'sources' => $sources] 146 * @throws Exception 147 */ 148 public function askQuestion($question) 149 { 150 $similar = $this->getEmbeddings()->getSimilarChunks($question); 151 if ($similar) { 152 $context = implode("\n", array_map(function (Chunk $chunk) { 153 return "\n```\n" . $chunk->getText() . "\n```\n"; 154 }, $similar)); 155 $prompt = $this->getPrompt('question', ['context' => $context]); 156 } else { 157 $prompt = $this->getPrompt('noanswer'); 158 } 159 160 $messages = [ 161 [ 162 'role' => 'system', 163 'content' => $prompt 164 ], 165 [ 166 'role' => 'user', 167 'content' => $question 168 ] 169 ]; 170 171 $answer = $this->getModel()->getAnswer($messages); 172 173 return [ 174 'question' => $question, 175 'answer' => $answer, 176 'sources' => $similar, 177 ]; 178 } 179 180 /** 181 * Rephrase a question into a standalone question based on the chat history 182 * 183 * @param string $question The original user question 184 * @param array[] $history The chat history [[user, ai], [user, ai], ...] 185 * @return string The rephrased question 186 * @throws Exception 187 */ 188 public function rephraseChatQuestion($question, $history) 189 { 190 // go back in history as far as possible without hitting the token limit 191 $chatHistory = ''; 192 $history = array_reverse($history); 193 foreach ($history as $row) { 194 if ( 195 count($this->getEmbeddings()->getTokenEncoder()->encode($chatHistory)) > 196 $this->getModel()->getMaxRephrasingTokenLength() 197 ) { 198 break; 199 } 200 201 $chatHistory = 202 "Human: " . $row[0] . "\n" . 203 "Assistant: " . $row[1] . "\n" . 204 $chatHistory; 205 } 206 207 // ask openAI to rephrase the question 208 $prompt = $this->getPrompt('rephrase', ['history' => $chatHistory, 'question' => $question]); 209 $messages = [['role' => 'user', 'content' => $prompt]]; 210 return $this->getModel()->getRephrasedQuestion($messages); 211 } 212 213 /** 214 * Load the given prompt template and fill in the variables 215 * 216 * @param string $type 217 * @param string[] $vars 218 * @return string 219 */ 220 protected function getPrompt($type, $vars = []) 221 { 222 $template = file_get_contents($this->localFN('prompt_' . $type)); 223 224 $replace = array(); 225 foreach ($vars as $key => $val) { 226 $replace['{{' . strtoupper($key) . '}}'] = $val; 227 } 228 229 return strtr($template, $replace); 230 } 231} 232 233