Phase 20O.1 Extend the Story Intelligence persistence/audit design so every import attempt is fully traceable.

This commit is contained in:
Nick Beckley 2026-07-04 21:17:22 +01:00
parent 35e76d8ac4
commit 650febab70
6 changed files with 359 additions and 6 deletions

View File

@ -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);

View File

@ -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<StoryIntelligenceSceneResultSaveRequest> 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; }
}

View File

@ -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<string>();
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);
}
}

View File

@ -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

View File

@ -66,24 +66,49 @@ else
<dl class="mt-3 mb-0">
<dt>Source type</dt>
<dd>@run.SourceType</dd>
<dt>Source file</dt>
<dd>@Display(run.SourceFileName)</dd>
<dt>Source file size</dt>
<dd>@DisplayBytes(run.SourceFileSizeBytes)</dd>
<dt>Source word count</dt>
<dd>@Display(run.SourceWordCount)</dd>
<dt>Source character count</dt>
<dd>@Display(run.SourceCharacterCount)</dd>
<dt>Source chapter count</dt>
<dd>@Display(run.SourceChapterCount)</dd>
<dt>Detected document features</dt>
<dd>
Images: @Display(run.SourceDetectedImagesCount),
tables: @Display(run.SourceDetectedTablesCount),
footnotes: @Display(run.SourceDetectedFootnotesCount),
comments: @Display(run.SourceDetectedCommentsCount)
</dd>
<dt>Project</dt>
<dd>@DisplayName(run.ProjectTitle, run.ProjectID, "Project")</dd>
<dt>Book</dt>
<dd>@DisplayName(run.BookTitle, run.BookID, "Book")</dd>
<dt>Prompt version</dt>
<dd>@run.PromptVersion</dd>
<dt>Prompt versions summary</dt>
<dd>@Display(run.PromptVersionsSummary)</dd>
<dt>Model</dt>
<dd>@run.Model</dd>
<dt>Started</dt>
<dd>@run.StartedUtc.ToString("yyyy-MM-dd HH:mm:ss") UTC</dd>
<dt>Completed</dt>
<dd>@(run.CompletedUtc?.ToString("yyyy-MM-dd HH:mm:ss") ?? "None") UTC</dd>
<dt>Failure stage</dt>
<dd>@Display(run.FailureStage)</dd>
<dt>Input tokens</dt>
<dd>@Display(run.TotalInputTokens)</dd>
<dt>Output tokens</dt>
<dd>@Display(run.TotalOutputTokens)</dd>
<dt>Estimated cost</dt>
<dd>@DisplayCost(run.EstimatedCostGBP, "GBP") / @DisplayCost(run.EstimatedCostUSD, "USD")</dd>
<dt>Error message</dt>
<dd>@Display(run.ErrorMessage)</dd>
<dt>Error detail</dt>
<dd><pre class="mb-0">@Display(run.ErrorDetail)</pre></dd>
</dl>
</section>
@ -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

View File

@ -43,8 +43,11 @@
<th>Created</th>
<th>Project / Book</th>
<th>Status</th>
<th>Source</th>
<th>Failure stage</th>
<th>Scenes</th>
<th>Tokens</th>
<th>Cost</th>
<th>Duration</th>
<th>Validation</th>
<th></th>
@ -61,8 +64,11 @@
<div class="muted">@DisplayName(run.BookTitle, run.BookID, "Book")</div>
</td>
<td>@run.Status</td>
<td>@Display(run.SourceType)</td>
<td>@Display(run.FailureStage)</td>
<td>@run.SceneCount.ToString("N0")</td>
<td>@Display(run.TotalTokens)</td>
<td>@DisplayCost(run.EstimatedCostGBP, "GBP") / @DisplayCost(run.EstimatedCostUSD, "USD")</td>
<td>@DisplayDuration(run.TotalDurationMs)</td>
<td>@run.ValidationErrorsCount.ToString("N0") error(s), @run.ValidationWarningsCount.ToString("N0") warning(s)</td>
<td><a class="btn btn-outline-primary btn-sm" asp-action="StoryIntelligenceRunDetails" asp-route-id="@run.StoryIntelligenceRunID">Open</a></td>
@ -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