xref: /plugin/aichat/helper.php (revision 7ebc78955c65af90e7ee0afbd07adc15271113ba)
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