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', [ 156 'context' => $context, 157 'language' => $this->getLanguagePrompt() 158 ]); 159 } else { 160 $prompt = $this->getPrompt('noanswer'); 161 } 162 163 $messages = [ 164 [ 165 'role' => 'system', 166 'content' => $prompt 167 ], 168 [ 169 'role' => 'user', 170 'content' => $question 171 ] 172 ]; 173 174 $answer = $this->getModel()->getAnswer($messages); 175 176 return [ 177 'question' => $question, 178 'answer' => $answer, 179 'sources' => $similar, 180 ]; 181 } 182 183 /** 184 * Rephrase a question into a standalone question based on the chat history 185 * 186 * @param string $question The original user question 187 * @param array[] $history The chat history [[user, ai], [user, ai], ...] 188 * @return string The rephrased question 189 * @throws Exception 190 */ 191 public function rephraseChatQuestion($question, $history) 192 { 193 // go back in history as far as possible without hitting the token limit 194 $chatHistory = ''; 195 $history = array_reverse($history); 196 foreach ($history as $row) { 197 if ( 198 count($this->getEmbeddings()->getTokenEncoder()->encode($chatHistory)) > 199 $this->getModel()->getMaxRephrasingTokenLength() 200 ) { 201 break; 202 } 203 204 $chatHistory = 205 "Human: " . $row[0] . "\n" . 206 "Assistant: " . $row[1] . "\n" . 207 $chatHistory; 208 } 209 210 // ask openAI to rephrase the question 211 $prompt = $this->getPrompt('rephrase', ['history' => $chatHistory, 'question' => $question]); 212 $messages = [['role' => 'user', 'content' => $prompt]]; 213 return $this->getModel()->getRephrasedQuestion($messages); 214 } 215 216 /** 217 * Load the given prompt template and fill in the variables 218 * 219 * @param string $type 220 * @param string[] $vars 221 * @return string 222 */ 223 protected function getPrompt($type, $vars = []) 224 { 225 $template = file_get_contents($this->localFN('prompt_' . $type)); 226 227 $replace = array(); 228 foreach ($vars as $key => $val) { 229 $replace['{{' . strtoupper($key) . '}}'] = $val; 230 } 231 232 return strtr($template, $replace); 233 } 234 235 /** 236 * Construct the prompt to define the answer language 237 * 238 * @return string 239 */ 240 protected function getLanguagePrompt() 241 { 242 global $conf; 243 244 if ($this->getConf('preferUIlanguage')) { 245 $isoLangnames = include(__DIR__ . '/lang/languages.php'); 246 if (isset($isoLangnames[$conf['lang']])) { 247 $languagePrompt = 'Always answer in ' . $isoLangnames[$conf['lang']] . '.'; 248 return $languagePrompt; 249 } 250 } 251 252 $languagePrompt = 'Always answer in the user\'s language.'; 253 return $languagePrompt; 254 } 255} 256 257