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, 205219268b1SAndreas Gohr ]); 2069e81bea7SAndreas Gohr } else { 20759a2a267SAndreas Gohr $prompt = $this->getPrompt('noanswer', [ 20859a2a267SAndreas Gohr 'question' => $question, 20959a2a267SAndreas Gohr ]); 21034a1c478SAndreas Gohr $history = []; 2119e81bea7SAndreas Gohr } 21268908844SAndreas Gohr 21351aa8517SAndreas Gohr $messages = $this->prepareMessages( 2142071dcedSAndreas Gohr $this->getChatModel(), 2152071dcedSAndreas Gohr $prompt, 2162071dcedSAndreas Gohr $history, 2172071dcedSAndreas Gohr $this->getConf('chatHistory') 21851aa8517SAndreas Gohr ); 2196a18e0f4SAndreas Gohr $answer = $this->getChatModel()->getAnswer($messages); 2200337f47fSAndreas Gohr 2210337f47fSAndreas Gohr return [ 2220337f47fSAndreas Gohr 'question' => $question, 22387090e4bSAndreas Gohr 'contextQuestion' => $contextQuestion, 2240337f47fSAndreas Gohr 'answer' => $answer, 2250337f47fSAndreas Gohr 'sources' => $similar, 2260337f47fSAndreas Gohr ]; 2270337f47fSAndreas Gohr } 2280337f47fSAndreas Gohr 2290337f47fSAndreas Gohr /** 2300337f47fSAndreas Gohr * Rephrase a question into a standalone question based on the chat history 2310337f47fSAndreas Gohr * 2320337f47fSAndreas Gohr * @param string $question The original user question 2330337f47fSAndreas Gohr * @param array[] $history The chat history [[user, ai], [user, ai], ...] 2340337f47fSAndreas Gohr * @return string The rephrased question 2350337f47fSAndreas Gohr * @throws Exception 2360337f47fSAndreas Gohr */ 2370337f47fSAndreas Gohr public function rephraseChatQuestion($question, $history) 2380337f47fSAndreas Gohr { 23959a2a267SAndreas Gohr $prompt = $this->getPrompt('rephrase', [ 24059a2a267SAndreas Gohr 'question' => $question, 24159a2a267SAndreas Gohr ]); 24251aa8517SAndreas Gohr $messages = $this->prepareMessages( 2432071dcedSAndreas Gohr $this->getRephraseModel(), 2442071dcedSAndreas Gohr $prompt, 2452071dcedSAndreas Gohr $history, 2462071dcedSAndreas Gohr $this->getConf('rephraseHistory') 24751aa8517SAndreas Gohr ); 24851aa8517SAndreas Gohr return $this->getRephraseModel()->getAnswer($messages); 24934a1c478SAndreas Gohr } 25034a1c478SAndreas Gohr 25134a1c478SAndreas Gohr /** 25234a1c478SAndreas Gohr * Prepare the messages for the AI 25334a1c478SAndreas Gohr * 25451aa8517SAndreas Gohr * @param ChatInterface $model The used model 25559a2a267SAndreas Gohr * @param string $promptedQuestion The user question embedded in a prompt 25634a1c478SAndreas Gohr * @param array[] $history The chat history [[user, ai], [user, ai], ...] 25751aa8517SAndreas Gohr * @param int $historySize The maximum number of messages to use from the history 25834a1c478SAndreas Gohr * @return array An OpenAI compatible array of messages 25934a1c478SAndreas Gohr */ 26051aa8517SAndreas Gohr protected function prepareMessages( 2612071dcedSAndreas Gohr ChatInterface $model, 2622071dcedSAndreas Gohr string $promptedQuestion, 2632071dcedSAndreas Gohr array $history, 2642071dcedSAndreas Gohr int $historySize 265*8c08cb3fSAndreas Gohr ): array { 26634a1c478SAndreas Gohr // calculate the space for context 26751aa8517SAndreas Gohr $remainingContext = $model->getMaxInputTokenLength(); 26859a2a267SAndreas Gohr $remainingContext -= $this->countTokens($promptedQuestion); 26934a1c478SAndreas Gohr $safetyMargin = $remainingContext * 0.05; // 5% safety margin 27034a1c478SAndreas Gohr $remainingContext -= $safetyMargin; 27134a1c478SAndreas Gohr // FIXME we may want to also have an upper limit for the history and not always use the full context 27234a1c478SAndreas Gohr 27351aa8517SAndreas Gohr $messages = $this->historyMessages($history, $remainingContext, $historySize); 27434a1c478SAndreas Gohr $messages[] = [ 27534a1c478SAndreas Gohr 'role' => 'user', 27659a2a267SAndreas Gohr 'content' => $promptedQuestion 27734a1c478SAndreas Gohr ]; 27834a1c478SAndreas Gohr return $messages; 27934a1c478SAndreas Gohr } 28034a1c478SAndreas Gohr 28134a1c478SAndreas Gohr /** 28234a1c478SAndreas Gohr * Create an array of OpenAI compatible messages from the given history 28334a1c478SAndreas Gohr * 28434a1c478SAndreas Gohr * Only as many messages are used as fit into the token limit 28534a1c478SAndreas Gohr * 28634a1c478SAndreas Gohr * @param array[] $history The chat history [[user, ai], [user, ai], ...] 28751aa8517SAndreas Gohr * @param int $tokenLimit The maximum number of tokens to use 28851aa8517SAndreas Gohr * @param int $sizeLimit The maximum number of messages to use 28934a1c478SAndreas Gohr * @return array 29034a1c478SAndreas Gohr */ 29151aa8517SAndreas Gohr protected function historyMessages(array $history, int $tokenLimit, int $sizeLimit): array 29234a1c478SAndreas Gohr { 29334a1c478SAndreas Gohr $remainingContext = $tokenLimit; 29434a1c478SAndreas Gohr 29534a1c478SAndreas Gohr $messages = []; 2960337f47fSAndreas Gohr $history = array_reverse($history); 29751aa8517SAndreas Gohr $history = array_slice($history, 0, $sizeLimit); 2980337f47fSAndreas Gohr foreach ($history as $row) { 29934a1c478SAndreas Gohr $length = $this->countTokens($row[0] . $row[1]); 30034a1c478SAndreas Gohr if ($length > $remainingContext) { 3010337f47fSAndreas Gohr break; 3020337f47fSAndreas Gohr } 30334a1c478SAndreas Gohr $remainingContext -= $length; 3040337f47fSAndreas Gohr 30534a1c478SAndreas Gohr $messages[] = [ 30634a1c478SAndreas Gohr 'role' => 'assistant', 30734a1c478SAndreas Gohr 'content' => $row[1] 30834a1c478SAndreas Gohr ]; 30934a1c478SAndreas Gohr $messages[] = [ 31034a1c478SAndreas Gohr 'role' => 'user', 31134a1c478SAndreas Gohr 'content' => $row[0] 31234a1c478SAndreas Gohr ]; 31334a1c478SAndreas Gohr } 31434a1c478SAndreas Gohr return array_reverse($messages); 3150337f47fSAndreas Gohr } 3160337f47fSAndreas Gohr 31734a1c478SAndreas Gohr /** 31834a1c478SAndreas Gohr * Get an aproximation of the token count for the given text 31934a1c478SAndreas Gohr * 32034a1c478SAndreas Gohr * @param $text 32134a1c478SAndreas Gohr * @return int 32234a1c478SAndreas Gohr */ 32334a1c478SAndreas Gohr protected function countTokens($text) 32434a1c478SAndreas Gohr { 32534a1c478SAndreas Gohr return count($this->getEmbeddings()->getTokenEncoder()->encode($text)); 3260337f47fSAndreas Gohr } 3270337f47fSAndreas Gohr 3280337f47fSAndreas Gohr /** 3290337f47fSAndreas Gohr * Load the given prompt template and fill in the variables 3300337f47fSAndreas Gohr * 3310337f47fSAndreas Gohr * @param string $type 3320337f47fSAndreas Gohr * @param string[] $vars 3330337f47fSAndreas Gohr * @return string 3340337f47fSAndreas Gohr */ 3350337f47fSAndreas Gohr protected function getPrompt($type, $vars = []) 3360337f47fSAndreas Gohr { 33759a2a267SAndreas Gohr $template = file_get_contents($this->localFN($type, 'prompt')); 33834a1c478SAndreas Gohr $vars['language'] = $this->getLanguagePrompt(); 3390337f47fSAndreas Gohr 3407ebc7895Ssplitbrain $replace = []; 3410337f47fSAndreas Gohr foreach ($vars as $key => $val) { 3420337f47fSAndreas Gohr $replace['{{' . strtoupper($key) . '}}'] = $val; 3430337f47fSAndreas Gohr } 3440337f47fSAndreas Gohr 3450337f47fSAndreas Gohr return strtr($template, $replace); 3460337f47fSAndreas Gohr } 347219268b1SAndreas Gohr 348219268b1SAndreas Gohr /** 349219268b1SAndreas Gohr * Construct the prompt to define the answer language 350219268b1SAndreas Gohr * 351219268b1SAndreas Gohr * @return string 352219268b1SAndreas Gohr */ 353219268b1SAndreas Gohr protected function getLanguagePrompt() 354219268b1SAndreas Gohr { 355219268b1SAndreas Gohr global $conf; 356cfaf6b32SAndreas Gohr $isoLangnames = include(__DIR__ . '/lang/languages.php'); 357cfaf6b32SAndreas Gohr 358cfaf6b32SAndreas Gohr $currentLang = $isoLangnames[$conf['lang']] ?? 'English'; 359219268b1SAndreas Gohr 360e33a1d7aSAndreas Gohr if ($this->getConf('preferUIlanguage') > AIChat::LANG_AUTO_ALL) { 361219268b1SAndreas Gohr if (isset($isoLangnames[$conf['lang']])) { 362219268b1SAndreas Gohr $languagePrompt = 'Always answer in ' . $isoLangnames[$conf['lang']] . '.'; 363219268b1SAndreas Gohr return $languagePrompt; 364219268b1SAndreas Gohr } 365219268b1SAndreas Gohr } 366219268b1SAndreas Gohr 367cfaf6b32SAndreas Gohr $languagePrompt = 'Always answer in the user\'s language. ' . 368cfaf6b32SAndreas Gohr "If you are unsure about the language, speak $currentLang."; 369219268b1SAndreas Gohr return $languagePrompt; 370219268b1SAndreas Gohr } 371e33a1d7aSAndreas Gohr 372e33a1d7aSAndreas Gohr /** 373e33a1d7aSAndreas Gohr * Should sources be limited to current language? 374e33a1d7aSAndreas Gohr * 375e33a1d7aSAndreas Gohr * @return string The current language code or empty string 376e33a1d7aSAndreas Gohr */ 377e33a1d7aSAndreas Gohr public function getLanguageLimit() 378e33a1d7aSAndreas Gohr { 379e33a1d7aSAndreas Gohr if ($this->getConf('preferUIlanguage') >= AIChat::LANG_UI_LIMITED) { 380e33a1d7aSAndreas Gohr global $conf; 381e33a1d7aSAndreas Gohr return $conf['lang']; 382e33a1d7aSAndreas Gohr } else { 383e33a1d7aSAndreas Gohr return ''; 384e33a1d7aSAndreas Gohr } 385e33a1d7aSAndreas Gohr } 386e75dc39fSAndreas Gohr 387e75dc39fSAndreas Gohr /** 388e75dc39fSAndreas Gohr * Store info about the last run 389e75dc39fSAndreas Gohr * 390e75dc39fSAndreas Gohr * @param array $data 391e75dc39fSAndreas Gohr * @return void 392e75dc39fSAndreas Gohr */ 393e75dc39fSAndreas Gohr public function setRunData(array $data) 394e75dc39fSAndreas Gohr { 395e75dc39fSAndreas Gohr file_put_contents($this->runDataFile, json_encode($data, JSON_PRETTY_PRINT)); 396e75dc39fSAndreas Gohr } 397e75dc39fSAndreas Gohr 398e75dc39fSAndreas Gohr /** 399e75dc39fSAndreas Gohr * Get info about the last run 400e75dc39fSAndreas Gohr * 401e75dc39fSAndreas Gohr * @return array 402e75dc39fSAndreas Gohr */ 403e75dc39fSAndreas Gohr public function getRunData() 404e75dc39fSAndreas Gohr { 405e75dc39fSAndreas Gohr if (!file_exists($this->runDataFile)) { 406e75dc39fSAndreas Gohr return []; 407e75dc39fSAndreas Gohr } 408e75dc39fSAndreas Gohr return json_decode(file_get_contents($this->runDataFile), true); 409e75dc39fSAndreas Gohr } 4100337f47fSAndreas Gohr} 411