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