using System.Text; using Markdig.Extensions.Mathematics; using Markdig.Syntax; namespace AIStudio.Assistants.ERI; /// /// Reads the files of an ERI server out of the answers of the model and writes them to disk. /// internal static class ERIServerFiles { /// /// Reads the content of a file out of the answer which wrote it. /// /// /// The model is asked for a heading with the path of the file, followed by a code block with its /// content. Only the code block counts: models write the heading as they please, e.g., with the /// path in backticks, with a "File:" in front, or not at all. Up to v26.10.1, a regular expression /// insisted on a heading without a hyphen, which left out files such as docker-compose.yml, and it /// read up to the last fence of the answer, which merged a usage example into the file. /// /// Markdig reads the answer the way the chat shows it. So we take the first complete code block, /// and a file which holds code blocks itself stays whole when the model fences it with more /// backticks. A block the model never closed is left out: the answer broke off, and half a file /// is nothing to write to disk. /// /// The answer of the model to the request for one file. /// The content of the file, ending with a line break, or an empty string when the /// answer holds no complete code block. public static string ReadFileContent(string answer) { if (string.IsNullOrWhiteSpace(answer)) return string.Empty; // // Markdig reads a formula between $$ as a MathBlock, which is a fenced code block as well: // var document = Markdig.Markdown.Parse(answer, Markdown.SAFE_MARKDOWN_PIPELINE); var codeBlock = document.Descendants().FirstOrDefault(block => block is not MathBlock && block.ClosingFencedCharCount > 0); if (codeBlock is null) return string.Empty; var content = codeBlock.Lines.ToString(); return content.Length is 0 ? string.Empty : $"{content}\n"; } /// /// The path to which a file of the model is written. /// /// The base directory of the ERI server. /// The path of the file, as the model named it. /// The path of the file below the base directory. public static string PathOf(string baseDirectory, string file) => Path.Join(baseDirectory, file); /// /// Writes a file of the model below the base directory. /// /// /// A file which cannot be written ends nothing but its own attempt: the caller tells the user, /// and the server gets its other files. Up to v26.10.1, an error while writing ended the whole /// run, and the files written so far were never recorded for their deletion on the next run. /// /// The base directory of the ERI server. /// The path of the file, as the model named it. /// The answer of the model to the request for this file. /// The logger which records the details. /// What became of the file. public static async Task WriteAsync(string baseDirectory, string file, string answer, ILogger logger) { var desiredFilePath = PathOf(baseDirectory, file); // // Security check: ensure that the desired file path is inside the base directory. // We cannot trust the beginning of the file path because it would be possible // to escape by using `..` in the file path. // if (!PathTools.IsInsideDirectory(baseDirectory, desiredFilePath)) { logger.LogWarning("The file path '{FilePath}' is not inside the base directory '{BaseDirectory}'.", desiredFilePath, baseDirectory); return ERIServerFileOutcome.OUTSIDE_BASE_DIRECTORY; } var content = ReadFileContent(answer); if (string.IsNullOrWhiteSpace(content)) { logger.LogWarning("The content of the file '{FilePath}' is empty or was not found.", desiredFilePath); return ERIServerFileOutcome.NO_COMPLETE_CODE_BLOCK; } try { // A path inside the base directory always has a directory: var fileDirectory = Path.GetDirectoryName(desiredFilePath)!; if (!Directory.Exists(fileDirectory)) { Directory.CreateDirectory(fileDirectory); logger.LogInformation("The directory '{Directory}' was created.", fileDirectory); } await File.WriteAllTextAsync(desiredFilePath, content, Encoding.UTF8); logger.LogInformation("The file '{FilePath}' was created.", desiredFilePath); return ERIServerFileOutcome.WRITTEN; } catch (Exception e) when (e is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) { logger.LogWarning(e, "The file '{FilePath}' could not be written.", desiredFilePath); return ERIServerFileOutcome.WRITE_FAILED; } } }