1<?php 2 3use dokuwiki\Extension\CLIPlugin; 4use dokuwiki\plugin\aichat\Chunk; 5use dokuwiki\plugin\aichat\Embeddings; 6use dokuwiki\plugin\aichat\Model\AbstractModel; 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 $this->embeddings = new Embeddings($this->getModel(), $this->getStorage()); 92 if ($this->logger) { 93 $this->embeddings->setLogger($this->logger); 94 } 95 } 96 97 return $this->embeddings; 98 } 99 100 /** 101 * Access the Storage interface 102 * 103 * @return AbstractStorage 104 */ 105 public function getStorage() 106 { 107 if ($this->storage === null) { 108 if ($this->getConf('pinecone_apikey')) { 109 $this->storage = new PineconeStorage(); 110 } else { 111 $this->storage = new SQLiteStorage(); 112 } 113 114 if ($this->logger) { 115 $this->storage->setLogger($this->logger); 116 } 117 } 118 119 return $this->storage; 120 } 121 122 /** 123 * Ask a question with a chat history 124 * 125 * @param string $question 126 * @param array[] $history The chat history [[user, ai], [user, ai], ...] 127 * @return array ['question' => $question, 'answer' => $answer, 'sources' => $sources] 128 * @throws Exception 129 */ 130 public function askChatQuestion($question, $history = []) 131 { 132 if ($history) { 133 $standaloneQuestion = $this->rephraseChatQuestion($question, $history); 134 $prev = end($history); 135 } else { 136 $standaloneQuestion = $question; 137 $prev = []; 138 } 139 return $this->askQuestion($standaloneQuestion, $prev); 140 } 141 142 /** 143 * Ask a single standalone question 144 * 145 * @param string $question 146 * @param array $previous [user, ai] of the previous question 147 * @return array ['question' => $question, 'answer' => $answer, 'sources' => $sources] 148 * @throws Exception 149 */ 150 public function askQuestion($question, $previous = []) 151 { 152 $similar = $this->getEmbeddings()->getSimilarChunks($question); 153 if ($similar) { 154 $context = implode("\n", array_map(function (Chunk $chunk) { 155 return "\n```\n" . $chunk->getText() . "\n```\n"; 156 }, $similar)); 157 $prompt = $this->getPrompt('question', [ 158 'context' => $context, 159 'language' => $this->getLanguagePrompt() 160 ]); 161 } else { 162 $prompt = $this->getPrompt('noanswer'); 163 } 164 165 $messages = [ 166 [ 167 'role' => 'system', 168 'content' => $prompt 169 ], 170 [ 171 'role' => 'user', 172 'content' => $question 173 ] 174 ]; 175 176 if ($previous) { 177 array_unshift($messages, [ 178 'role' => 'assistant', 179 'content' => $previous[1] 180 ]); 181 array_unshift($messages, [ 182 'role' => 'user', 183 'content' => $previous[0] 184 ]); 185 } 186 187 $answer = $this->getModel()->getAnswer($messages); 188 189 return [ 190 'question' => $question, 191 'answer' => $answer, 192 'sources' => $similar, 193 ]; 194 } 195 196 /** 197 * Rephrase a question into a standalone question based on the chat history 198 * 199 * @param string $question The original user question 200 * @param array[] $history The chat history [[user, ai], [user, ai], ...] 201 * @return string The rephrased question 202 * @throws Exception 203 */ 204 public function rephraseChatQuestion($question, $history) 205 { 206 // go back in history as far as possible without hitting the token limit 207 $chatHistory = ''; 208 $history = array_reverse($history); 209 foreach ($history as $row) { 210 if ( 211 count($this->getEmbeddings()->getTokenEncoder()->encode($chatHistory)) > 212 $this->getModel()->getMaxRephrasingTokenLength() 213 ) { 214 break; 215 } 216 217 $chatHistory = 218 "Human: " . $row[0] . "\n" . 219 "Assistant: " . $row[1] . "\n" . 220 $chatHistory; 221 } 222 223 // ask openAI to rephrase the question 224 $prompt = $this->getPrompt('rephrase', ['history' => $chatHistory, 'question' => $question]); 225 $messages = [['role' => 'user', 'content' => $prompt]]; 226 return $this->getModel()->getRephrasedQuestion($messages); 227 } 228 229 /** 230 * Load the given prompt template and fill in the variables 231 * 232 * @param string $type 233 * @param string[] $vars 234 * @return string 235 */ 236 protected function getPrompt($type, $vars = []) 237 { 238 $template = file_get_contents($this->localFN('prompt_' . $type)); 239 240 $replace = array(); 241 foreach ($vars as $key => $val) { 242 $replace['{{' . strtoupper($key) . '}}'] = $val; 243 } 244 245 return strtr($template, $replace); 246 } 247 248 /** 249 * Construct the prompt to define the answer language 250 * 251 * @return string 252 */ 253 protected function getLanguagePrompt() 254 { 255 global $conf; 256 257 if ($this->getConf('preferUIlanguage')) { 258 $isoLangnames = include(__DIR__ . '/lang/languages.php'); 259 if (isset($isoLangnames[$conf['lang']])) { 260 $languagePrompt = 'Always answer in ' . $isoLangnames[$conf['lang']] . '.'; 261 return $languagePrompt; 262 } 263 } 264 265 $languagePrompt = 'Always answer in the user\'s language.'; 266 return $languagePrompt; 267 } 268} 269 270