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