@model StoryIntelligenceSavedRunDetailViewModel @{ ViewData["Title"] = "Story Intelligence Run"; var run = Model.Run; var canCancel = run is not null && (run.Status == StoryIntelligenceRunStatuses.Pending || run.Status == StoryIntelligenceRunStatuses.Running); var shouldRefresh = run is not null && (canCancel || run.Status == "Running"); } @if (shouldRefresh) { }

Admin utility

Story Intelligence Run @(run is null ? string.Empty : run.StoryIntelligenceRunID.ToString("N0"))

Saved audit output from the admin chapter-to-scene dry-run pipeline.

@if (TempData["AdminMessage"] is string message) {
@message
} @if (run is null) {

This Story Intelligence run could not be found.

} else {

Run metadata

Status

@run.Status

Scenes

@Model.SceneResults.Count.ToString("N0")

Tokens

@Display(run.TotalTokens)

Duration

@DisplayDuration(run.TotalDurationMs)

Current stage
@Display(run.CurrentStage)
Current message
@Display(run.CurrentMessage)
Chapter structure status
@ChapterStructureStatus(Model.ChapterResult, run)
Scene progress
Detected: @Display(run.TotalDetectedScenes), completed: @Display(run.CompletedScenes), failed: @Display(run.FailedScenes)
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)
Submitted paragraph count
@Display(run.SourceParagraphCount)
First five numbered paragraphs
@FirstNumberedParagraphs(run)
Last paragraph number
@Display(LastParagraphNumber(run))
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")
Chapter
@DisplayChapter(run.ChapterID, run.ChapterNumber)
Prompt version
@run.PromptVersion
Prompt versions summary
@Display(run.PromptVersionsSummary)
Model
@run.Model
Chapter Structure model used
@ChapterStructureModelUsed(Model)
Scene Intelligence model used
@SceneIntelligenceModelUsed(Model)
Started
@run.StartedUtc.ToString("yyyy-MM-dd HH:mm:ss") UTC
Completed
@(run.CompletedUtc?.ToString("yyyy-MM-dd HH:mm:ss") ?? "None") UTC
Cancellation requested
@(run.CancellationRequestedUtc?.ToString("yyyy-MM-dd HH:mm:ss") ?? "None") UTC
Cancelled
@(run.CancelledUtc?.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)
@if (canCancel) {
} else if (CanRerun(run)) {
Run same source again with different models
}

Chapter result

@if (Model.ChapterResult is null) {

No chapter result was saved for this run.

} else { var chapter = Model.ChapterResult;
Source label
@chapter.SourceLabel
Chapter
@Display(chapter.ChapterNumber)
Prompt version
@chapter.PromptVersion
Model
@chapter.Model
Validation
@chapter.ValidationErrorsCount.ToString("N0") error(s), @chapter.ValidationWarningsCount.ToString("N0") warning(s)
Duration
@DisplayDuration(chapter.DurationMs)
Tokens
@Display(chapter.TotalTokens) total (@Display(chapter.InputTokens) input, @Display(chapter.OutputTokens) output)
Highest returned endParagraph
@Display(HighestRawReturnedEndParagraph(chapter))
Returned scene boundary ranges
@DisplayRawBoundaryRanges(chapter)
Normalised scene boundary ranges used
@DisplayNormalisedBoundaryRanges(chapter)
Chapter structure normalisation
@ChapterStructureNormalisation(chapter)
@if (ReturnedEndParagraphExceedsSubmitted(chapter, run)) {
The model returned paragraph numbers beyond the submitted chapter. This usually means the prompt was not using explicit numbered paragraphs.
} }

Scene results

