667 lines
27 KiB
Plaintext
667 lines
27 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 (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>
|
|
}
|
|
</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>
|
|
}
|
|
|
|
<h3>Timeline</h3>
|
|
<p>@preview.Timeline.Count.ToString("N0") scene timeline entr@(preview.Timeline.Count == 1 ? "y" : "ies") would be created.</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 => item.Status))
|
|
{
|
|
builder.Append("<h4>").Append(Encode(group.Key)).Append("</h4><ul>");
|
|
foreach (var item in group)
|
|
{
|
|
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.NewCharacter or StoryImportResolutionStatuses.NewLocation or StoryImportResolutionStatuses.NewAsset => "+",
|
|
_ => "•"
|
|
};
|
|
|
|
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 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]}...";
|
|
}
|
|
}
|