diff --git a/PlotLine/Data/StoryIntelligenceResultRepository.cs b/PlotLine/Data/StoryIntelligenceResultRepository.cs index 489df6d..2ac5103 100644 --- a/PlotLine/Data/StoryIntelligenceResultRepository.cs +++ b/PlotLine/Data/StoryIntelligenceResultRepository.cs @@ -32,15 +32,29 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn request.BookID, request.Status, request.SourceType, + request.SourceFileName, + request.SourceFileSizeBytes, + request.SourceWordCount, + request.SourceCharacterCount, + request.SourceChapterCount, + request.SourceDetectedImagesCount, + request.SourceDetectedTablesCount, + request.SourceDetectedFootnotesCount, + request.SourceDetectedCommentsCount, request.PromptVersion, request.Model, request.StartedUtc, request.CompletedUtc, + request.FailureStage, request.TotalInputTokens, request.TotalOutputTokens, request.TotalTokens, request.TotalDurationMs, - request.ErrorMessage + request.EstimatedCostGBP, + request.EstimatedCostUSD, + request.PromptVersionsSummary, + request.ErrorMessage, + request.ErrorDetail }, transaction, commandType: CommandType.StoredProcedure); diff --git a/PlotLine/Models/StoryIntelligencePersistenceModels.cs b/PlotLine/Models/StoryIntelligencePersistenceModels.cs index e35ec57..826352c 100644 --- a/PlotLine/Models/StoryIntelligencePersistenceModels.cs +++ b/PlotLine/Models/StoryIntelligencePersistenceModels.cs @@ -2,9 +2,23 @@ namespace PlotLine.Models; public static class StoryIntelligenceRunStatuses { + public const string Pending = "Pending"; + public const string Running = "Running"; public const string Completed = "Completed"; - public const string CompletedWithIssues = "CompletedWithIssues"; + public const string CompletedWithWarnings = "CompletedWithWarnings"; public const string Failed = "Failed"; + public const string Cancelled = "Cancelled"; +} + +public static class StoryIntelligenceFailureStages +{ + public const string DocumentRead = "DocumentRead"; + public const string ChapterStructure = "ChapterStructure"; + public const string SceneSplit = "SceneSplit"; + public const string SceneIntelligence = "SceneIntelligence"; + public const string Validation = "Validation"; + public const string Persistence = "Persistence"; + public const string DomainImport = "DomainImport"; } public sealed class StoryIntelligenceRunSaveRequest @@ -14,15 +28,29 @@ public sealed class StoryIntelligenceRunSaveRequest public int? BookID { get; init; } public string Status { get; init; } = StoryIntelligenceRunStatuses.Completed; public string SourceType { get; init; } = "AdminDryRun"; + public string? SourceFileName { get; init; } + public long? SourceFileSizeBytes { get; init; } + public int? SourceWordCount { get; init; } + public int? SourceCharacterCount { get; init; } + public int? SourceChapterCount { get; init; } + public int? SourceDetectedImagesCount { get; init; } + public int? SourceDetectedTablesCount { get; init; } + public int? SourceDetectedFootnotesCount { get; init; } + public int? SourceDetectedCommentsCount { get; init; } public string PromptVersion { get; init; } = string.Empty; public string Model { get; init; } = string.Empty; public DateTime StartedUtc { get; init; } public DateTime? CompletedUtc { get; init; } + public string? FailureStage { get; init; } public int? TotalInputTokens { get; init; } public int? TotalOutputTokens { get; init; } public int? TotalTokens { get; init; } public long? TotalDurationMs { get; init; } + public decimal? EstimatedCostGBP { get; init; } + public decimal? EstimatedCostUSD { get; init; } + public string? PromptVersionsSummary { get; init; } public string? ErrorMessage { get; init; } + public string? ErrorDetail { get; init; } public StoryIntelligenceChapterResultSaveRequest? ChapterResult { get; init; } public IReadOnlyList SceneResults { get; init; } = []; } @@ -80,9 +108,13 @@ public sealed class StoryIntelligenceSavedRunListItem public int? BookID { get; init; } public string? BookTitle { get; init; } public string Status { get; init; } = string.Empty; + public string SourceType { get; init; } = string.Empty; + public string? FailureStage { get; init; } public int SceneCount { get; init; } public int? TotalTokens { get; init; } public long? TotalDurationMs { get; init; } + public decimal? EstimatedCostGBP { get; init; } + public decimal? EstimatedCostUSD { get; init; } public int ValidationErrorsCount { get; init; } public int ValidationWarningsCount { get; init; } } @@ -97,15 +129,29 @@ public sealed class StoryIntelligenceSavedRun public string? BookTitle { get; init; } public string Status { get; init; } = string.Empty; public string SourceType { get; init; } = string.Empty; + public string? SourceFileName { get; init; } + public long? SourceFileSizeBytes { get; init; } + public int? SourceWordCount { get; init; } + public int? SourceCharacterCount { get; init; } + public int? SourceChapterCount { get; init; } + public int? SourceDetectedImagesCount { get; init; } + public int? SourceDetectedTablesCount { get; init; } + public int? SourceDetectedFootnotesCount { get; init; } + public int? SourceDetectedCommentsCount { get; init; } public string PromptVersion { get; init; } = string.Empty; + public string? PromptVersionsSummary { get; init; } public string Model { get; init; } = string.Empty; public DateTime StartedUtc { get; init; } public DateTime? CompletedUtc { get; init; } + public string? FailureStage { get; init; } public int? TotalInputTokens { get; init; } public int? TotalOutputTokens { get; init; } public int? TotalTokens { get; init; } public long? TotalDurationMs { get; init; } + public decimal? EstimatedCostGBP { get; init; } + public decimal? EstimatedCostUSD { get; init; } public string? ErrorMessage { get; init; } + public string? ErrorDetail { get; init; } public DateTime CreatedUtc { get; init; } public DateTime UpdatedUtc { get; init; } } diff --git a/PlotLine/Services/StoryIntelligenceResultPersistenceService.cs b/PlotLine/Services/StoryIntelligenceResultPersistenceService.cs index af7acf4..fad9fa1 100644 --- a/PlotLine/Services/StoryIntelligenceResultPersistenceService.cs +++ b/PlotLine/Services/StoryIntelligenceResultPersistenceService.cs @@ -86,11 +86,13 @@ public sealed class StoryIntelligenceResultPersistenceService( : completedUtc; var errorCount = CountErrors(result); var warningCount = CountWarnings(result); - var status = !string.IsNullOrWhiteSpace(result.ErrorMessage) || result.PipelineStopped + var sceneFailed = result.SceneBlocks.Any(block => !block.Success && block.SplitValid); + var status = !string.IsNullOrWhiteSpace(result.ErrorMessage) || result.PipelineStopped || errorCount > 0 || sceneFailed ? StoryIntelligenceRunStatuses.Failed - : errorCount > 0 || warningCount > 0 || result.SceneBlocks.Any(block => !block.Success && block.SplitValid) - ? StoryIntelligenceRunStatuses.CompletedWithIssues + : warningCount > 0 + ? StoryIntelligenceRunStatuses.CompletedWithWarnings : StoryIntelligenceRunStatuses.Completed; + var promptVersionsSummary = $"Chapter={result.ChapterPromptVersion}; Scene={result.ScenePromptVersion}"; return new StoryIntelligenceRunSaveRequest { @@ -99,15 +101,21 @@ public sealed class StoryIntelligenceResultPersistenceService( BookID = form.BookID, Status = status, SourceType = "AdminDryRun", - PromptVersion = $"Chapter={result.ChapterPromptVersion}; Scene={result.ScenePromptVersion}", + SourceWordCount = CountWords(form.ChapterText), + SourceCharacterCount = string.IsNullOrEmpty(form.ChapterText) ? null : form.ChapterText.Length, + SourceChapterCount = 1, + PromptVersion = promptVersionsSummary, + PromptVersionsSummary = promptVersionsSummary, Model = result.Model, StartedUtc = startedUtc, CompletedUtc = completedUtc, + FailureStage = DetermineFailureStage(result), TotalInputTokens = result.TotalInputTokens, TotalOutputTokens = result.TotalOutputTokens, TotalTokens = SumTokens(result.TotalInputTokens, result.TotalOutputTokens), TotalDurationMs = result.TotalDurationMs, ErrorMessage = FirstError(result), + ErrorDetail = BuildErrorDetail(result), ChapterResult = BuildChapterResult(form, result), SceneResults = result.SceneBlocks.Select(block => BuildSceneResult(form, result, block)).ToList() }; @@ -172,6 +180,11 @@ public sealed class StoryIntelligenceResultPersistenceService( private static int? SumTokens(int? inputTokens, int? outputTokens) => inputTokens.HasValue || outputTokens.HasValue ? (inputTokens ?? 0) + (outputTokens ?? 0) : null; + private static int? CountWords(string? value) + => string.IsNullOrWhiteSpace(value) + ? null + : value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length; + private static int CountErrors(ChapterStoryIntelligenceDryRunResultViewModel result) => (result.ChapterValidation?.Errors.Count ?? 0) + result.SceneBlocks.Sum(block => block.Validation?.Errors.Count ?? 0); @@ -184,4 +197,57 @@ public sealed class StoryIntelligenceResultPersistenceService( => !string.IsNullOrWhiteSpace(result.ErrorMessage) ? result.ErrorMessage : result.SceneBlocks.FirstOrDefault(block => !string.IsNullOrWhiteSpace(block.ErrorMessage))?.ErrorMessage; + + private static string? DetermineFailureStage(ChapterStoryIntelligenceDryRunResultViewModel result) + { + if (string.IsNullOrWhiteSpace(result.ErrorMessage) + && !result.PipelineStopped + && result.ChapterValidation?.Errors.Count is not > 0 + && result.SceneBlocks.All(block => block.Success || !block.SplitValid) + && result.SceneBlocks.All(block => block.Validation?.Errors.Count is not > 0)) + { + return null; + } + + if (result.ChapterValidation?.Errors.Count is > 0 || result.StopReason.Contains("validation", StringComparison.OrdinalIgnoreCase)) + { + return StoryIntelligenceFailureStages.Validation; + } + + if (result.SceneBlocks.Any(block => !block.SplitValid)) + { + return StoryIntelligenceFailureStages.SceneSplit; + } + + if (result.SceneBlocks.Any(block => !block.Success || !string.IsNullOrWhiteSpace(block.ErrorMessage))) + { + return StoryIntelligenceFailureStages.SceneIntelligence; + } + + return StoryIntelligenceFailureStages.ChapterStructure; + } + + private static string? BuildErrorDetail(ChapterStoryIntelligenceDryRunResultViewModel result) + { + var details = new List(); + if (!string.IsNullOrWhiteSpace(result.StopReason)) + { + details.Add(result.StopReason); + } + + if (!string.IsNullOrWhiteSpace(result.ErrorMessage)) + { + details.Add(result.ErrorMessage); + } + + details.AddRange(result.ChapterValidation?.Errors.Select(issue => $"{issue.Path}: {issue.Message}") ?? []); + details.AddRange(result.SceneBlocks + .Where(block => !string.IsNullOrWhiteSpace(block.ErrorMessage)) + .Select(block => $"Scene {block.TemporarySceneNumber}: {block.ErrorMessage}")); + details.AddRange(result.SceneBlocks + .Where(block => block.Validation is not null) + .SelectMany(block => block.Validation!.Errors.Select(issue => $"Scene {block.TemporarySceneNumber} {issue.Path}: {issue.Message}"))); + + return details.Count == 0 ? null : string.Join(Environment.NewLine, details); + } } diff --git a/PlotLine/Sql/116_Phase20J_StoryIntelligenceRunAuditFields.sql b/PlotLine/Sql/116_Phase20J_StoryIntelligenceRunAuditFields.sql new file mode 100644 index 0000000..54885f9 --- /dev/null +++ b/PlotLine/Sql/116_Phase20J_StoryIntelligenceRunAuditFields.sql @@ -0,0 +1,187 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'SourceFileName') IS NULL + ALTER TABLE dbo.StoryIntelligenceRuns ADD SourceFileName nvarchar(260) NULL; +GO +IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'SourceFileSizeBytes') IS NULL + ALTER TABLE dbo.StoryIntelligenceRuns ADD SourceFileSizeBytes bigint NULL; +GO +IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'SourceWordCount') IS NULL + ALTER TABLE dbo.StoryIntelligenceRuns ADD SourceWordCount int NULL; +GO +IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'SourceCharacterCount') IS NULL + ALTER TABLE dbo.StoryIntelligenceRuns ADD SourceCharacterCount int NULL; +GO +IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'SourceChapterCount') IS NULL + ALTER TABLE dbo.StoryIntelligenceRuns ADD SourceChapterCount int NULL; +GO +IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'SourceDetectedImagesCount') IS NULL + ALTER TABLE dbo.StoryIntelligenceRuns ADD SourceDetectedImagesCount int NULL; +GO +IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'SourceDetectedTablesCount') IS NULL + ALTER TABLE dbo.StoryIntelligenceRuns ADD SourceDetectedTablesCount int NULL; +GO +IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'SourceDetectedFootnotesCount') IS NULL + ALTER TABLE dbo.StoryIntelligenceRuns ADD SourceDetectedFootnotesCount int NULL; +GO +IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'SourceDetectedCommentsCount') IS NULL + ALTER TABLE dbo.StoryIntelligenceRuns ADD SourceDetectedCommentsCount int NULL; +GO +IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'FailureStage') IS NULL + ALTER TABLE dbo.StoryIntelligenceRuns ADD FailureStage nvarchar(80) NULL; +GO +IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'ErrorDetail') IS NULL + ALTER TABLE dbo.StoryIntelligenceRuns ADD ErrorDetail nvarchar(max) NULL; +GO +IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'EstimatedCostGBP') IS NULL + ALTER TABLE dbo.StoryIntelligenceRuns ADD EstimatedCostGBP decimal(19,6) NULL; +GO +IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'EstimatedCostUSD') IS NULL + ALTER TABLE dbo.StoryIntelligenceRuns ADD EstimatedCostUSD decimal(19,6) NULL; +GO +IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'PromptVersionsSummary') IS NULL + ALTER TABLE dbo.StoryIntelligenceRuns ADD PromptVersionsSummary nvarchar(500) NULL; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_Save + @UserID int, + @ProjectID int = NULL, + @BookID int = NULL, + @Status nvarchar(50), + @SourceType nvarchar(50), + @SourceFileName nvarchar(260) = NULL, + @SourceFileSizeBytes bigint = NULL, + @SourceWordCount int = NULL, + @SourceCharacterCount int = NULL, + @SourceChapterCount int = NULL, + @SourceDetectedImagesCount int = NULL, + @SourceDetectedTablesCount int = NULL, + @SourceDetectedFootnotesCount int = NULL, + @SourceDetectedCommentsCount int = NULL, + @PromptVersion nvarchar(200), + @Model nvarchar(100), + @StartedUtc datetime2, + @CompletedUtc datetime2 = NULL, + @FailureStage nvarchar(80) = NULL, + @TotalInputTokens int = NULL, + @TotalOutputTokens int = NULL, + @TotalTokens int = NULL, + @TotalDurationMs bigint = NULL, + @EstimatedCostGBP decimal(19,6) = NULL, + @EstimatedCostUSD decimal(19,6) = NULL, + @PromptVersionsSummary nvarchar(500) = NULL, + @ErrorMessage nvarchar(max) = NULL, + @ErrorDetail nvarchar(max) = NULL +AS +BEGIN + SET NOCOUNT ON; + + INSERT dbo.StoryIntelligenceRuns + ( + UserID, ProjectID, BookID, Status, SourceType, SourceFileName, SourceFileSizeBytes, + SourceWordCount, SourceCharacterCount, SourceChapterCount, SourceDetectedImagesCount, + SourceDetectedTablesCount, SourceDetectedFootnotesCount, SourceDetectedCommentsCount, + PromptVersion, Model, StartedUtc, CompletedUtc, FailureStage, TotalInputTokens, + TotalOutputTokens, TotalTokens, TotalDurationMs, EstimatedCostGBP, EstimatedCostUSD, + PromptVersionsSummary, ErrorMessage, ErrorDetail + ) + VALUES + ( + @UserID, @ProjectID, @BookID, @Status, @SourceType, @SourceFileName, @SourceFileSizeBytes, + @SourceWordCount, @SourceCharacterCount, @SourceChapterCount, @SourceDetectedImagesCount, + @SourceDetectedTablesCount, @SourceDetectedFootnotesCount, @SourceDetectedCommentsCount, + @PromptVersion, @Model, @StartedUtc, @CompletedUtc, @FailureStage, @TotalInputTokens, + @TotalOutputTokens, @TotalTokens, @TotalDurationMs, @EstimatedCostGBP, @EstimatedCostUSD, + @PromptVersionsSummary, @ErrorMessage, @ErrorDetail + ); + + SELECT CAST(SCOPE_IDENTITY() AS int) AS StoryIntelligenceRunID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_ListAdmin +AS +BEGIN + SET NOCOUNT ON; + + SELECT + r.StoryIntelligenceRunID, + r.CreatedUtc, + r.UserID, + r.ProjectID, + p.ProjectName AS ProjectTitle, + r.BookID, + b.BookTitle, + r.Status, + r.SourceType, + r.FailureStage, + COUNT(sr.SceneResultID) AS SceneCount, + r.TotalTokens, + r.TotalDurationMs, + r.EstimatedCostGBP, + r.EstimatedCostUSD, + COALESCE(cr.ValidationErrorsCount, 0) + COALESCE(SUM(sr.ValidationErrorsCount), 0) AS ValidationErrorsCount, + COALESCE(cr.ValidationWarningsCount, 0) + COALESCE(SUM(sr.ValidationWarningsCount), 0) AS ValidationWarningsCount + FROM dbo.StoryIntelligenceRuns r + LEFT JOIN dbo.Projects p ON p.ProjectID = r.ProjectID + LEFT JOIN dbo.Books b ON b.BookID = r.BookID + LEFT JOIN dbo.StoryIntelligenceChapterResults cr ON cr.StoryIntelligenceRunID = r.StoryIntelligenceRunID + LEFT JOIN dbo.StoryIntelligenceSceneResults sr ON sr.StoryIntelligenceRunID = r.StoryIntelligenceRunID + GROUP BY + r.StoryIntelligenceRunID, r.CreatedUtc, r.UserID, r.ProjectID, p.ProjectName, + r.BookID, b.BookTitle, r.Status, r.SourceType, r.FailureStage, r.TotalTokens, + r.TotalDurationMs, r.EstimatedCostGBP, r.EstimatedCostUSD, + cr.ValidationErrorsCount, cr.ValidationWarningsCount + ORDER BY r.CreatedUtc DESC; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_GetAdmin + @StoryIntelligenceRunID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT + r.StoryIntelligenceRunID, + r.UserID, + r.ProjectID, + p.ProjectName AS ProjectTitle, + r.BookID, + b.BookTitle, + r.Status, + r.SourceType, + r.SourceFileName, + r.SourceFileSizeBytes, + r.SourceWordCount, + r.SourceCharacterCount, + r.SourceChapterCount, + r.SourceDetectedImagesCount, + r.SourceDetectedTablesCount, + r.SourceDetectedFootnotesCount, + r.SourceDetectedCommentsCount, + r.PromptVersion, + r.PromptVersionsSummary, + r.Model, + r.StartedUtc, + r.CompletedUtc, + r.FailureStage, + r.TotalInputTokens, + r.TotalOutputTokens, + r.TotalTokens, + r.TotalDurationMs, + r.EstimatedCostGBP, + r.EstimatedCostUSD, + r.ErrorMessage, + r.ErrorDetail, + r.CreatedUtc, + r.UpdatedUtc + FROM dbo.StoryIntelligenceRuns r + LEFT JOIN dbo.Projects p ON p.ProjectID = r.ProjectID + LEFT JOIN dbo.Books b ON b.BookID = r.BookID + WHERE r.StoryIntelligenceRunID = @StoryIntelligenceRunID; +END; +GO diff --git a/PlotLine/Views/Admin/StoryIntelligenceRunDetails.cshtml b/PlotLine/Views/Admin/StoryIntelligenceRunDetails.cshtml index 82c6fe3..ecf7e03 100644 --- a/PlotLine/Views/Admin/StoryIntelligenceRunDetails.cshtml +++ b/PlotLine/Views/Admin/StoryIntelligenceRunDetails.cshtml @@ -66,24 +66,49 @@ else
Source type
@run.SourceType
+
Source file
+
@Display(run.SourceFileName)
+
Source file size
+
@DisplayBytes(run.SourceFileSizeBytes)
+
Source word count
+
@Display(run.SourceWordCount)
+
Source character count
+
@Display(run.SourceCharacterCount)
+
Source chapter count
+
@Display(run.SourceChapterCount)
+
Detected document features
+
+ Images: @Display(run.SourceDetectedImagesCount), + tables: @Display(run.SourceDetectedTablesCount), + footnotes: @Display(run.SourceDetectedFootnotesCount), + comments: @Display(run.SourceDetectedCommentsCount) +
Project
@DisplayName(run.ProjectTitle, run.ProjectID, "Project")
Book
@DisplayName(run.BookTitle, run.BookID, "Book")
Prompt version
@run.PromptVersion
+
Prompt versions summary
+
@Display(run.PromptVersionsSummary)
Model
@run.Model
Started
@run.StartedUtc.ToString("yyyy-MM-dd HH:mm:ss") UTC
Completed
@(run.CompletedUtc?.ToString("yyyy-MM-dd HH:mm:ss") ?? "None") UTC
+
Failure stage
+
@Display(run.FailureStage)
Input tokens
@Display(run.TotalInputTokens)
Output tokens
@Display(run.TotalOutputTokens)
+
Estimated cost
+
@DisplayCost(run.EstimatedCostGBP, "GBP") / @DisplayCost(run.EstimatedCostUSD, "USD")
Error message
@Display(run.ErrorMessage)
+
Error detail
+
@Display(run.ErrorDetail)
@@ -174,6 +199,12 @@ else : $"{TimeSpan.FromMilliseconds(milliseconds.Value).TotalSeconds:0.00} sec" : "None"; + private static string DisplayBytes(long? bytes) + => bytes.HasValue ? $"{bytes.Value:N0} bytes" : "None"; + + private static string DisplayCost(decimal? value, string currency) + => value.HasValue ? $"{value.Value:0.000000} {currency}" : "None"; + private static string DisplayName(string? title, int? id, string label) => !string.IsNullOrWhiteSpace(title) ? title diff --git a/PlotLine/Views/Admin/StoryIntelligenceRuns.cshtml b/PlotLine/Views/Admin/StoryIntelligenceRuns.cshtml index 41c67d8..6c12875 100644 --- a/PlotLine/Views/Admin/StoryIntelligenceRuns.cshtml +++ b/PlotLine/Views/Admin/StoryIntelligenceRuns.cshtml @@ -43,8 +43,11 @@ Created Project / Book Status + Source + Failure stage Scenes Tokens + Cost Duration Validation @@ -61,8 +64,11 @@
@DisplayName(run.BookTitle, run.BookID, "Book")
@run.Status + @Display(run.SourceType) + @Display(run.FailureStage) @run.SceneCount.ToString("N0") @Display(run.TotalTokens) + @DisplayCost(run.EstimatedCostGBP, "GBP") / @DisplayCost(run.EstimatedCostUSD, "USD") @DisplayDuration(run.TotalDurationMs) @run.ValidationErrorsCount.ToString("N0") error(s), @run.ValidationWarningsCount.ToString("N0") warning(s) Open @@ -90,6 +96,9 @@ : $"{TimeSpan.FromMilliseconds(milliseconds.Value).TotalSeconds:0.00} sec" : "None"; + private static string DisplayCost(decimal? value, string currency) + => value.HasValue ? $"{value.Value:0.000000} {currency}" : "None"; + private static string DisplayName(string? title, int? id, string label) => !string.IsNullOrWhiteSpace(title) ? title