PlotDirector/PlotLine/Views/Admin/StoryIntelligenceRunDetails.cshtml

732 lines
30 KiB
Plaintext

@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)
{
<meta http-equiv="refresh" content="10" />
}
<div class="page-heading compact">
<div>
<p class="eyebrow">Admin utility</p>
<h1>Story Intelligence Run @(run is null ? string.Empty : run.StoryIntelligenceRunID.ToString("N0"))</h1>
<p class="lead-text">Saved audit output from the admin chapter-to-scene dry-run pipeline.</p>
</div>
</div>
<nav class="mb-3" aria-label="Breadcrumb">
<a asp-controller="Projects" asp-action="Index">Projects</a>
<span class="muted"> / </span>
<a asp-action="Index">Admin</a>
<span class="muted"> / </span>
<a asp-action="StoryIntelligenceRuns">Saved Story Intelligence Runs</a>
<span class="muted"> / Run @(run is null ? string.Empty : run.StoryIntelligenceRunID.ToString("N0"))</span>
</nav>
@if (TempData["AdminMessage"] is string message)
{
<div class="alert alert-success">@message</div>
}
@if (TempData["AdminError"] is string error)
{
<div class="alert alert-danger">@error</div>
}
@if (run is null)
{
<section class="edit-panel">
<p class="mb-0">This Story Intelligence run could not be found.</p>
</section>
}
else
{
<section class="edit-panel">
<h2>Run metadata</h2>
<div class="row g-3">
<div class="col-md-3">
<div class="border rounded p-3 h-100">
<p class="eyebrow">Status</p>
<h3 class="mb-0">@run.Status</h3>
</div>
</div>
<div class="col-md-3">
<div class="border rounded p-3 h-100">
<p class="eyebrow">Scenes</p>
<h3 class="mb-0">@Model.SceneResults.Count.ToString("N0")</h3>
</div>
</div>
<div class="col-md-3">
<div class="border rounded p-3 h-100">
<p class="eyebrow">Tokens</p>
<h3 class="mb-0">@Display(run.TotalTokens)</h3>
</div>
</div>
<div class="col-md-3">
<div class="border rounded p-3 h-100">
<p class="eyebrow">Duration</p>
<h3 class="mb-0">@DisplayDuration(run.TotalDurationMs)</h3>
</div>
</div>
</div>
<dl class="mt-3 mb-0">
<dt>Current stage</dt>
<dd>@Display(run.CurrentStage)</dd>
<dt>Current message</dt>
<dd>@Display(run.CurrentMessage)</dd>
<dt>Chapter structure status</dt>
<dd>@ChapterStructureStatus(Model.ChapterResult, run)</dd>
<dt>Scene progress</dt>
<dd>
Detected: @Display(run.TotalDetectedScenes),
completed: @Display(run.CompletedScenes),
failed: @Display(run.FailedScenes)
</dd>
<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>Submitted paragraph count</dt>
<dd>@Display(run.SourceParagraphCount)</dd>
<dt>First five numbered paragraphs</dt>
<dd><pre class="mb-0">@FirstNumberedParagraphs(run)</pre></dd>
<dt>Last paragraph number</dt>
<dd>@Display(LastParagraphNumber(run))</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>Chapter</dt>
<dd>@DisplayChapter(run.ChapterID, run.ChapterNumber)</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>Chapter Structure model used</dt>
<dd>@ChapterStructureModelUsed(Model)</dd>
<dt>Scene Intelligence model used</dt>
<dd>@SceneIntelligenceModelUsed(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>Cancellation requested</dt>
<dd>@(run.CancellationRequestedUtc?.ToString("yyyy-MM-dd HH:mm:ss") ?? "None") UTC</dd>
<dt>Cancelled</dt>
<dd>@(run.CancelledUtc?.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>
@if (canCancel)
{
<form asp-action="CancelStoryIntelligenceRun" asp-route-id="@run.StoryIntelligenceRunID" method="post" class="mt-3">
<button class="btn btn-outline-danger" type="submit">Cancel run</button>
</form>
}
else if (CanRerun(run))
{
<div class="mt-3">
<a class="btn btn-outline-primary" asp-action="StoryIntelligenceFullChapterTest" asp-route-sourceRunId="@run.StoryIntelligenceRunID">Run same source again with different models</a>
</div>
}
<div class="mt-3">
@if (Model.ImportCommit is not null && string.Equals(Model.ImportCommit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase))
{
<div class="alert alert-success mb-0">
This run was committed on @Model.ImportCommit.CommittedAt.ToString("yyyy-MM-dd HH:mm:ss") UTC.
Created @Model.ImportCommit.ScenesCreated.ToString("N0") scene@(Model.ImportCommit.ScenesCreated == 1 ? string.Empty : "s")
and @Model.ImportCommit.MetricsCreated.ToString("N0") metric value@(Model.ImportCommit.MetricsCreated == 1 ? string.Empty : "s").
</div>
}
else if (CanCommitImport(run, Model.SceneResults))
{
<a class="btn btn-primary" asp-action="ConfirmStoryIntelligenceImportCommit" asp-route-id="@run.StoryIntelligenceRunID">Commit Import</a>
}
</div>
</section>
<section class="edit-panel">
<h2>Chapter result</h2>
@if (Model.ChapterResult is null)
{
<p class="mb-0">No chapter result was saved for this run.</p>
}
else
{
var chapter = Model.ChapterResult;
<dl>
<dt>Source label</dt>
<dd>@chapter.SourceLabel</dd>
<dt>Chapter</dt>
<dd>@Display(chapter.ChapterNumber)</dd>
<dt>Prompt version</dt>
<dd>@chapter.PromptVersion</dd>
<dt>Model</dt>
<dd>@chapter.Model</dd>
<dt>Validation</dt>
<dd>@chapter.ValidationErrorsCount.ToString("N0") error(s), @chapter.ValidationWarningsCount.ToString("N0") warning(s)</dd>
<dt>Duration</dt>
<dd>@DisplayDuration(chapter.DurationMs)</dd>
<dt>Tokens</dt>
<dd>@Display(chapter.TotalTokens) total (@Display(chapter.InputTokens) input, @Display(chapter.OutputTokens) output)</dd>
</dl>
<dl>
<dt>Highest returned endParagraph</dt>
<dd>@Display(HighestRawReturnedEndParagraph(chapter))</dd>
<dt>Returned scene boundary ranges</dt>
<dd>@DisplayRawBoundaryRanges(chapter)</dd>
<dt>Normalised scene boundary ranges used</dt>
<dd>@DisplayNormalisedBoundaryRanges(chapter)</dd>
<dt>Chapter structure normalisation</dt>
<dd>@ChapterStructureNormalisation(chapter)</dd>
</dl>
@if (ReturnedEndParagraphExceedsSubmitted(chapter, run))
{
<div class="alert alert-warning">
The model returned paragraph numbers beyond the submitted chapter. This usually means the prompt was not using explicit numbered paragraphs.
</div>
}
<label class="form-label" for="chapterParsedJson">Parsed / normalised JSON used for validation and splitting</label>
<textarea class="form-control font-monospace" id="chapterParsedJson" rows="10" readonly>@chapter.ParsedJson</textarea>
<label class="form-label mt-3" for="chapterOutputJson">Extracted output JSON</label>
<textarea class="form-control font-monospace" id="chapterOutputJson" rows="10" readonly>@chapter.OutputTextJson</textarea>
<label class="form-label mt-3" for="chapterRawJson">Raw OpenAI response JSON</label>
<textarea class="form-control font-monospace" id="chapterRawJson" rows="10" readonly>@chapter.RawResponseJson</textarea>
}
</section>
<section class="edit-panel">
<h2>Scene results</h2>
@if (Model.SceneResults.Count == 0)
{
<p class="mb-0">No scene results were saved for this run.</p>
}
</section>
<section class="edit-panel">
<h2>Import Preview</h2>
@if (Model.ImportPreview is null)
{
<p class="mb-0">No import preview is available.</p>
}
else
{
var preview = Model.ImportPreview;
<div class="alert alert-info">Dry run only. No PlotDirector entities are created or updated from this preview.</div>
<h3>Characters</h3>
@RenderEntityList(preview.Characters)
<h3>Locations</h3>
@RenderEntityList(preview.Locations)
<h3>Assets</h3>
@RenderEntityList(preview.Assets)
<h3>Relationships</h3>
@if (preview.Relationships.Count == 0)
{
<p>No relationship changes detected.</p>
}
else
{
<ul>
@foreach (var relationship in preview.Relationships)
{
<li>@relationship.Action: @relationship.CharacterA - @relationship.Signal - @relationship.CharacterB (@DisplayConfidence(relationship.ConfidencePercent))</li>
}
</ul>
}
<h3>Knowledge</h3>
@if (preview.Knowledge.Count == 0)
{
<p>No knowledge changes detected.</p>
}
else
{
<ul>
@foreach (var item in preview.Knowledge)
{
<li>@item.Action: @item.Character - @item.KnowledgeItem (@item.OldState -> @item.NewState, @DisplayConfidence(item.ConfidencePercent))</li>
}
</ul>
}
@if (preview.UnresolvedKnowledge.Count > 0)
{
<h4>Knowledge involving unresolved references</h4>
<ul>
@foreach (var item in preview.UnresolvedKnowledge)
{
<li>@item.Action: @item.Character - @item.KnowledgeItem (@item.OldState -> @item.NewState, @DisplayConfidence(item.ConfidencePercent))</li>
}
</ul>
}
<h3>Timeline Clues</h3>
<p>@preview.Timeline.Count.ToString("N0") timeline clue@(preview.Timeline.Count == 1 ? string.Empty : "s") detected.</p>
@if (preview.Timeline.Count > 0)
{
<ul>
@foreach (var item in preview.Timeline.Take(20))
{
<li>Scene @Display(item.SceneNumber): @item.SceneDate (@DisplayConfidence(item.ConfidencePercent))</li>
}
</ul>
}
<h3>Warnings</h3>
<ul class="mb-0">
@foreach (var warning in preview.Warnings)
{
<li>@warning</li>
}
</ul>
}
</section>
@foreach (var scene in Model.SceneResults)
{
<section class="edit-panel">
<h2>Suggested Scene @scene.TemporarySceneNumber.ToString("N0")</h2>
<dl>
<dt>Paragraph range</dt>
<dd>@Display(scene.StartParagraph)-@Display(scene.EndParagraph)</dd>
<dt>Source label</dt>
<dd>@scene.SourceLabel</dd>
<dt>Prompt version</dt>
<dd>@scene.PromptVersion</dd>
<dt>Model</dt>
<dd>@scene.Model</dd>
<dt>Validation</dt>
<dd>@scene.ValidationErrorsCount.ToString("N0") error(s), @scene.ValidationWarningsCount.ToString("N0") warning(s)</dd>
@if (IsFailedScene(scene))
{
<dt>Failure stage</dt>
<dd>@SceneFailureStage(scene)</dd>
<dt>Error message</dt>
<dd>@SceneErrorMessage(scene)</dd>
<dt>Retry attempted</dt>
<dd>@(SceneRetryAttempted(scene) ? "Yes" : "No")</dd>
<dt>Retry error</dt>
<dd>@Display(SceneRetryError(scene))</dd>
}
<dt>Duration</dt>
<dd>@DisplayDuration(scene.DurationMs)</dd>
<dt>Tokens</dt>
<dd>@Display(scene.TotalTokens) total (@Display(scene.InputTokens) input, @Display(scene.OutputTokens) output)</dd>
</dl>
<label class="form-label" for="sceneParsedJson-@scene.SceneResultID">Parsed JSON</label>
<textarea class="form-control font-monospace" id="sceneParsedJson-@scene.SceneResultID" rows="10" readonly>@scene.ParsedJson</textarea>
<label class="form-label mt-3" for="sceneOutputJson-@scene.SceneResultID">Extracted raw assistant output</label>
<textarea class="form-control font-monospace" id="sceneOutputJson-@scene.SceneResultID" rows="10" readonly>@scene.OutputTextJson</textarea>
<label class="form-label mt-3" for="sceneRawJson-@scene.SceneResultID">Raw OpenAI response JSON</label>
<textarea class="form-control font-monospace" id="sceneRawJson-@scene.SceneResultID" rows="10" readonly>@scene.RawResponseJson</textarea>
</section>
}
}
@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 DisplayConfidence(int? percent)
=> percent.HasValue
? percent.Value >= 90
? $"{percent.Value}% High"
: percent.Value >= 70
? $"{percent.Value}% Medium"
: $"{percent.Value}% Low"
: "Unknown";
private static Microsoft.AspNetCore.Html.IHtmlContent RenderEntityList(IReadOnlyList<StoryImportEntityResolution> items)
{
if (items.Count == 0)
{
return new Microsoft.AspNetCore.Html.HtmlString("<p>No items detected.</p>");
}
var builder = new System.Text.StringBuilder();
foreach (var group in items
.GroupBy(item => GroupLabel(item.Status))
.OrderBy(group => GroupSort(group.Key)))
{
builder.Append("<h4>").Append(Encode(group.Key)).Append("</h4><ul>");
foreach (var item in group.OrderBy(item => item.MatchedName ?? item.InputName, StringComparer.OrdinalIgnoreCase))
{
builder.Append("<li>");
builder.Append(IconForStatus(item.Status)).Append(' ');
builder.Append(Encode(item.MatchedName ?? item.InputName));
if (!string.IsNullOrWhiteSpace(item.MatchedName) && !string.Equals(item.MatchedName, item.InputName, StringComparison.OrdinalIgnoreCase))
{
builder.Append(" <span class=\"muted\">from ").Append(Encode(item.InputName)).Append("</span>");
}
builder.Append(" <span class=\"muted\">(").Append(Encode(DisplayConfidence(item.ConfidencePercent))).Append(")</span>");
if (item.Candidates.Count > 1)
{
builder.Append("<br /><span class=\"muted\">Candidates: ")
.Append(Encode(string.Join(", ", item.Candidates.Select(candidate => candidate.Name))))
.Append("</span>");
}
if (!string.IsNullOrWhiteSpace(item.Notes))
{
builder.Append("<br /><span class=\"muted\">").Append(Encode(item.Notes)).Append("</span>");
}
builder.Append("</li>");
}
builder.Append("</ul>");
}
return new Microsoft.AspNetCore.Html.HtmlString(builder.ToString());
}
private static string IconForStatus(string status)
=> status switch
{
StoryImportResolutionStatuses.Matched => "✓",
StoryImportResolutionStatuses.Ambiguous => "⚠",
StoryImportResolutionStatuses.PossibleDuplicate => "⚠",
StoryImportResolutionStatuses.PossibleDuplicateAliasCandidate => "⚠",
StoryImportResolutionStatuses.PossibleMatch => "?",
StoryImportResolutionStatuses.NewCharacter or StoryImportResolutionStatuses.NewLocation or StoryImportResolutionStatuses.NewAsset => "+",
_ => "•"
};
private static string GroupLabel(string status)
=> status switch
{
StoryImportResolutionStatuses.NewCharacter or StoryImportResolutionStatuses.NewLocation or StoryImportResolutionStatuses.NewAsset => "New",
StoryImportResolutionStatuses.PossibleDuplicate or StoryImportResolutionStatuses.PossibleDuplicateAliasCandidate => "Possible Duplicate / Alias Candidate",
StoryImportResolutionStatuses.GenericReference or StoryImportResolutionStatuses.Ignored => "Generic / Ignored",
StoryImportResolutionStatuses.UnresolvedPersonReference => "Unresolved",
_ => status
};
private static int GroupSort(string group)
=> group switch
{
"Matched" => 0,
"New" => 1,
"Possible Match" => 2,
"Possible Duplicate / Alias Candidate" => 3,
"Ambiguous" => 4,
"Generic / Ignored" => 5,
"Unresolved" => 6,
_ => 99
};
private static string Encode(string? value)
=> System.Net.WebUtility.HtmlEncode(value ?? string.Empty);
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 bool CanCommitImport(StoryIntelligenceSavedRun run, IReadOnlyList<StoryIntelligenceSavedSceneResult> scenes)
=> run.Status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings
&& run.ProjectID.HasValue
&& run.BookID.HasValue
&& run.ChapterID.HasValue
&& scenes.Count > 0;
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]}...";
}
}