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