@if (Model.SceneResults.Count == 0) {

No scene results were saved for this run.

}
@foreach (var scene in Model.SceneResults) {

Suggested Scene @scene.TemporarySceneNumber.ToString("N0")

Paragraph range
@Display(scene.StartParagraph)-@Display(scene.EndParagraph)
Source label
@scene.SourceLabel
Prompt version
@scene.PromptVersion
Model
@scene.Model
Validation
@scene.ValidationErrorsCount.ToString("N0") error(s), @scene.ValidationWarningsCount.ToString("N0") warning(s)
@if (IsFailedScene(scene)) {
Failure stage
@SceneFailureStage(scene)
Error message
@SceneErrorMessage(scene)
Retry attempted
@(SceneRetryAttempted(scene) ? "Yes" : "No")
Retry error
@Display(SceneRetryError(scene))
}
Duration
@DisplayDuration(scene.DurationMs)
Tokens
@Display(scene.TotalTokens) total (@Display(scene.InputTokens) input, @Display(scene.OutputTokens) output)
} } @functions { private static string Display(object? value) => value switch { null => "None", string text when string.IsNullOrWhiteSpace(text) => "None", decimal number => number.ToString("0.##"), int number => number.ToString("N0"), _ => value.ToString() ?? "None" }; private static string DisplayDuration(long? milliseconds) => milliseconds.HasValue ? TimeSpan.FromMilliseconds(milliseconds.Value).TotalSeconds < 1 ? $"{milliseconds.Value:N0} ms" : $"{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 ChapterStructureModelUsed(StoryIntelligenceSavedRunDetailViewModel model) => !string.IsNullOrWhiteSpace(model.ChapterResult?.Model) ? model.ChapterResult.Model : ParseModelPart(model.Run?.Model, "Chapter"); private static string SceneIntelligenceModelUsed(StoryIntelligenceSavedRunDetailViewModel model) { var sceneModels = model.SceneResults .Select(scene => scene.Model) .Where(value => !string.IsNullOrWhiteSpace(value)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); if (sceneModels.Count > 0) { return string.Join(", ", sceneModels); } return ParseModelPart(model.Run?.Model, "Scene"); } private static string ParseModelPart(string? summary, string key) { if (string.IsNullOrWhiteSpace(summary)) { return "None"; } foreach (var segment in summary.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { var parts = segment.Split('=', 2, StringSplitOptions.TrimEntries); if (parts.Length == 2 && string.Equals(parts[0], key, StringComparison.OrdinalIgnoreCase)) { return parts[1]; } } return summary; } private static string DisplayName(string? title, int? id, string label) => !string.IsNullOrWhiteSpace(title) ? title : id.HasValue ? $"{label} {id.Value:N0}" : "None"; private static string DisplayChapter(int? chapterId, decimal? chapterNumber) => chapterId.HasValue ? chapterNumber.HasValue ? $"Chapter {chapterNumber.Value:0.##} (ID {chapterId.Value:N0})" : $"Chapter ID {chapterId.Value:N0}" : "None"; private static string FirstNumberedParagraphs(StoryIntelligenceSavedRun run) { var paragraphs = StoryIntelligenceParagraphs.Split(run.SourceText); if (paragraphs.Count == 0) { return "None"; } return string.Join( Environment.NewLine + Environment.NewLine, paragraphs.Take(5).Select((paragraph, index) => $"[{index + 1}] {PreviewParagraph(paragraph)}")); } private static int? LastParagraphNumber(StoryIntelligenceSavedRun run) { var paragraphs = StoryIntelligenceParagraphs.Split(run.SourceText); return paragraphs.Count == 0 ? null : paragraphs.Count; } private static string ChapterStructureStatus(StoryIntelligenceSavedChapterResult? chapter, StoryIntelligenceSavedRun run) { if (chapter is not null) { return chapter.ValidationErrorsCount > 0 ? $"Saved with {chapter.ValidationErrorsCount:N0} validation error(s)." : "Saved."; } return string.Equals(run.FailureStage, StoryIntelligenceFailureStages.ChapterStructure, StringComparison.Ordinal) ? "Failed before a valid chapter structure result was saved." : "Not saved yet."; } private static bool IsFailedScene(StoryIntelligenceSavedSceneResult scene) => scene.ValidationErrorsCount > 0 && SceneJsonString(scene, "error") is not null; private static bool CanRerun(StoryIntelligenceSavedRun run) => !string.IsNullOrWhiteSpace(run.SourceText) && (run.Status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings or StoryIntelligenceRunStatuses.Failed or StoryIntelligenceRunStatuses.Cancelled); private static string SceneFailureStage(StoryIntelligenceSavedSceneResult scene) => SceneErrorMessage(scene).Contains("boundary", StringComparison.OrdinalIgnoreCase) ? StoryIntelligenceFailureStages.SceneSplit : StoryIntelligenceFailureStages.SceneIntelligence; private static string SceneErrorMessage(StoryIntelligenceSavedSceneResult scene) => SceneJsonString(scene, "error") ?? "Scene failed. See parsed JSON for details."; private static bool SceneRetryAttempted(StoryIntelligenceSavedSceneResult scene) { try { using var document = System.Text.Json.JsonDocument.Parse(scene.ParsedJson); if (document.RootElement.TryGetProperty("retryAttempted", out var retry) && retry.ValueKind is System.Text.Json.JsonValueKind.True or System.Text.Json.JsonValueKind.False) { return retry.GetBoolean(); } } catch (System.Text.Json.JsonException) { } return false; } private static string? SceneRetryError(StoryIntelligenceSavedSceneResult scene) => SceneJsonString(scene, "retryError"); private static string? SceneJsonString(StoryIntelligenceSavedSceneResult scene, string propertyName) { if (string.IsNullOrWhiteSpace(scene.ParsedJson)) { return null; } try { using var document = System.Text.Json.JsonDocument.Parse(scene.ParsedJson); if (document.RootElement.TryGetProperty(propertyName, out var value) && value.ValueKind == System.Text.Json.JsonValueKind.String) { return value.GetString(); } } catch (System.Text.Json.JsonException) { } return null; } private static int? HighestRawReturnedEndParagraph(StoryIntelligenceSavedChapterResult chapter) => ReadBoundaryRanges(chapter.OutputTextJson).Select(range => range.End).DefaultIfEmpty().Max() is var max && max > 0 ? max : null; private static string DisplayRawBoundaryRanges(StoryIntelligenceSavedChapterResult chapter) => DisplayBoundaryRanges(chapter.OutputTextJson); private static string DisplayNormalisedBoundaryRanges(StoryIntelligenceSavedChapterResult chapter) => DisplayBoundaryRanges(chapter.ParsedJson); private static string DisplayBoundaryRanges(string json) { var ranges = ReadBoundaryRanges(json).ToList(); return ranges.Count == 0 ? "None" : string.Join(", ", ranges.Select(range => $"{range.Start}-{range.End}")); } private static bool ReturnedEndParagraphExceedsSubmitted(StoryIntelligenceSavedChapterResult chapter, StoryIntelligenceSavedRun run) { var highest = HighestRawReturnedEndParagraph(chapter); var submitted = run.SourceParagraphCount; return highest.HasValue && submitted.HasValue && highest.Value > submitted.Value; } private static string ChapterStructureNormalisation(StoryIntelligenceSavedChapterResult chapter) { if (string.IsNullOrWhiteSpace(chapter.ParsedJson)) { return "None"; } try { using var document = System.Text.Json.JsonDocument.Parse(chapter.ParsedJson); if (!document.RootElement.TryGetProperty("normalisation", out var normalisation) || normalisation.ValueKind != System.Text.Json.JsonValueKind.Array) { return "None"; } var messages = normalisation .EnumerateArray() .Select(issue => issue.TryGetProperty("message", out var message) && message.ValueKind == System.Text.Json.JsonValueKind.String ? message.GetString() : null) .Where(message => !string.IsNullOrWhiteSpace(message)) .ToList(); return messages.Count == 0 ? "None" : string.Join(Environment.NewLine, messages); } catch (System.Text.Json.JsonException) { return "None"; } } private static IReadOnlyList<(int Start, int End)> ReadBoundaryRanges(string json) { if (string.IsNullOrWhiteSpace(json)) { return []; } try { using var document = System.Text.Json.JsonDocument.Parse(json); if (!document.RootElement.TryGetProperty("sceneBoundaries", out var boundaries) || boundaries.ValueKind != System.Text.Json.JsonValueKind.Array) { return []; } var ranges = new List<(int Start, int End)>(); foreach (var boundary in boundaries.EnumerateArray()) { var start = boundary.TryGetProperty("startParagraph", out var startElement) && startElement.TryGetInt32(out var startValue) ? startValue : 0; var end = boundary.TryGetProperty("endParagraph", out var endElement) && endElement.TryGetInt32(out var endValue) ? endValue : 0; ranges.Add((start, end)); } return ranges; } catch (System.Text.Json.JsonException) { return []; } } private static string PreviewParagraph(string value) { var cleaned = value.ReplaceLineEndings(" ").Trim(); return cleaned.Length <= 220 ? cleaned : $"{cleaned[..220]}..."; } }