using System.Globalization; using System.Text; using AIStudio.Dialogs; using DialogOptions = AIStudio.Dialogs.DialogOptions; namespace AIStudio.Assistants.BatchProcessing; public partial class AssistantBatchProcessing { /// /// Asks the user whether a previous batch run should be continued. /// /// The decision, or null when the user canceled the dialog. private async Task AskResumeDecisionAsync(int numCompletedFiles, int numRemainingFiles, int numMissingResults) { var dialogParameters = new DialogParameters { { x => x.NumCompletedFiles, numCompletedFiles }, { x => x.NumRemainingFiles, numRemainingFiles }, { x => x.NumMissingResults, numMissingResults }, }; var dialogReference = await this.DialogService.ShowAsync(T("Continue the previous batch run?"), dialogParameters, DialogOptions.FULLSCREEN); var dialogResult = await dialogReference.Result; if (dialogResult is null || dialogResult.Canceled) return null; return dialogResult.Data as BatchProcessingResumeDecision?; } /// /// Reads the log of the previous run and asks the user how to proceed. /// /// The previous log and results, or null when the user canceled. private async Task<(Dictionary PreviousLog, Dictionary PreviousResults)?> LoadPreviousRunAsync(string resolvedOutputDirectory, IReadOnlyList files) { var previousLog = await this.ReadLogAsync(Path.Join(resolvedOutputDirectory, LOG_FILENAME)); // We read the results table before showing the dialog: the dialog must // report how many documents are actually restorable, not how many the // log claims to be completed. Both may differ, e.g., when the user // deleted result files or renamed the results table in the meantime. var previousResults = this.outputMode is BatchProcessingOutputMode.TABLE_ONLY ? await this.ReadPreviousResultsAsync(Path.Join(resolvedOutputDirectory, this.ResolveResultsFileName())) : new Dictionary(StringComparer.OrdinalIgnoreCase); var numCompletedInLog = 0; var numRestorable = 0; foreach (var file in files) { var relativePath = Path.GetRelativePath(this.inputDirectory, file); if (previousLog.TryGetValue(relativePath, out var entry) && entry.WasSuccessful) numCompletedInLog++; if (this.CanRestoreFromPreviousRun(relativePath, resolvedOutputDirectory, previousLog, previousResults, out _)) numRestorable++; } var decision = await this.AskResumeDecisionAsync(numRestorable, files.Count - numRestorable, numCompletedInLog - numRestorable); if (decision is null) return null; if (decision is BatchProcessingResumeDecision.RESTART) previousLog.Clear(); return (previousLog, previousResults); } /// /// Checks whether a document can be restored from the previous run. Beyond /// the log entry, the result of the previous run must still exist: in the /// table mode the answer within the results table, in the Markdown mode the /// result file. Without the result, restoring would mark the document as /// done while its answer is lost, so we process it again instead. /// private bool CanRestoreFromPreviousRun(string relativePath, string resolvedOutputDirectory, Dictionary previousLog, Dictionary previousResults, out BatchProcessingLogEntry? logEntry) { if (!previousLog.TryGetValue(relativePath, out logEntry) || !logEntry.WasSuccessful) return false; if (this.outputMode is BatchProcessingOutputMode.TABLE_ONLY) return previousResults.ContainsKey(relativePath); return !string.IsNullOrWhiteSpace(logEntry.Details) && File.Exists(Path.Join(resolvedOutputDirectory, logEntry.Details)); } /// /// Rewrites the output files after each processed file. This way, the /// results on disk stay complete even when the run is canceled or crashes. /// private async Task WriteAggregatedResultsAsync(string resolvedOutputDirectory) { await this.WriteLogAsync(resolvedOutputDirectory); if (this.outputMode is BatchProcessingOutputMode.TABLE_ONLY) await this.WriteResultsTableAsync(resolvedOutputDirectory); } /// /// Writes the log of the batch run. The log contains the metadata of every /// document, including the documents which failed. It never contains the AI /// answers, and it is written in both output modes. /// private async Task WriteLogAsync(string resolvedOutputDirectory) { var sb = new StringBuilder(); sb.AppendLine(BatchProcessingCsv.ToCsvRow(T("File"), T("Time"), T("Model"), T("Status"), T("Details"))); foreach (var fileResult in this.fileResults.Where(x => x.Status is not BatchProcessingFileStatus.QUEUED and not BatchProcessingFileStatus.PROCESSING)) sb.AppendLine(BatchProcessingCsv.ToCsvRow(fileResult.RelativePath, fileResult.ProcessedAt.ToString(TIME_FORMAT, CultureInfo.InvariantCulture), fileResult.ModelName, fileResult.Status.ToString(), fileResult.Message)); await this.WriteCsvFileAsync(Path.Join(resolvedOutputDirectory, LOG_FILENAME), sb.ToString()); } /// /// Writes the results table, which contains the AI answers. /// private async Task WriteResultsTableAsync(string resolvedOutputDirectory) { var sb = new StringBuilder(); sb.AppendLine(BatchProcessingCsv.ToCsvRow(T("File"), this.ResultColumnHeader)); foreach (var fileResult in this.fileResults.Where(x => x.Status is BatchProcessingFileStatus.DONE)) sb.AppendLine(BatchProcessingCsv.ToCsvRow(fileResult.RelativePath, fileResult.ResultText)); await this.WriteCsvFileAsync(Path.Join(resolvedOutputDirectory, this.ResolveResultsFileName()), sb.ToString()); } private async Task WriteCsvFileAsync(string targetFilePath, string content) { // Write to a sibling file first, then rename. This way, an aborted // write can never destroy the results of the previous files: var tempFilePath = targetFilePath + ".tmp"; try { // We write the CSV file with a byte order mark, so that spreadsheet // applications recognize the UTF-8 encoding of, e.g., umlauts: await File.WriteAllTextAsync(tempFilePath, content, new UTF8Encoding(true), CancellationToken.None); File.Move(tempFilePath, targetFilePath, true); } catch (Exception e) { this.Logger.LogError(e, "Was not able to write the batch output file '{TargetFilePath}'.", targetFilePath); // Remove our leftover: a failing rename keeps the temporary file in // the output folder, where it looks like a result to the user and // piles up over several runs. try { File.Delete(tempFilePath); } catch (Exception deleteError) { this.Logger.LogWarning(deleteError, "Was not able to remove the temporary file '{TempFilePath}'.", tempFilePath); } // A failing write repeats for every document. We report it once per // run: without any message, the UI would show a successful run // while the files on disk stay behind. if (this.hasReportedWriteFailure) return; this.hasReportedWriteFailure = true; await this.MessageBus.SendError(new(Icons.Material.Filled.SaveAs, string.Format(T("Was not able to write '{0}'. Please make sure that the file is not opened in another application. The results of this run are incomplete on disk. The message is: '{1}'"), Path.GetFileName(targetFilePath), e.Message))); } } /// /// Reads the log of a previous batch run. The key is the relative path of /// the document. /// private async Task> ReadLogAsync(string logFilePath) { var entries = new Dictionary(StringComparer.OrdinalIgnoreCase); try { var content = await File.ReadAllTextAsync(logFilePath); var rows = BatchProcessingCsv.Parse(content); // The first row is the header, which we skip: foreach (var row in rows.Skip(1)) { if (row.Count < 5 || string.IsNullOrWhiteSpace(row[0])) continue; entries[row[0]] = new BatchProcessingLogEntry(row[0], row[1], row[2], row[3], row[4]); } } catch (Exception e) { this.Logger.LogWarning(e, "Was not able to read the log of the previous batch run at '{LogFilePath}'.", logFilePath); // Without this message, continuing the run would silently process // every document again, because we recognize nothing as completed: await this.MessageBus.SendWarning(new(Icons.Material.Filled.Warning, T("Was not able to read the log of the previous run. Continuing the run would process all documents again."))); } return entries; } /// /// Reads the AI answers of a previous batch run from the results table, so /// that continuing a run does not lose the answers of the previous run. /// private async Task> ReadPreviousResultsAsync(string resultsFilePath) { var results = new Dictionary(StringComparer.OrdinalIgnoreCase); try { if (!File.Exists(resultsFilePath)) return results; var content = await File.ReadAllTextAsync(resultsFilePath); foreach (var row in BatchProcessingCsv.Parse(content).Skip(1)) { if (row.Count < 2 || string.IsNullOrWhiteSpace(row[0])) continue; results[row[0]] = row[1]; } } catch (Exception e) { this.Logger.LogWarning(e, "Was not able to read the results table of the previous batch run at '{ResultsFilePath}'.", resultsFilePath); await this.MessageBus.SendWarning(new(Icons.Material.Filled.Warning, T("Was not able to read the results table of the previous run. Its completed documents cannot be restored and will be processed again."))); } return results; } /// /// Creates the name of the Markdown result file for one document. /// /// /// Two documents of the same run may share their name and differ only in /// their extension, e.g., report.docx and report.pdf. Both would map to /// report_result.md, so we add a counter for the second one. Otherwise, one /// result would silently overwrite the other. /// private string CreateResultFileName(string sourceFileName) { var stem = Path.GetFileNameWithoutExtension(sourceFileName); var candidate = $"{stem}{RESULT_FILE_SUFFIX}"; var counter = 2; while (!this.usedResultFileNames.Add(candidate)) { candidate = $"{stem}_result_{counter}.md"; counter++; } return candidate; } /// /// Resolves the file name of the CSV results table. This is the only output /// file the user may name; the log always uses . /// private string ResolveResultsFileName() { var name = this.csvFileName.Trim(); if (string.IsNullOrWhiteSpace(name)) return DEFAULT_RESULTS_FILENAME; return name.EndsWith(CSV_EXTENSION, StringComparison.OrdinalIgnoreCase) ? name : $"{name}{CSV_EXTENSION}"; } }