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