xref: /plugin/aichat/helper.php (revision 34a1c47875552330ce367360d99f2c3f9f69af94)
10337f47fSAndreas Gohr<?php
20337f47fSAndreas Gohr
33379af09SAndreas Gohruse dokuwiki\Extension\CLIPlugin;
45e6dd16eSAndreas Gohruse dokuwiki\Extension\Plugin;
5e33a1d7aSAndreas Gohruse dokuwiki\plugin\aichat\AIChat;
6f6ef2e50SAndreas Gohruse dokuwiki\plugin\aichat\Chunk;
70337f47fSAndreas Gohruse dokuwiki\plugin\aichat\Embeddings;
8294a9eafSAndreas Gohruse dokuwiki\plugin\aichat\Model\ChatInterface;
9294a9eafSAndreas Gohruse dokuwiki\plugin\aichat\Model\EmbeddingInterface;
10*34a1c478SAndreas Gohruse dokuwiki\plugin\aichat\Model\OpenAI\Embedding3Small;
116a18e0f4SAndreas Gohruse dokuwiki\plugin\aichat\Model\OpenAI\EmbeddingAda02;
1201f06932SAndreas Gohruse dokuwiki\plugin\aichat\Storage\AbstractStorage;
135e6dd16eSAndreas Gohruse dokuwiki\plugin\aichat\Storage\ChromaStorage;
1413dbfc23SAndreas Gohruse dokuwiki\plugin\aichat\Storage\PineconeStorage;
154c0099a8SAndreas Gohruse dokuwiki\plugin\aichat\Storage\QdrantStorage;
16f6ef2e50SAndreas Gohruse dokuwiki\plugin\aichat\Storage\SQLiteStorage;
170337f47fSAndreas Gohr
180337f47fSAndreas Gohr/**
190337f47fSAndreas Gohr * DokuWiki Plugin aichat (Helper Component)
200337f47fSAndreas Gohr *
210337f47fSAndreas Gohr * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
220337f47fSAndreas Gohr * @author  Andreas Gohr <gohr@cosmocode.de>
230337f47fSAndreas Gohr */
247ebc7895Ssplitbrainclass helper_plugin_aichat extends Plugin
250337f47fSAndreas Gohr{
263379af09SAndreas Gohr    /** @var CLIPlugin $logger */
273379af09SAndreas Gohr    protected $logger;
28294a9eafSAndreas Gohr    /** @var ChatInterface */
296a18e0f4SAndreas Gohr    protected $chatModel;
30294a9eafSAndreas Gohr    /** @var EmbeddingInterface */
316a18e0f4SAndreas Gohr    protected $embedModel;
320337f47fSAndreas Gohr    /** @var Embeddings */
330337f47fSAndreas Gohr    protected $embeddings;
3401f06932SAndreas Gohr    /** @var AbstractStorage */
3501f06932SAndreas Gohr    protected $storage;
360337f47fSAndreas Gohr
37e75dc39fSAndreas Gohr    /** @var array where to store meta data on the last run */
38e75dc39fSAndreas Gohr    protected $runDataFile;
39e75dc39fSAndreas Gohr
400337f47fSAndreas Gohr    /**
41f8d5ae01SAndreas Gohr     * Constructor. Initializes vendor autoloader
42f8d5ae01SAndreas Gohr     */
43f8d5ae01SAndreas Gohr    public function __construct()
44f8d5ae01SAndreas Gohr    {
45e75dc39fSAndreas Gohr        require_once __DIR__ . '/vendor/autoload.php'; // FIXME obsolete from Kaos onwards
46e75dc39fSAndreas Gohr        global $conf;
47e75dc39fSAndreas Gohr        $this->runDataFile = $conf['metadir'] . '/aichat__run.json';
48d02b7935SAndreas Gohr        $this->loadConfig();
49f8d5ae01SAndreas Gohr    }
50f8d5ae01SAndreas Gohr
51f8d5ae01SAndreas Gohr    /**
523379af09SAndreas Gohr     * Use the given CLI plugin for logging
533379af09SAndreas Gohr     *
543379af09SAndreas Gohr     * @param CLIPlugin $logger
553379af09SAndreas Gohr     * @return void
563379af09SAndreas Gohr     */
578285fff9SAndreas Gohr    public function setLogger($logger)
588285fff9SAndreas Gohr    {
593379af09SAndreas Gohr        $this->logger = $logger;
603379af09SAndreas Gohr    }
613379af09SAndreas Gohr
623379af09SAndreas Gohr    /**
63c4127b8eSAndreas Gohr     * Check if the current user is allowed to use the plugin (if it has been restricted)
64c4127b8eSAndreas Gohr     *
65c4127b8eSAndreas Gohr     * @return bool
66c4127b8eSAndreas Gohr     */
67c4127b8eSAndreas Gohr    public function userMayAccess()
68c4127b8eSAndreas Gohr    {
69c4127b8eSAndreas Gohr        global $auth;
70c4127b8eSAndreas Gohr        global $USERINFO;
71c4127b8eSAndreas Gohr        global $INPUT;
72c4127b8eSAndreas Gohr
73c4127b8eSAndreas Gohr        if (!$auth) return true;
74c4127b8eSAndreas Gohr        if (!$this->getConf('restrict')) return true;
75c4127b8eSAndreas Gohr        if (!isset($USERINFO)) return false;
76c4127b8eSAndreas Gohr
77c4127b8eSAndreas Gohr        return auth_isMember($this->getConf('restrict'), $INPUT->server->str('REMOTE_USER'), $USERINFO['grps']);
78c4127b8eSAndreas Gohr    }
79c4127b8eSAndreas Gohr
80c4127b8eSAndreas Gohr    /**
816a18e0f4SAndreas Gohr     * Access the Chat Model
820337f47fSAndreas Gohr     *
83294a9eafSAndreas Gohr     * @return ChatInterface
840337f47fSAndreas Gohr     */
856a18e0f4SAndreas Gohr    public function getChatModel()
860337f47fSAndreas Gohr    {
87294a9eafSAndreas Gohr        if ($this->chatModel instanceof ChatInterface) {
886a18e0f4SAndreas Gohr            return $this->chatModel;
896a18e0f4SAndreas Gohr        }
906a18e0f4SAndreas Gohr
919f6b34c4SAndreas Gohr        $class = '\\dokuwiki\\plugin\\aichat\\Model\\' . $this->getConf('model');
929f6b34c4SAndreas Gohr
93d02b7935SAndreas Gohr        //$class = Claude3Haiku::class;
94d02b7935SAndreas Gohr
959f6b34c4SAndreas Gohr        if (!class_exists($class)) {
969f6b34c4SAndreas Gohr            throw new \RuntimeException('Configured model not found: ' . $class);
979f6b34c4SAndreas Gohr        }
98d02b7935SAndreas Gohr
999f6b34c4SAndreas Gohr        // FIXME for now we only have OpenAI models, so we can hardcode the auth setup
100d02b7935SAndreas Gohr        $this->chatModel = new $class($this->conf);
1016a18e0f4SAndreas Gohr
1026a18e0f4SAndreas Gohr        return $this->chatModel;
1039f6b34c4SAndreas Gohr    }
1049f6b34c4SAndreas Gohr
1056a18e0f4SAndreas Gohr    /**
1066a18e0f4SAndreas Gohr     * Access the Embedding Model
1076a18e0f4SAndreas Gohr     *
108294a9eafSAndreas Gohr     * @return EmbeddingInterface
1096a18e0f4SAndreas Gohr     */
1106a18e0f4SAndreas Gohr    public function getEmbedModel()
1116a18e0f4SAndreas Gohr    {
1126a18e0f4SAndreas Gohr        // FIXME this is hardcoded to OpenAI for now
113294a9eafSAndreas Gohr        if ($this->embedModel instanceof EmbeddingInterface) {
1146a18e0f4SAndreas Gohr            return $this->embedModel;
1150337f47fSAndreas Gohr        }
1160337f47fSAndreas Gohr
117*34a1c478SAndreas Gohr        //$this->embedModel = new Embedding3Small($this->conf);
118d02b7935SAndreas Gohr        $this->embedModel = new EmbeddingAda02($this->conf);
1196a18e0f4SAndreas Gohr
1206a18e0f4SAndreas Gohr        return $this->embedModel;
1216a18e0f4SAndreas Gohr    }
1226a18e0f4SAndreas Gohr
1236a18e0f4SAndreas Gohr
1240337f47fSAndreas Gohr    /**
1250337f47fSAndreas Gohr     * Access the Embeddings interface
1260337f47fSAndreas Gohr     *
1270337f47fSAndreas Gohr     * @return Embeddings
1280337f47fSAndreas Gohr     */
1290337f47fSAndreas Gohr    public function getEmbeddings()
1300337f47fSAndreas Gohr    {
1316a18e0f4SAndreas Gohr        if ($this->embeddings instanceof Embeddings) {
1326a18e0f4SAndreas Gohr            return $this->embeddings;
1336a18e0f4SAndreas Gohr        }
1346a18e0f4SAndreas Gohr
135*34a1c478SAndreas Gohr        $this->embeddings = new Embeddings(
136*34a1c478SAndreas Gohr            $this->getChatModel(),
137*34a1c478SAndreas Gohr            $this->getEmbedModel(),
138*34a1c478SAndreas Gohr            $this->getStorage(),
139*34a1c478SAndreas Gohr            $this->conf
140*34a1c478SAndreas Gohr        );
1413379af09SAndreas Gohr        if ($this->logger) {
1423379af09SAndreas Gohr            $this->embeddings->setLogger($this->logger);
1433379af09SAndreas Gohr        }
1449f6b34c4SAndreas Gohr
1450337f47fSAndreas Gohr        return $this->embeddings;
1460337f47fSAndreas Gohr    }
1470337f47fSAndreas Gohr
1480337f47fSAndreas Gohr    /**
14901f06932SAndreas Gohr     * Access the Storage interface
15001f06932SAndreas Gohr     *
15101f06932SAndreas Gohr     * @return AbstractStorage
15201f06932SAndreas Gohr     */
15301f06932SAndreas Gohr    public function getStorage()
15401f06932SAndreas Gohr    {
1556a18e0f4SAndreas Gohr        if ($this->storage instanceof AbstractStorage) {
1566a18e0f4SAndreas Gohr            return $this->storage;
1576a18e0f4SAndreas Gohr        }
1586a18e0f4SAndreas Gohr
15913dbfc23SAndreas Gohr        if ($this->getConf('pinecone_apikey')) {
16013dbfc23SAndreas Gohr            $this->storage = new PineconeStorage();
1615e6dd16eSAndreas Gohr        } elseif ($this->getConf('chroma_baseurl')) {
1625e6dd16eSAndreas Gohr            $this->storage = new ChromaStorage();
1634c0099a8SAndreas Gohr        } elseif ($this->getConf('qdrant_baseurl')) {
1644c0099a8SAndreas Gohr            $this->storage = new QdrantStorage();
16513dbfc23SAndreas Gohr        } else {
16601f06932SAndreas Gohr            $this->storage = new SQLiteStorage();
16768b6fa79SAndreas Gohr        }
1688285fff9SAndreas Gohr
1693379af09SAndreas Gohr        if ($this->logger) {
1703379af09SAndreas Gohr            $this->storage->setLogger($this->logger);
1713379af09SAndreas Gohr        }
17201f06932SAndreas Gohr
17301f06932SAndreas Gohr        return $this->storage;
17401f06932SAndreas Gohr    }
17501f06932SAndreas Gohr
17601f06932SAndreas Gohr    /**
1770337f47fSAndreas Gohr     * Ask a question with a chat history
1780337f47fSAndreas Gohr     *
1790337f47fSAndreas Gohr     * @param string $question
1800337f47fSAndreas Gohr     * @param array[] $history The chat history [[user, ai], [user, ai], ...]
1810337f47fSAndreas Gohr     * @return array ['question' => $question, 'answer' => $answer, 'sources' => $sources]
1820337f47fSAndreas Gohr     * @throws Exception
1830337f47fSAndreas Gohr     */
1840337f47fSAndreas Gohr    public function askChatQuestion($question, $history = [])
1850337f47fSAndreas Gohr    {
1860337f47fSAndreas Gohr        if ($history) {
1870337f47fSAndreas Gohr            $standaloneQuestion = $this->rephraseChatQuestion($question, $history);
1880337f47fSAndreas Gohr        } else {
1890337f47fSAndreas Gohr            $standaloneQuestion = $question;
1900337f47fSAndreas Gohr        }
191*34a1c478SAndreas Gohr        return $this->askQuestion($standaloneQuestion, $history);
1920337f47fSAndreas Gohr    }
1930337f47fSAndreas Gohr
1940337f47fSAndreas Gohr    /**
1950337f47fSAndreas Gohr     * Ask a single standalone question
1960337f47fSAndreas Gohr     *
1970337f47fSAndreas Gohr     * @param string $question
198*34a1c478SAndreas Gohr     * @param array $history [user, ai] of the previous question
1990337f47fSAndreas Gohr     * @return array ['question' => $question, 'answer' => $answer, 'sources' => $sources]
2000337f47fSAndreas Gohr     * @throws Exception
2010337f47fSAndreas Gohr     */
202*34a1c478SAndreas Gohr    public function askQuestion($question, $history = [])
2030337f47fSAndreas Gohr    {
204e33a1d7aSAndreas Gohr        $similar = $this->getEmbeddings()->getSimilarChunks($question, $this->getLanguageLimit());
2059e81bea7SAndreas Gohr        if ($similar) {
206441edf84SAndreas Gohr            $context = implode(
207441edf84SAndreas Gohr                "\n",
208441edf84SAndreas Gohr                array_map(static fn(Chunk $chunk) => "\n```\n" . $chunk->getText() . "\n```\n", $similar)
209441edf84SAndreas Gohr            );
210219268b1SAndreas Gohr            $prompt = $this->getPrompt('question', [
211219268b1SAndreas Gohr                'context' => $context,
212219268b1SAndreas Gohr            ]);
2139e81bea7SAndreas Gohr        } else {
214*34a1c478SAndreas Gohr            $prompt = $this->getPrompt('noanswer');
215*34a1c478SAndreas Gohr            $history = [];
2169e81bea7SAndreas Gohr        }
21768908844SAndreas Gohr
218*34a1c478SAndreas Gohr        $messages = $this->prepareMessages($prompt, $question, $history);
2196a18e0f4SAndreas Gohr        $answer = $this->getChatModel()->getAnswer($messages);
2200337f47fSAndreas Gohr
2210337f47fSAndreas Gohr        return [
2220337f47fSAndreas Gohr            'question' => $question,
2230337f47fSAndreas Gohr            'answer' => $answer,
2240337f47fSAndreas Gohr            'sources' => $similar,
2250337f47fSAndreas Gohr        ];
2260337f47fSAndreas Gohr    }
2270337f47fSAndreas Gohr
2280337f47fSAndreas Gohr    /**
2290337f47fSAndreas Gohr     * Rephrase a question into a standalone question based on the chat history
2300337f47fSAndreas Gohr     *
2310337f47fSAndreas Gohr     * @param string $question The original user question
2320337f47fSAndreas Gohr     * @param array[] $history The chat history [[user, ai], [user, ai], ...]
2330337f47fSAndreas Gohr     * @return string The rephrased question
2340337f47fSAndreas Gohr     * @throws Exception
2350337f47fSAndreas Gohr     */
2360337f47fSAndreas Gohr    public function rephraseChatQuestion($question, $history)
2370337f47fSAndreas Gohr    {
238*34a1c478SAndreas Gohr        $prompt = $this->getPrompt('rephrase');
239*34a1c478SAndreas Gohr        $messages = $this->prepareMessages($prompt, $question, $history);
240*34a1c478SAndreas Gohr        return $this->getChatModel()->getAnswer($messages);
241*34a1c478SAndreas Gohr    }
242*34a1c478SAndreas Gohr
243*34a1c478SAndreas Gohr    /**
244*34a1c478SAndreas Gohr     * Prepare the messages for the AI
245*34a1c478SAndreas Gohr     *
246*34a1c478SAndreas Gohr     * @param string $prompt The fully prepared system prompt
247*34a1c478SAndreas Gohr     * @param string $question The user question
248*34a1c478SAndreas Gohr     * @param array[] $history The chat history [[user, ai], [user, ai], ...]
249*34a1c478SAndreas Gohr     * @return array An OpenAI compatible array of messages
250*34a1c478SAndreas Gohr     */
251*34a1c478SAndreas Gohr    protected function prepareMessages($prompt, $question, $history)
252*34a1c478SAndreas Gohr    {
253*34a1c478SAndreas Gohr        // calculate the space for context
254*34a1c478SAndreas Gohr        $remainingContext = $this->getChatModel()->getMaxInputTokenLength();
255*34a1c478SAndreas Gohr        $remainingContext -= $this->countTokens($prompt);
256*34a1c478SAndreas Gohr        $remainingContext -= $this->countTokens($question);
257*34a1c478SAndreas Gohr        $safetyMargin = $remainingContext * 0.05; // 5% safety margin
258*34a1c478SAndreas Gohr        $remainingContext -= $safetyMargin;
259*34a1c478SAndreas Gohr        // FIXME we may want to also have an upper limit for the history and not always use the full context
260*34a1c478SAndreas Gohr
261*34a1c478SAndreas Gohr        $messages = $this->historyMessages($history, $remainingContext);
262*34a1c478SAndreas Gohr        $messages[] = [
263*34a1c478SAndreas Gohr            'role' => 'system',
264*34a1c478SAndreas Gohr            'content' => $prompt
265*34a1c478SAndreas Gohr        ];
266*34a1c478SAndreas Gohr        $messages[] = [
267*34a1c478SAndreas Gohr            'role' => 'user',
268*34a1c478SAndreas Gohr            'content' => $question
269*34a1c478SAndreas Gohr        ];
270*34a1c478SAndreas Gohr        return $messages;
271*34a1c478SAndreas Gohr    }
272*34a1c478SAndreas Gohr
273*34a1c478SAndreas Gohr    /**
274*34a1c478SAndreas Gohr     * Create an array of OpenAI compatible messages from the given history
275*34a1c478SAndreas Gohr     *
276*34a1c478SAndreas Gohr     * Only as many messages are used as fit into the token limit
277*34a1c478SAndreas Gohr     *
278*34a1c478SAndreas Gohr     * @param array[] $history The chat history [[user, ai], [user, ai], ...]
279*34a1c478SAndreas Gohr     * @param int $tokenLimit
280*34a1c478SAndreas Gohr     * @return array
281*34a1c478SAndreas Gohr     */
282*34a1c478SAndreas Gohr    protected function historyMessages($history, $tokenLimit)
283*34a1c478SAndreas Gohr    {
284*34a1c478SAndreas Gohr        $remainingContext = $tokenLimit;
285*34a1c478SAndreas Gohr
286*34a1c478SAndreas Gohr        $messages = [];
2870337f47fSAndreas Gohr        $history = array_reverse($history);
2880337f47fSAndreas Gohr        foreach ($history as $row) {
289*34a1c478SAndreas Gohr            $length = $this->countTokens($row[0] . $row[1]);
290*34a1c478SAndreas Gohr            if ($length > $remainingContext) {
2910337f47fSAndreas Gohr                break;
2920337f47fSAndreas Gohr            }
293*34a1c478SAndreas Gohr            $remainingContext -= $length;
2940337f47fSAndreas Gohr
295*34a1c478SAndreas Gohr            $messages[] = [
296*34a1c478SAndreas Gohr                'role' => 'assistant',
297*34a1c478SAndreas Gohr                'content' => $row[1]
298*34a1c478SAndreas Gohr            ];
299*34a1c478SAndreas Gohr            $messages[] = [
300*34a1c478SAndreas Gohr                'role' => 'user',
301*34a1c478SAndreas Gohr                'content' => $row[0]
302*34a1c478SAndreas Gohr            ];
303*34a1c478SAndreas Gohr        }
304*34a1c478SAndreas Gohr        return array_reverse($messages);
3050337f47fSAndreas Gohr    }
3060337f47fSAndreas Gohr
307*34a1c478SAndreas Gohr    /**
308*34a1c478SAndreas Gohr     * Get an aproximation of the token count for the given text
309*34a1c478SAndreas Gohr     *
310*34a1c478SAndreas Gohr     * @param $text
311*34a1c478SAndreas Gohr     * @return int
312*34a1c478SAndreas Gohr     */
313*34a1c478SAndreas Gohr    protected function countTokens($text)
314*34a1c478SAndreas Gohr    {
315*34a1c478SAndreas Gohr        return count($this->getEmbeddings()->getTokenEncoder()->encode($text));
3160337f47fSAndreas Gohr    }
3170337f47fSAndreas Gohr
3180337f47fSAndreas Gohr    /**
3190337f47fSAndreas Gohr     * Load the given prompt template and fill in the variables
3200337f47fSAndreas Gohr     *
3210337f47fSAndreas Gohr     * @param string $type
3220337f47fSAndreas Gohr     * @param string[] $vars
3230337f47fSAndreas Gohr     * @return string
3240337f47fSAndreas Gohr     */
3250337f47fSAndreas Gohr    protected function getPrompt($type, $vars = [])
3260337f47fSAndreas Gohr    {
3270337f47fSAndreas Gohr        $template = file_get_contents($this->localFN('prompt_' . $type));
328*34a1c478SAndreas Gohr        $vars['language'] = $this->getLanguagePrompt();
3290337f47fSAndreas Gohr
3307ebc7895Ssplitbrain        $replace = [];
3310337f47fSAndreas Gohr        foreach ($vars as $key => $val) {
3320337f47fSAndreas Gohr            $replace['{{' . strtoupper($key) . '}}'] = $val;
3330337f47fSAndreas Gohr        }
3340337f47fSAndreas Gohr
3350337f47fSAndreas Gohr        return strtr($template, $replace);
3360337f47fSAndreas Gohr    }
337219268b1SAndreas Gohr
338219268b1SAndreas Gohr    /**
339219268b1SAndreas Gohr     * Construct the prompt to define the answer language
340219268b1SAndreas Gohr     *
341219268b1SAndreas Gohr     * @return string
342219268b1SAndreas Gohr     */
343219268b1SAndreas Gohr    protected function getLanguagePrompt()
344219268b1SAndreas Gohr    {
345219268b1SAndreas Gohr        global $conf;
346cfaf6b32SAndreas Gohr        $isoLangnames = include(__DIR__ . '/lang/languages.php');
347cfaf6b32SAndreas Gohr
348cfaf6b32SAndreas Gohr        $currentLang = $isoLangnames[$conf['lang']] ?? 'English';
349219268b1SAndreas Gohr
350e33a1d7aSAndreas Gohr        if ($this->getConf('preferUIlanguage') > AIChat::LANG_AUTO_ALL) {
351219268b1SAndreas Gohr            if (isset($isoLangnames[$conf['lang']])) {
352219268b1SAndreas Gohr                $languagePrompt = 'Always answer in ' . $isoLangnames[$conf['lang']] . '.';
353219268b1SAndreas Gohr                return $languagePrompt;
354219268b1SAndreas Gohr            }
355219268b1SAndreas Gohr        }
356219268b1SAndreas Gohr
357cfaf6b32SAndreas Gohr        $languagePrompt = 'Always answer in the user\'s language. ' .
358cfaf6b32SAndreas Gohr            "If you are unsure about the language, speak $currentLang.";
359219268b1SAndreas Gohr        return $languagePrompt;
360219268b1SAndreas Gohr    }
361e33a1d7aSAndreas Gohr
362e33a1d7aSAndreas Gohr    /**
363e33a1d7aSAndreas Gohr     * Should sources be limited to current language?
364e33a1d7aSAndreas Gohr     *
365e33a1d7aSAndreas Gohr     * @return string The current language code or empty string
366e33a1d7aSAndreas Gohr     */
367e33a1d7aSAndreas Gohr    public function getLanguageLimit()
368e33a1d7aSAndreas Gohr    {
369e33a1d7aSAndreas Gohr        if ($this->getConf('preferUIlanguage') >= AIChat::LANG_UI_LIMITED) {
370e33a1d7aSAndreas Gohr            global $conf;
371e33a1d7aSAndreas Gohr            return $conf['lang'];
372e33a1d7aSAndreas Gohr        } else {
373e33a1d7aSAndreas Gohr            return '';
374e33a1d7aSAndreas Gohr        }
375e33a1d7aSAndreas Gohr    }
376e75dc39fSAndreas Gohr
377e75dc39fSAndreas Gohr    /**
378e75dc39fSAndreas Gohr     * Store info about the last run
379e75dc39fSAndreas Gohr     *
380e75dc39fSAndreas Gohr     * @param array $data
381e75dc39fSAndreas Gohr     * @return void
382e75dc39fSAndreas Gohr     */
383e75dc39fSAndreas Gohr    public function setRunData(array $data)
384e75dc39fSAndreas Gohr    {
385e75dc39fSAndreas Gohr        file_put_contents($this->runDataFile, json_encode($data, JSON_PRETTY_PRINT));
386e75dc39fSAndreas Gohr    }
387e75dc39fSAndreas Gohr
388e75dc39fSAndreas Gohr    /**
389e75dc39fSAndreas Gohr     * Get info about the last run
390e75dc39fSAndreas Gohr     *
391e75dc39fSAndreas Gohr     * @return array
392e75dc39fSAndreas Gohr     */
393e75dc39fSAndreas Gohr    public function getRunData()
394e75dc39fSAndreas Gohr    {
395e75dc39fSAndreas Gohr        if (!file_exists($this->runDataFile)) {
396e75dc39fSAndreas Gohr            return [];
397e75dc39fSAndreas Gohr        }
398e75dc39fSAndreas Gohr        return json_decode(file_get_contents($this->runDataFile), true);
399e75dc39fSAndreas Gohr    }
4000337f47fSAndreas Gohr}
401