PlotDirector/PlotLine/Views/Onboarding/StoryIntelligenceReview.cshtml

368 lines
17 KiB
Plaintext

@model StoryIntelligenceProgressViewModel
@using System.Security.Claims
@inject IConfiguration Configuration
@{
ViewData["Title"] = "Review analysis";
var adminEmails = Configuration.GetSection("Admin:AllowedEmails").Get<string[]>() ?? [];
var userEmail = User.FindFirstValue(ClaimTypes.Email);
var isAdmin = !string.IsNullOrWhiteSpace(userEmail)
&& adminEmails.Any(email => string.Equals(email, userEmail, StringComparison.OrdinalIgnoreCase));
var chaptersNeedingReview = Model.Chapters.Count(NeedsReview);
var warningsCount = Model.Chapters.Sum(chapter => NeedsReviewCount(chapter));
}
<section class="onboarding-shell" aria-labelledby="story-review-title">
<div class="onboarding-panel onboarding-review-panel story-review-page">
<partial name="_OnboardingJourneyHeader" model="@(new OnboardingJourneyHeaderViewModel { CurrentStep = 9 })" />
<div class="onboarding-copy">
<p class="eyebrow">Step 9 of 9</p>
<h1 id="story-review-title">Review Story Intelligence Results</h1>
<p>PlotDirector has finished reading your manuscript. Review the detected scenes first, then approve any characters you want to add or link.</p>
</div>
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
{
<div class="alert alert-danger">@error</div>
}
@if (TempData["OnboardingStoryIntelligenceMessage"] is string message)
{
<div class="alert alert-success">@message</div>
}
<section class="alert alert-info">
Scene creation runs first. Character import is reviewed separately and only creates or links approved characters. Locations, assets, relationships and knowledge remain for later Story Intelligence phases.
</section>
<section class="story-review-summary" aria-label="Analysis summary">
<div>
<span>Story Intelligence complete</span>
<strong>@Model.CompletedChapterCount.ToString("N0") chapter@(Model.CompletedChapterCount == 1 ? string.Empty : "s")</strong>
</div>
<div>
<span>Scenes detected</span>
<strong>@Model.TotalDetectedScenes.ToString("N0")</strong>
</div>
<div>
<span>Words analysed</span>
<strong>@Model.TotalAnalysedWords.ToString("N0")</strong>
</div>
<div>
<span>Elapsed time</span>
<strong>@FormatDuration(Model.TotalDurationMs)</strong>
</div>
<div>
<span>Estimated AI cost</span>
<strong>@FormatCost(Model.TotalEstimatedCostUSD)</strong>
</div>
<div>
<span>Needs review</span>
<strong>@warningsCount.ToString("N0")</strong>
</div>
</section>
<section class="story-review-chapter-list" aria-label="Chapter review">
@foreach (var chapter in Model.Chapters.OrderBy(chapter => chapter.ChapterNumber))
{
<details class="story-review-chapter-card">
<summary class="story-review-chapter-heading">
<div>
<p class="eyebrow">Chapter @chapter.ChapterNumber</p>
<h2>@chapter.ChapterTitle</h2>
<p>@DetectedCount(chapter).ToString("N0") scene@(DetectedCount(chapter) == 1 ? string.Empty : "s") detected / @ReadyCount(chapter).ToString("N0") ready to create</p>
</div>
<strong class="@StatusClass(chapter)">@FriendlyStatus(chapter)</strong>
</summary>
<div class="story-review-scene-panel">
<div>
<span>Detected scenes</span>
<strong>@DetectedCount(chapter).ToString("N0")</strong>
</div>
<div>
<span>Ready</span>
<strong>@ReadyCount(chapter).ToString("N0")</strong>
</div>
<div>
<span>Needs review</span>
<strong>@NeedsReviewCount(chapter).ToString("N0")</strong>
</div>
<div>
<span>Rejected</span>
<strong>@RejectedCount(chapter).ToString("N0")</strong>
</div>
</div>
@if (!string.IsNullOrWhiteSpace(chapter.CommitMessage))
{
<p class="story-review-note">@chapter.CommitMessage</p>
}
@if (chapter.Warnings.Count > 0)
{
<div class="story-review-note">
<strong>Notes</strong>
<ul>
@foreach (var warning in chapter.Warnings)
{
<li>@AuthorMessage(warning)</li>
}
</ul>
</div>
}
@if (chapter.Blockers.Count > 0)
{
<div class="story-review-note story-review-note--warning">
<strong>Before scenes can be created</strong>
<ul>
@foreach (var blocker in chapter.Blockers)
{
<li>@AuthorMessage(blocker)</li>
}
</ul>
</div>
}
@if (!string.IsNullOrWhiteSpace(chapter.ErrorMessage))
{
<p class="story-review-note story-review-note--warning">@AuthorMessage(chapter.ErrorMessage)</p>
}
<div class="story-scene-preview-list">
@if (chapter.Scenes.Count == 0)
{
<p class="text-muted mb-0">No readable scene previews are available for this chapter.</p>
}
else
{
@foreach (var scene in chapter.Scenes)
{
<article class="story-scene-preview">
<div>
<strong>Scene @scene.SceneNumber.ToString("N0")</strong>
@if (scene.HasWarnings)
{
<span class="story-review-status story-review-status--active">Review recommended</span>
}
</div>
<p>@scene.Summary</p>
<dl>
<div><dt>POV</dt><dd>@Display(scene.Pov)</dd></div>
<div><dt>Setting</dt><dd>@Display(scene.Setting)</dd></div>
<div><dt>Confidence</dt><dd>@FormatConfidence(scene.Confidence)</dd></div>
</dl>
</article>
}
}
</div>
@if (isAdmin)
{
<details class="story-review-admin">
<summary>Admin diagnostics</summary>
<dl>
<div><dt>Run ID</dt><dd>@chapter.RunID</dd></div>
<div><dt>Status</dt><dd>@chapter.Status</dd></div>
<div><dt>Stage</dt><dd>@(chapter.CurrentStage ?? "-")</dd></div>
<div><dt>Failure stage</dt><dd>@(chapter.FailureStage ?? "-")</dd></div>
<div><dt>Tokens</dt><dd>@(chapter.TotalTokens?.ToString("N0") ?? "-")</dd></div>
</dl>
<a class="btn btn-outline-secondary btn-sm" asp-controller="Admin" asp-action="StoryIntelligenceRunDetails" asp-route-id="@chapter.RunID">Open diagnostics</a>
</details>
}
</details>
}
</section>
<section class="alert alert-info">
Scene creation uses PlotDirector import safeguards. Duplicate imports are blocked, and failures roll back safely.
</section>
@if (Model.CharacterReview.HasCommittedScenes)
{
<section class="story-review-chapter-list" aria-label="Character review">
<h2>Review Characters</h2>
@if (Model.CharacterReview.Candidates.Count == 0)
{
<p class="text-muted">No new character suggestions are waiting. Existing scene character links are already up to date where PlotDirector could match them.</p>
}
else
{
<p>Approve the characters PlotDirector should create or link from the imported scenes. You can rename new characters before import.</p>
<form asp-action="ImportStoryIntelligenceCharacters" method="post">
<input type="hidden" name="BatchID" value="@Model.BatchID" />
<div class="story-scene-preview-list">
@for (var i = 0; i < Model.CharacterReview.Candidates.Count; i++)
{
var candidate = Model.CharacterReview.Candidates[i];
<article class="story-scene-preview">
<input type="hidden" name="Characters[@i].Key" value="@candidate.Key" />
<div>
<label>
<input type="checkbox" name="Characters[@i].Include" value="true" checked />
<strong>@candidate.CharacterName</strong>
</label>
<span class="story-review-status story-review-status--ready">@candidate.ActionLabel</span>
</div>
<dl>
<div><dt>Appears in</dt><dd>@candidate.AppearsInScenes.ToString("N0") scene@(candidate.AppearsInScenes == 1 ? string.Empty : "s")</dd></div>
<div><dt>Confidence</dt><dd>@candidate.Confidence</dd></div>
<div><dt>Match</dt><dd>@(candidate.ExistingCharacterName ?? "New character")</dd></div>
</dl>
@if (candidate.PossibleAliases.Count > 0)
{
<p>Aliases: @string.Join(", ", candidate.PossibleAliases)</p>
}
@if (!string.IsNullOrWhiteSpace(candidate.ExampleFirstAppearance))
{
<p>@candidate.ExampleFirstAppearance</p>
}
<label class="form-label" for="character-import-name-@i">Import name</label>
<input id="character-import-name-@i" class="form-control" name="Characters[@i].ImportName" value="@candidate.ImportName" readonly="@candidate.IsExistingMatch" />
</article>
}
</div>
<button class="btn btn-primary mt-3" type="submit">Import approved characters</button>
</form>
}
</section>
}
<div class="onboarding-actions">
@if (Model.BatchID.HasValue)
{
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceProgress" asp-route-batchId="@Model.BatchID">Back</a>
@if (Model.HasReadyScenesToCreate)
{
<form asp-action="CommitAllStoryIntelligence" method="post">
<input type="hidden" name="batchId" value="@Model.BatchID" />
<button class="btn btn-primary" type="submit">Create PlotDirector Scenes</button>
</form>
}
else if (Model.AllCommitted)
{
<a class="btn btn-primary" asp-action="StoryIntelligenceComplete" asp-route-batchId="@Model.BatchID">Continue</a>
}
}
</div>
</div>
</section>
@functions {
private static bool NeedsReview(StoryIntelligenceOnboardingChapterViewModel chapter)
=> chapter.IsFailed
|| chapter.Blockers.Count > 0
|| NeedsReviewCount(chapter) > 0
|| (!chapter.CanCommit && !chapter.HasCompletedCommit && chapter.IsRunComplete);
private static int DetectedCount(StoryIntelligenceOnboardingChapterViewModel chapter)
=> Math.Max(chapter.TotalDetectedScenes ?? 0, ReadyCount(chapter) + Math.Max(chapter.FailedScenes ?? 0, 0));
private static int ReadyCount(StoryIntelligenceOnboardingChapterViewModel chapter)
=> chapter.HasCompletedCommit ? chapter.ScenesCreated : chapter.Scenes.Count;
private static int RejectedCount(StoryIntelligenceOnboardingChapterViewModel chapter) => 0;
private static int NeedsReviewCount(StoryIntelligenceOnboardingChapterViewModel chapter)
=> Math.Max(0, DetectedCount(chapter) - ReadyCount(chapter) - RejectedCount(chapter));
private static string FriendlyStatus(StoryIntelligenceOnboardingChapterViewModel chapter)
{
if (chapter.HasCompletedCommit)
{
return "Scenes created";
}
if (chapter.CanCommit)
{
return "Ready";
}
return chapter.Status switch
{
StoryIntelligenceRunStatuses.Pending => "Waiting",
StoryIntelligenceRunStatuses.Running => "Reading",
StoryIntelligenceRunStatuses.Completed => "Cannot import",
StoryIntelligenceRunStatuses.CompletedWithWarnings => "Cannot import",
StoryIntelligenceRunStatuses.Failed => "Needs review",
StoryIntelligenceRunStatuses.Cancelled => "Cancelled",
_ => "Needs review"
};
}
private static string StatusClass(StoryIntelligenceOnboardingChapterViewModel chapter)
{
if (chapter.HasCompletedCommit)
{
return "story-review-status story-review-status--created";
}
if (chapter.CanCommit)
{
return "story-review-status story-review-status--ready";
}
return chapter.IsActive
? "story-review-status story-review-status--active"
: "story-review-status story-review-status--warning";
}
private static string Display(string? value) => string.IsNullOrWhiteSpace(value) ? "Not detected" : value;
private static string AuthorMessage(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "This chapter needs review before scenes can be created.";
}
var message = value.Trim();
if (message.Contains("JSON", StringComparison.OrdinalIgnoreCase)
|| message.Contains("parsed", StringComparison.OrdinalIgnoreCase)
|| message.Contains("parser", StringComparison.OrdinalIgnoreCase)
|| message.Contains("deserial", StringComparison.OrdinalIgnoreCase))
{
return "One or more scene suggestions could not be prepared automatically and need review.";
}
if (message.Contains("existing authored scenes", StringComparison.OrdinalIgnoreCase)
|| message.Contains("already contains scenes", StringComparison.OrdinalIgnoreCase))
{
return "This chapter already contains scenes. Choose an empty chapter before creating scenes from this analysis.";
}
return message;
}
private static string FormatConfidence(decimal? confidence)
{
if (!confidence.HasValue)
{
return "Not available";
}
var value = Math.Clamp(confidence.Value, 0m, 1m);
return value >= 0.75m
? "High"
: value >= 0.45m
? "Medium"
: "Low";
}
private static string FormatCost(decimal? cost)
=> cost.HasValue ? $"About ${cost.Value:0.00}" : "Not available";
private static string FormatDuration(long milliseconds)
{
var elapsed = TimeSpan.FromMilliseconds(Math.Max(0, milliseconds));
if (elapsed.TotalHours >= 1)
{
return $"{Math.Ceiling(elapsed.TotalHours):N0} hr";
}
return elapsed.TotalMinutes >= 1
? $"{Math.Max(1, (int)Math.Round(elapsed.TotalMinutes)):N0} min"
: $"{Math.Max(0, (int)Math.Round(elapsed.TotalSeconds)):N0} sec";
}
}