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; 10c2b7a1f7SAndreas Gohruse dokuwiki\plugin\aichat\ModelFactory; 1101f06932SAndreas Gohruse dokuwiki\plugin\aichat\Storage\AbstractStorage; 120337f47fSAndreas Gohr 130337f47fSAndreas Gohr/** 140337f47fSAndreas Gohr * DokuWiki Plugin aichat (Helper Component) 150337f47fSAndreas Gohr * 160337f47fSAndreas Gohr * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html 170337f47fSAndreas Gohr * @author Andreas Gohr <gohr@cosmocode.de> 180337f47fSAndreas Gohr */ 197ebc7895Ssplitbrainclass helper_plugin_aichat extends Plugin 200337f47fSAndreas Gohr{ 21c2b7a1f7SAndreas Gohr /** @var ModelFactory */ 22c2b7a1f7SAndreas Gohr public $factory; 23c2b7a1f7SAndreas Gohr 243379af09SAndreas Gohr /** @var CLIPlugin $logger */ 253379af09SAndreas Gohr protected $logger; 26c2b7a1f7SAndreas Gohr 270337f47fSAndreas Gohr /** @var Embeddings */ 280337f47fSAndreas Gohr protected $embeddings; 2901f06932SAndreas Gohr /** @var AbstractStorage */ 3001f06932SAndreas Gohr protected $storage; 310337f47fSAndreas Gohr 32e75dc39fSAndreas Gohr /** @var array where to store meta data on the last run */ 33e75dc39fSAndreas Gohr protected $runDataFile; 34e75dc39fSAndreas Gohr 3551aa8517SAndreas Gohr 360337f47fSAndreas Gohr /** 37f8d5ae01SAndreas Gohr * Constructor. Initializes vendor autoloader 38f8d5ae01SAndreas Gohr */ 39f8d5ae01SAndreas Gohr public function __construct() 40f8d5ae01SAndreas Gohr { 41e75dc39fSAndreas Gohr require_once __DIR__ . '/vendor/autoload.php'; // FIXME obsolete from Kaos onwards 42e75dc39fSAndreas Gohr global $conf; 43e75dc39fSAndreas Gohr $this->runDataFile = $conf['metadir'] . '/aichat__run.json'; 44d02b7935SAndreas Gohr $this->loadConfig(); 45c2b7a1f7SAndreas Gohr $this->factory = new ModelFactory($this->conf); 46f8d5ae01SAndreas Gohr } 47f8d5ae01SAndreas Gohr 48f8d5ae01SAndreas Gohr /** 493379af09SAndreas Gohr * Use the given CLI plugin for logging 503379af09SAndreas Gohr * 513379af09SAndreas Gohr * @param CLIPlugin $logger 523379af09SAndreas Gohr * @return void 533379af09SAndreas Gohr */ 548285fff9SAndreas Gohr public function setLogger($logger) 558285fff9SAndreas Gohr { 563379af09SAndreas Gohr $this->logger = $logger; 573379af09SAndreas Gohr } 583379af09SAndreas Gohr 593379af09SAndreas Gohr /** 600de7e020SAndreas Gohr * Update the configuration 610de7e020SAndreas Gohr * 620de7e020SAndreas Gohr * @param array $config 630de7e020SAndreas Gohr * @return void 640de7e020SAndreas Gohr */ 650de7e020SAndreas Gohr public function updateConfig(array $config) 660de7e020SAndreas Gohr { 670de7e020SAndreas Gohr $this->conf = array_merge($this->conf, $config); 680de7e020SAndreas Gohr $this->factory->updateConfig($config); 690de7e020SAndreas Gohr } 700de7e020SAndreas Gohr 710de7e020SAndreas Gohr /** 72c4127b8eSAndreas Gohr * Check if the current user is allowed to use the plugin (if it has been restricted) 73c4127b8eSAndreas Gohr * 74c4127b8eSAndreas Gohr * @return bool 75c4127b8eSAndreas Gohr */ 76c4127b8eSAndreas Gohr public function userMayAccess() 77c4127b8eSAndreas Gohr { 78c4127b8eSAndreas Gohr global $auth; 79c4127b8eSAndreas Gohr global $USERINFO; 80c4127b8eSAndreas Gohr global $INPUT; 81c4127b8eSAndreas Gohr 82c4127b8eSAndreas Gohr if (!$auth) return true; 83c4127b8eSAndreas Gohr if (!$this->getConf('restrict')) return true; 84c4127b8eSAndreas Gohr if (!isset($USERINFO)) return false; 85c4127b8eSAndreas Gohr 86c4127b8eSAndreas Gohr return auth_isMember($this->getConf('restrict'), $INPUT->server->str('REMOTE_USER'), $USERINFO['grps']); 87c4127b8eSAndreas Gohr } 88c4127b8eSAndreas Gohr 89c4127b8eSAndreas Gohr /** 906a18e0f4SAndreas Gohr * Access the Chat Model 910337f47fSAndreas Gohr * 92294a9eafSAndreas Gohr * @return ChatInterface 930337f47fSAndreas Gohr */ 946a18e0f4SAndreas Gohr public function getChatModel() 950337f47fSAndreas Gohr { 96c2b7a1f7SAndreas Gohr return $this->factory->getChatModel(); 979f6b34c4SAndreas Gohr } 989f6b34c4SAndreas Gohr 996a18e0f4SAndreas Gohr /** 10051aa8517SAndreas Gohr * @return ChatInterface 10151aa8517SAndreas Gohr */ 10251aa8517SAndreas Gohr public function getRephraseModel() 10351aa8517SAndreas Gohr { 104c2b7a1f7SAndreas Gohr return $this->factory->getRephraseModel(); 10551aa8517SAndreas Gohr } 10651aa8517SAndreas Gohr 10751aa8517SAndreas Gohr /** 1086a18e0f4SAndreas Gohr * Access the Embedding Model 1096a18e0f4SAndreas Gohr * 110294a9eafSAndreas Gohr * @return EmbeddingInterface 1116a18e0f4SAndreas Gohr */ 112c2b7a1f7SAndreas Gohr public function getEmbeddingModel() 1136a18e0f4SAndreas Gohr { 114c2b7a1f7SAndreas Gohr return $this->factory->getEmbeddingModel(); 1150337f47fSAndreas Gohr } 1160337f47fSAndreas Gohr 1170337f47fSAndreas Gohr /** 1180337f47fSAndreas Gohr * Access the Embeddings interface 1190337f47fSAndreas Gohr * 1200337f47fSAndreas Gohr * @return Embeddings 1210337f47fSAndreas Gohr */ 1220337f47fSAndreas Gohr public function getEmbeddings() 1230337f47fSAndreas Gohr { 1246a18e0f4SAndreas Gohr if ($this->embeddings instanceof Embeddings) { 1256a18e0f4SAndreas Gohr return $this->embeddings; 1266a18e0f4SAndreas Gohr } 1276a18e0f4SAndreas Gohr 12834a1c478SAndreas Gohr $this->embeddings = new Embeddings( 12934a1c478SAndreas Gohr $this->getChatModel(), 130c2b7a1f7SAndreas Gohr $this->getEmbeddingModel(), 13134a1c478SAndreas Gohr $this->getStorage(), 13234a1c478SAndreas Gohr $this->conf 13334a1c478SAndreas Gohr ); 1343379af09SAndreas Gohr if ($this->logger) { 1353379af09SAndreas Gohr $this->embeddings->setLogger($this->logger); 1363379af09SAndreas Gohr } 1379f6b34c4SAndreas Gohr 1380337f47fSAndreas Gohr return $this->embeddings; 1390337f47fSAndreas Gohr } 1400337f47fSAndreas Gohr 1410337f47fSAndreas Gohr /** 14201f06932SAndreas Gohr * Access the Storage interface 14301f06932SAndreas Gohr * 14401f06932SAndreas Gohr * @return AbstractStorage 14501f06932SAndreas Gohr */ 14601f06932SAndreas Gohr public function getStorage() 14701f06932SAndreas Gohr { 1486a18e0f4SAndreas Gohr if ($this->storage instanceof AbstractStorage) { 1496a18e0f4SAndreas Gohr return $this->storage; 1506a18e0f4SAndreas Gohr } 1516a18e0f4SAndreas Gohr 15204afb84fSAndreas Gohr $class = '\\dokuwiki\\plugin\\aichat\\Storage\\' . $this->getConf('storage') . 'Storage'; 15304afb84fSAndreas Gohr $this->storage = new $class($this->conf); 1548285fff9SAndreas Gohr 1553379af09SAndreas Gohr if ($this->logger) { 1563379af09SAndreas Gohr $this->storage->setLogger($this->logger); 1573379af09SAndreas Gohr } 15801f06932SAndreas Gohr 15901f06932SAndreas Gohr return $this->storage; 16001f06932SAndreas Gohr } 16101f06932SAndreas Gohr 16201f06932SAndreas Gohr /** 1630337f47fSAndreas Gohr * Ask a question with a chat history 1640337f47fSAndreas Gohr * 1650337f47fSAndreas Gohr * @param string $question 1660337f47fSAndreas Gohr * @param array[] $history The chat history [[user, ai], [user, ai], ...] 1670337f47fSAndreas Gohr * @return array ['question' => $question, 'answer' => $answer, 'sources' => $sources] 1680337f47fSAndreas Gohr * @throws Exception 1690337f47fSAndreas Gohr */ 1700337f47fSAndreas Gohr public function askChatQuestion($question, $history = []) 1710337f47fSAndreas Gohr { 17251aa8517SAndreas Gohr if ($history && $this->getConf('rephraseHistory') > 0) { 17387090e4bSAndreas Gohr $contextQuestion = $this->rephraseChatQuestion($question, $history); 17487090e4bSAndreas Gohr 17587090e4bSAndreas Gohr // Only use the rephrased question if it has more history than the chat history provides 17687090e4bSAndreas Gohr if ($this->getConf('rephraseHistory') > $this->getConf('chatHistory')) { 17787090e4bSAndreas Gohr $question = $contextQuestion; 1780337f47fSAndreas Gohr } 17987090e4bSAndreas Gohr } else { 18087090e4bSAndreas Gohr $contextQuestion = $question; 18187090e4bSAndreas Gohr } 18287090e4bSAndreas Gohr return $this->askQuestion($question, $history, $contextQuestion); 1830337f47fSAndreas Gohr } 1840337f47fSAndreas Gohr 1850337f47fSAndreas Gohr /** 1860337f47fSAndreas Gohr * Ask a single standalone question 1870337f47fSAndreas Gohr * 18887090e4bSAndreas Gohr * @param string $question The question to ask 18934a1c478SAndreas Gohr * @param array $history [user, ai] of the previous question 19087090e4bSAndreas Gohr * @param string $contextQuestion The question to use for context search 1910337f47fSAndreas Gohr * @return array ['question' => $question, 'answer' => $answer, 'sources' => $sources] 1920337f47fSAndreas Gohr * @throws Exception 1930337f47fSAndreas Gohr */ 19487090e4bSAndreas Gohr public function askQuestion($question, $history = [], $contextQuestion = '') 1950337f47fSAndreas Gohr { 19687090e4bSAndreas Gohr $similar = $this->getEmbeddings()->getSimilarChunks($contextQuestion ?: $question, $this->getLanguageLimit()); 1979e81bea7SAndreas Gohr if ($similar) { 198441edf84SAndreas Gohr $context = implode( 199441edf84SAndreas Gohr "\n", 200441edf84SAndreas Gohr array_map(static fn(Chunk $chunk) => "\n```\n" . $chunk->getText() . "\n```\n", $similar) 201441edf84SAndreas Gohr ); 202219268b1SAndreas Gohr $prompt = $this->getPrompt('question', [ 203219268b1SAndreas Gohr 'context' => $context, 20459a2a267SAndreas Gohr 'question' => $question, 205*666b8ea7SAndreas Gohr 'customprompt' => $this->getConf('customprompt'), 206219268b1SAndreas Gohr ]); 2079e81bea7SAndreas Gohr } else { 20859a2a267SAndreas Gohr $prompt = $this->getPrompt('noanswer', [ 20959a2a267SAndreas Gohr 'question' => $question, 21059a2a267SAndreas Gohr ]); 21134a1c478SAndreas Gohr $history = []; 2129e81bea7SAndreas Gohr } 21368908844SAndreas Gohr 21451aa8517SAndreas Gohr $messages = $this->prepareMessages( 2152071dcedSAndreas Gohr $this->getChatModel(), 2162071dcedSAndreas Gohr $prompt, 2172071dcedSAndreas Gohr $history, 2182071dcedSAndreas Gohr $this->getConf('chatHistory') 21951aa8517SAndreas Gohr ); 2206a18e0f4SAndreas Gohr $answer = $this->getChatModel()->getAnswer($messages); 2210337f47fSAndreas Gohr 2220337f47fSAndreas Gohr return [ 2230337f47fSAndreas Gohr 'question' => $question, 22487090e4bSAndreas Gohr 'contextQuestion' => $contextQuestion, 2250337f47fSAndreas Gohr 'answer' => $answer, 2260337f47fSAndreas Gohr 'sources' => $similar, 2270337f47fSAndreas Gohr ]; 2280337f47fSAndreas Gohr } 2290337f47fSAndreas Gohr 2300337f47fSAndreas Gohr /** 2310337f47fSAndreas Gohr * Rephrase a question into a standalone question based on the chat history 2320337f47fSAndreas Gohr * 2330337f47fSAndreas Gohr * @param string $question The original user question 2340337f47fSAndreas Gohr * @param array[] $history The chat history [[user, ai], [user, ai], ...] 2350337f47fSAndreas Gohr * @return string The rephrased question 2360337f47fSAndreas Gohr * @throws Exception 2370337f47fSAndreas Gohr */ 2380337f47fSAndreas Gohr public function rephraseChatQuestion($question, $history) 2390337f47fSAndreas Gohr { 24059a2a267SAndreas Gohr $prompt = $this->getPrompt('rephrase', [ 24159a2a267SAndreas Gohr 'question' => $question, 24259a2a267SAndreas Gohr ]); 24351aa8517SAndreas Gohr $messages = $this->prepareMessages( 2442071dcedSAndreas Gohr $this->getRephraseModel(), 2452071dcedSAndreas Gohr $prompt, 2462071dcedSAndreas Gohr $history, 2472071dcedSAndreas Gohr $this->getConf('rephraseHistory') 24851aa8517SAndreas Gohr ); 24951aa8517SAndreas Gohr return $this->getRephraseModel()->getAnswer($messages); 25034a1c478SAndreas Gohr } 25134a1c478SAndreas Gohr 25234a1c478SAndreas Gohr /** 25334a1c478SAndreas Gohr * Prepare the messages for the AI 25434a1c478SAndreas Gohr * 25551aa8517SAndreas Gohr * @param ChatInterface $model The used model 25659a2a267SAndreas Gohr * @param string $promptedQuestion The user question embedded in a prompt 25734a1c478SAndreas Gohr * @param array[] $history The chat history [[user, ai], [user, ai], ...] 25851aa8517SAndreas Gohr * @param int $historySize The maximum number of messages to use from the history 25934a1c478SAndreas Gohr * @return array An OpenAI compatible array of messages 26034a1c478SAndreas Gohr */ 26151aa8517SAndreas Gohr protected function prepareMessages( 2622071dcedSAndreas Gohr ChatInterface $model, 2632071dcedSAndreas Gohr string $promptedQuestion, 2642071dcedSAndreas Gohr array $history, 2652071dcedSAndreas Gohr int $historySize 2668c08cb3fSAndreas Gohr ): array { 26734a1c478SAndreas Gohr // calculate the space for context 26851aa8517SAndreas Gohr $remainingContext = $model->getMaxInputTokenLength(); 26959a2a267SAndreas Gohr $remainingContext -= $this->countTokens($promptedQuestion); 27034a1c478SAndreas Gohr $safetyMargin = $remainingContext * 0.05; // 5% safety margin 27134a1c478SAndreas Gohr $remainingContext -= $safetyMargin; 27234a1c478SAndreas Gohr // FIXME we may want to also have an upper limit for the history and not always use the full context 27334a1c478SAndreas Gohr 27451aa8517SAndreas Gohr $messages = $this->historyMessages($history, $remainingContext, $historySize); 27534a1c478SAndreas Gohr $messages[] = [ 27634a1c478SAndreas Gohr 'role' => 'user', 27759a2a267SAndreas Gohr 'content' => $promptedQuestion 27834a1c478SAndreas Gohr ]; 27934a1c478SAndreas Gohr return $messages; 28034a1c478SAndreas Gohr } 28134a1c478SAndreas Gohr 28234a1c478SAndreas Gohr /** 28334a1c478SAndreas Gohr * Create an array of OpenAI compatible messages from the given history 28434a1c478SAndreas Gohr * 28534a1c478SAndreas Gohr * Only as many messages are used as fit into the token limit 28634a1c478SAndreas Gohr * 28734a1c478SAndreas Gohr * @param array[] $history The chat history [[user, ai], [user, ai], ...] 28851aa8517SAndreas Gohr * @param int $tokenLimit The maximum number of tokens to use 28951aa8517SAndreas Gohr * @param int $sizeLimit The maximum number of messages to use 29034a1c478SAndreas Gohr * @return array 29134a1c478SAndreas Gohr */ 29251aa8517SAndreas Gohr protected function historyMessages(array $history, int $tokenLimit, int $sizeLimit): array 29334a1c478SAndreas Gohr { 29434a1c478SAndreas Gohr $remainingContext = $tokenLimit; 29534a1c478SAndreas Gohr 29634a1c478SAndreas Gohr $messages = []; 2970337f47fSAndreas Gohr $history = array_reverse($history); 29851aa8517SAndreas Gohr $history = array_slice($history, 0, $sizeLimit); 2990337f47fSAndreas Gohr foreach ($history as $row) { 30034a1c478SAndreas Gohr $length = $this->countTokens($row[0] . $row[1]); 30134a1c478SAndreas Gohr if ($length > $remainingContext) { 3020337f47fSAndreas Gohr break; 3030337f47fSAndreas Gohr } 30434a1c478SAndreas Gohr $remainingContext -= $length; 3050337f47fSAndreas Gohr 30634a1c478SAndreas Gohr $messages[] = [ 30734a1c478SAndreas Gohr 'role' => 'assistant', 30834a1c478SAndreas Gohr 'content' => $row[1] 30934a1c478SAndreas Gohr ]; 31034a1c478SAndreas Gohr $messages[] = [ 31134a1c478SAndreas Gohr 'role' => 'user', 31234a1c478SAndreas Gohr 'content' => $row[0] 31334a1c478SAndreas Gohr ]; 31434a1c478SAndreas Gohr } 31534a1c478SAndreas Gohr return array_reverse($messages); 3160337f47fSAndreas Gohr } 3170337f47fSAndreas Gohr 31834a1c478SAndreas Gohr /** 31934a1c478SAndreas Gohr * Get an aproximation of the token count for the given text 32034a1c478SAndreas Gohr * 32134a1c478SAndreas Gohr * @param $text 32234a1c478SAndreas Gohr * @return int 32334a1c478SAndreas Gohr */ 32434a1c478SAndreas Gohr protected function countTokens($text) 32534a1c478SAndreas Gohr { 32634a1c478SAndreas Gohr return count($this->getEmbeddings()->getTokenEncoder()->encode($text)); 3270337f47fSAndreas Gohr } 3280337f47fSAndreas Gohr 3290337f47fSAndreas Gohr /** 3300337f47fSAndreas Gohr * Load the given prompt template and fill in the variables 3310337f47fSAndreas Gohr * 3320337f47fSAndreas Gohr * @param string $type 3330337f47fSAndreas Gohr * @param string[] $vars 3340337f47fSAndreas Gohr * @return string 3350337f47fSAndreas Gohr */ 3360337f47fSAndreas Gohr protected function getPrompt($type, $vars = []) 3370337f47fSAndreas Gohr { 33859a2a267SAndreas Gohr $template = file_get_contents($this->localFN($type, 'prompt')); 33934a1c478SAndreas Gohr $vars['language'] = $this->getLanguagePrompt(); 3400337f47fSAndreas Gohr 3417ebc7895Ssplitbrain $replace = []; 3420337f47fSAndreas Gohr foreach ($vars as $key => $val) { 3430337f47fSAndreas Gohr $replace['{{' . strtoupper($key) . '}}'] = $val; 3440337f47fSAndreas Gohr } 3450337f47fSAndreas Gohr 3460337f47fSAndreas Gohr return strtr($template, $replace); 3470337f47fSAndreas Gohr } 348219268b1SAndreas Gohr 349219268b1SAndreas Gohr /** 350219268b1SAndreas Gohr * Construct the prompt to define the answer language 351219268b1SAndreas Gohr * 352219268b1SAndreas Gohr * @return string 353219268b1SAndreas Gohr */ 354219268b1SAndreas Gohr protected function getLanguagePrompt() 355219268b1SAndreas Gohr { 356219268b1SAndreas Gohr global $conf; 357cfaf6b32SAndreas Gohr $isoLangnames = include(__DIR__ . '/lang/languages.php'); 358cfaf6b32SAndreas Gohr 359cfaf6b32SAndreas Gohr $currentLang = $isoLangnames[$conf['lang']] ?? 'English'; 360219268b1SAndreas Gohr 361e33a1d7aSAndreas Gohr if ($this->getConf('preferUIlanguage') > AIChat::LANG_AUTO_ALL) { 362219268b1SAndreas Gohr if (isset($isoLangnames[$conf['lang']])) { 363219268b1SAndreas Gohr $languagePrompt = 'Always answer in ' . $isoLangnames[$conf['lang']] . '.'; 364219268b1SAndreas Gohr return $languagePrompt; 365219268b1SAndreas Gohr } 366219268b1SAndreas Gohr } 367219268b1SAndreas Gohr 368cfaf6b32SAndreas Gohr $languagePrompt = 'Always answer in the user\'s language. ' . 369cfaf6b32SAndreas Gohr "If you are unsure about the language, speak $currentLang."; 370219268b1SAndreas Gohr return $languagePrompt; 371219268b1SAndreas Gohr } 372e33a1d7aSAndreas Gohr 373e33a1d7aSAndreas Gohr /** 374e33a1d7aSAndreas Gohr * Should sources be limited to current language? 375e33a1d7aSAndreas Gohr * 376e33a1d7aSAndreas Gohr * @return string The current language code or empty string 377e33a1d7aSAndreas Gohr */ 378e33a1d7aSAndreas Gohr public function getLanguageLimit() 379e33a1d7aSAndreas Gohr { 380e33a1d7aSAndreas Gohr if ($this->getConf('preferUIlanguage') >= AIChat::LANG_UI_LIMITED) { 381e33a1d7aSAndreas Gohr global $conf; 382e33a1d7aSAndreas Gohr return $conf['lang']; 383e33a1d7aSAndreas Gohr } else { 384e33a1d7aSAndreas Gohr return ''; 385e33a1d7aSAndreas Gohr } 386e33a1d7aSAndreas Gohr } 387e75dc39fSAndreas Gohr 388e75dc39fSAndreas Gohr /** 389e75dc39fSAndreas Gohr * Store info about the last run 390e75dc39fSAndreas Gohr * 391e75dc39fSAndreas Gohr * @param array $data 392e75dc39fSAndreas Gohr * @return void 393e75dc39fSAndreas Gohr */ 394e75dc39fSAndreas Gohr public function setRunData(array $data) 395e75dc39fSAndreas Gohr { 396e75dc39fSAndreas Gohr file_put_contents($this->runDataFile, json_encode($data, JSON_PRETTY_PRINT)); 397e75dc39fSAndreas Gohr } 398e75dc39fSAndreas Gohr 399e75dc39fSAndreas Gohr /** 400e75dc39fSAndreas Gohr * Get info about the last run 401e75dc39fSAndreas Gohr * 402e75dc39fSAndreas Gohr * @return array 403e75dc39fSAndreas Gohr */ 404e75dc39fSAndreas Gohr public function getRunData() 405e75dc39fSAndreas Gohr { 406e75dc39fSAndreas Gohr if (!file_exists($this->runDataFile)) { 407e75dc39fSAndreas Gohr return []; 408e75dc39fSAndreas Gohr } 409e75dc39fSAndreas Gohr return json_decode(file_get_contents($this->runDataFile), true); 410e75dc39fSAndreas Gohr } 4110337f47fSAndreas Gohr} 412