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