Refine onboarding review wizard steps

This commit is contained in:
Nick Beckley 2026-08-14 18:31:27 +00:00
parent bfe729e8c0
commit 54bcd56df4
7 changed files with 115 additions and 45 deletions

View File

@ -1275,7 +1275,8 @@ static void ScanReviewAliasesFeedStoryIntelligenceKnownCharacterContext()
new() { TemporaryCharacterKey = "c-maggie", Name = "Maggie", MentionCount = 8, QualityScore = 94, Category = "ProbableCharacter" },
new() { TemporaryCharacterKey = "c-margaret", Name = "Margaret", MentionCount = 3, QualityScore = 88, Category = "ProbableCharacter" },
new() { TemporaryCharacterKey = "c-mags", Name = "Mags", MentionCount = 2, QualityScore = 78, Category = "PossibleCharacter" },
new() { TemporaryCharacterKey = "c-kevin", Name = "Kevin", MentionCount = 4, QualityScore = 90, Category = "ProbableCharacter" }
new() { TemporaryCharacterKey = "c-kevin", Name = "Kevin", MentionCount = 4, QualityScore = 90, Category = "ProbableCharacter" },
new() { TemporaryCharacterKey = "c-road", Name = "Road", MentionCount = 10, QualityScore = 15, Category = "Excluded" }
]
};
var review = new ManuscriptScanReviewDecision
@ -1285,7 +1286,8 @@ static void ScanReviewAliasesFeedStoryIntelligenceKnownCharacterContext()
new() { TemporaryCharacterKey = "c-maggie", Include = true, Name = "Maggie", Category = "ProbableCharacter", ExistingCharacterID = 123 },
new() { TemporaryCharacterKey = "c-margaret", Include = true, Name = "Margaret", Category = "ProbableCharacter", AliasTargetTemporaryCharacterKey = "c-maggie" },
new() { TemporaryCharacterKey = "c-mags", Include = true, Name = "Mags", Category = "PossibleCharacter", AliasTargetTemporaryCharacterKey = "c-maggie" },
new() { TemporaryCharacterKey = "c-kevin", Include = true, Name = "Kevin", Category = "ProbableCharacter" }
new() { TemporaryCharacterKey = "c-kevin", Include = true, Name = "Kevin", Category = "ProbableCharacter" },
new() { TemporaryCharacterKey = "c-road", Include = true, Name = "Road", Category = "Excluded" }
]
};
@ -1300,6 +1302,7 @@ static void ScanReviewAliasesFeedStoryIntelligenceKnownCharacterContext()
Assert(maggie.MentionCount == 13, $"Alias mention counts should accumulate, got {maggie.MentionCount}.");
Assert(context.Any(item => item.CanonicalName == "Kevin"), "Other included canonical candidates should remain available.");
Assert(!context.Any(item => item.CanonicalName == "Margaret"), "Alias candidates should not become separate canonical context records.");
Assert(!context.Any(item => item.CanonicalName == "Road"), "Excluded scan candidates should not guide Story Intelligence.");
}
static JsonSerializerOptions JsonOptions()

View File

@ -530,7 +530,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
}
TempData["OnboardingReviewMessage"] = readyToImport
? "Review saved. Next: prepare chapters and analyse manuscript."
? "Review saved. Next: story and portrait context."
: continueToCharacters ? "Chapter and scene review saved. Next: review detected characters."
: continueCharacterReview || backCharacterReview ? "Character review choices saved."
: "Review choices saved.";
@ -552,13 +552,63 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
if (readyToImport || continueCharacterReview || backCharacterReview)
{
ViewData["ScanReviewStep"] = "characters";
ViewData["CharacterReviewPage"] = Math.Max(1, characterPage);
ViewData["CharacterReviewPage"] = readyToImport
? FirstInvalidCharacterPage(model)
: Math.Max(1, characterPage);
}
return View("ScanReview", model);
}
}
private static int FirstInvalidCharacterPage(ManuscriptScanReviewViewModel model)
{
const int pageSize = 10;
var characters = model.CharacterCandidates
.Where(character => !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase))
.OrderBy(character => character.ReviewName)
.ThenBy(character => character.Name)
.ToList();
var canonicalNames = characters
.Where(IsIncludedCanonicalCharacter)
.Select(character => character.ReviewName.Trim())
.Where(name => !string.IsNullOrWhiteSpace(name))
.GroupBy(name => name, StringComparer.OrdinalIgnoreCase)
.Where(group => group.Count() > 1)
.SelectMany(group => group)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
for (var index = 0; index < characters.Count; index++)
{
var character = characters[index];
if (character.Include && string.IsNullOrWhiteSpace(character.ReviewName))
{
return (index / pageSize) + 1;
}
if (IsIncludedCanonicalCharacter(character) && canonicalNames.Contains(character.ReviewName.Trim()))
{
return (index / pageSize) + 1;
}
if (character.Include && !string.IsNullOrWhiteSpace(character.AliasTargetTemporaryCharacterKey))
{
var target = characters.FirstOrDefault(candidate => string.Equals(candidate.TemporaryCharacterKey, character.AliasTargetTemporaryCharacterKey, StringComparison.Ordinal));
if (target is null || !IsIncludedCanonicalCharacter(target))
{
return (index / pageSize) + 1;
}
}
}
return 1;
}
private static bool IsIncludedCanonicalCharacter(ManuscriptScanReviewCharacterViewModel character)
=> character.Include
&& string.IsNullOrWhiteSpace(character.AliasTargetTemporaryCharacterKey)
&& !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase);
[HttpPost("welcome")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Welcome()

View File

@ -67,9 +67,7 @@ public sealed class OnboardingStoryIntelligenceService(
var existingBatch = await batchStore.GetLatestForPreviewAsync(userId, preview.PreviewID);
var missingTextCount = included.Count(chapter => string.IsNullOrWhiteSpace(chapter.Preview.ChapterText));
var primaryCharacterCount = preview.CharacterCandidates.Count(candidate => !string.Equals(candidate.Category, "Excluded", StringComparison.OrdinalIgnoreCase));
var excludedCharacterCount = preview.CharacterCandidates.Count(candidate => string.Equals(candidate.Category, "Excluded", StringComparison.OrdinalIgnoreCase));
var characterPageCount = Math.Max(1, (int)Math.Ceiling(primaryCharacterCount / 10d))
+ (excludedCharacterCount > 0 ? (int)Math.Ceiling(excludedCharacterCount / 10d) : 0);
var characterPageCount = Math.Max(1, (int)Math.Ceiling(primaryCharacterCount / 10d));
var totalWizardSteps = 7 + characterPageCount + 2;
return new StoryIntelligenceOverviewViewModel
{

View File

@ -10,37 +10,47 @@
var relationshipTitles = Model.CharacterCandidates
.Where(candidate => string.Equals(candidate.Category, "RelationshipTitle", StringComparison.OrdinalIgnoreCase))
.ToList();
var excludedCharacters = Model.CharacterCandidates
.Where(candidate => string.Equals(candidate.Category, "Excluded", StringComparison.OrdinalIgnoreCase))
.ToList();
var primaryCharacters = probableCharacters
.Concat(possibleCharacters)
.Concat(relationshipTitles)
.OrderBy(candidate => candidate.ReviewName)
.ThenBy(candidate => candidate.Name)
.ToList();
var detectedAliasTargets = Model.CharacterCandidates
.Where(candidate => !string.Equals(candidate.Category, "Excluded", StringComparison.OrdinalIgnoreCase))
var detectedAliasTargets = primaryCharacters
.OrderBy(candidate => candidate.ReviewName)
.ToList();
var visibleCharacterCount = probableCharacters.Count + possibleCharacters.Count + relationshipTitles.Count;
var isCharacterStep = string.Equals(ViewData["ScanReviewStep"] as string, "characters", StringComparison.OrdinalIgnoreCase);
const int characterPageSize = 10;
var primaryCharacterPageCount = Math.Max(1, (int)Math.Ceiling(primaryCharacters.Count / (double)characterPageSize));
var excludedCharacterPageCount = excludedCharacters.Any()
? (int)Math.Ceiling(excludedCharacters.Count / (double)characterPageSize)
: 0;
var characterReviewPageCount = primaryCharacterPageCount + excludedCharacterPageCount;
var characterReviewPageCount = primaryCharacterPageCount;
var requestedCharacterPage = ViewData["CharacterReviewPage"] is int requestedPage ? requestedPage : 1;
var characterPage = Math.Clamp(requestedCharacterPage, 1, characterReviewPageCount);
var isExcludedCharacterPage = excludedCharacterPageCount > 0 && characterPage > primaryCharacterPageCount;
var excludedPageIndex = isExcludedCharacterPage ? characterPage - primaryCharacterPageCount : 0;
var currentCharacters = isExcludedCharacterPage
? excludedCharacters.Skip((excludedPageIndex - 1) * characterPageSize).Take(characterPageSize).ToList()
: primaryCharacters.Skip((characterPage - 1) * characterPageSize).Take(characterPageSize).ToList();
var currentCharacters = primaryCharacters.Skip((characterPage - 1) * characterPageSize).Take(characterPageSize).ToList();
var currentStep = isCharacterStep ? 7 + characterPage : 7;
var totalSteps = 7 + characterReviewPageCount + 2;
var characterPageTitle = isExcludedCharacterPage
? "Review Excluded Characters"
: $"Review Characters {characterPage} of {primaryCharacterPageCount}";
var characterPageTitle = $"Review Characters {characterPage} of {primaryCharacterPageCount}";
var duplicateCanonicalNames = primaryCharacters
.Where(candidate => candidate.Include && string.IsNullOrWhiteSpace(candidate.AliasTargetTemporaryCharacterKey))
.Select(candidate => candidate.ReviewName.Trim())
.Where(name => !string.IsNullOrWhiteSpace(name))
.GroupBy(name => name, StringComparer.OrdinalIgnoreCase)
.Where(group => group.Count() > 1)
.Select(group => group.Key)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var includedPrimaryKeys = primaryCharacters
.Where(candidate => candidate.Include && string.IsNullOrWhiteSpace(candidate.AliasTargetTemporaryCharacterKey))
.Select(candidate => candidate.TemporaryCharacterKey)
.ToHashSet(StringComparer.Ordinal);
var invalidCharacterKeys = primaryCharacters
.Where(candidate =>
(candidate.Include && string.IsNullOrWhiteSpace(candidate.ReviewName))
|| (candidate.Include && string.IsNullOrWhiteSpace(candidate.AliasTargetTemporaryCharacterKey) && duplicateCanonicalNames.Contains(candidate.ReviewName.Trim()))
|| (candidate.Include
&& !string.IsNullOrWhiteSpace(candidate.AliasTargetTemporaryCharacterKey)
&& !includedPrimaryKeys.Contains(candidate.AliasTargetTemporaryCharacterKey)))
.Select(candidate => candidate.TemporaryCharacterKey)
.ToHashSet(StringComparer.Ordinal);
}
<section class="onboarding-shell" aria-labelledby="scan-review-title">
@ -79,7 +89,7 @@
</div>
<div>
<span>Characters</span>
<strong>@Model.SelectedCharacterCount / @Model.CharacterCandidateCount</strong>
<strong>@Model.SelectedCharacterCount / @visibleCharacterCount</strong>
</div>
<div>
<span>Words</span>
@ -114,7 +124,7 @@
}
@foreach (var chapter in Model.Chapters)
{
<details class="onboarding-review-chapter" open>
<details class="onboarding-review-chapter">
<summary>
<span>Chapter @chapter.ChapterNumber</span>
<strong>@chapter.ReviewTitle</strong>
@ -187,9 +197,7 @@
<div>
<h2>@characterPageTitle</h2>
<p>
@(isExcludedCharacterPage
? $"Excluded candidates page {excludedPageIndex} of {excludedCharacterPageCount}."
: $"Character candidates page {characterPage} of {primaryCharacterPageCount}.")
Character candidates page @characterPage of @primaryCharacterPageCount.
</p>
</div>
<strong>@currentCharacters.Count character@(currentCharacters.Count == 1 ? string.Empty : "s") on this step</strong>
@ -200,10 +208,6 @@
<span>Primary candidates</span>
<strong>@visibleCharacterCount.ToString("N0")</strong>
</div>
<div>
<span>Excluded</span>
<strong>@excludedCharacters.Count.ToString("N0")</strong>
</div>
<div>
<span>Wizard pages</span>
<strong>@characterReviewPageCount.ToString("N0")</strong>
@ -211,19 +215,22 @@
</div>
@{
var allCharacters = Model.CharacterCandidates.ToList();
var allCharacters = primaryCharacters.ToList();
}
@if (!currentCharacters.Any())
{
<p class="onboarding-empty-state">No character candidates were found for this step.</p>
}
@if (invalidCharacterKeys.Any(key => currentCharacters.Any(candidate => string.Equals(candidate.TemporaryCharacterKey, key, StringComparison.Ordinal))))
{
<p class="onboarding-empty-state onboarding-empty-state--error">The item that needs attention is highlighted below.</p>
}
<div class="onboarding-character-groups">
@foreach (var group in new[]
{
new { Title = "Probable characters", Candidates = currentCharacters.Where(candidate => string.Equals(candidate.Category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase)).ToList() },
new { Title = "Possible characters", Candidates = currentCharacters.Where(candidate => string.Equals(candidate.Category, "PossibleCharacter", StringComparison.OrdinalIgnoreCase)).ToList() },
new { Title = "Relationship titles", Candidates = currentCharacters.Where(candidate => string.Equals(candidate.Category, "RelationshipTitle", StringComparison.OrdinalIgnoreCase)).ToList() },
new { Title = "Excluded candidates", Candidates = currentCharacters.Where(candidate => string.Equals(candidate.Category, "Excluded", StringComparison.OrdinalIgnoreCase)).ToList() }
new { Title = "Relationship titles", Candidates = currentCharacters.Where(candidate => string.Equals(candidate.Category, "RelationshipTitle", StringComparison.OrdinalIgnoreCase)).ToList() }
})
{
if (!group.Candidates.Any())
@ -237,7 +244,7 @@
@foreach (var candidate in group.Candidates)
{
var currentCharacterIndex = allCharacters.FindIndex(item => string.Equals(item.TemporaryCharacterKey, candidate.TemporaryCharacterKey, StringComparison.Ordinal));
<li>
<li class="@(invalidCharacterKeys.Contains(candidate.TemporaryCharacterKey) ? "is-invalid" : string.Empty)">
<input type="hidden" name="Characters[@currentCharacterIndex].TemporaryCharacterKey" value="@candidate.TemporaryCharacterKey" />
<input type="hidden" name="Characters[@currentCharacterIndex].Category" value="@candidate.Category" />
<label class="onboarding-review-toggle">

View File

@ -1,6 +1,6 @@
@model StoryIntelligenceOverviewViewModel
@{
ViewData["Title"] = "Analyse manuscript";
ViewData["Title"] = "Story and portrait context";
}
<section class="onboarding-shell" aria-labelledby="story-intelligence-title">
@ -8,8 +8,8 @@
<partial name="_OnboardingJourneyHeader" model="@(new OnboardingJourneyHeaderViewModel { CurrentStep = Model.WizardCurrentStep, TotalSteps = Model.WizardTotalSteps })" />
<div class="onboarding-copy">
<p class="eyebrow">Step @Model.WizardCurrentStep of @Model.WizardTotalSteps</p>
<h1 id="story-intelligence-title">Analyse Manuscript</h1>
<p>Set the analysis context, then PlotDirector will read the approved chapters and prepare scenes, characters, locations, assets, relationships and knowledge for review.</p>
<h1 id="story-intelligence-title">Story and Portrait Context</h1>
<p>Set the context choices for this manuscript before the analysis starts.</p>
</div>
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
@ -73,8 +73,8 @@
<form asp-action="StartStoryIntelligence" method="post" class="onboarding-analysis-setup">
<div class="onboarding-analysis-setup__header">
<div>
<p class="eyebrow">Analysis setup</p>
<h2>Story and portrait context</h2>
<p class="eyebrow">Pre-analysis setup</p>
<h2>Context choices</h2>
<p>Explicit manuscript evidence always wins. These choices guide unresolved generated portraits and broad story context.</p>
</div>
<strong>Required before analysis</strong>
@ -144,7 +144,7 @@
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="ScanReview" asp-route-previewId="@Model.PreviewID">Back to scan review</a>
<button class="btn btn-primary" type="submit" disabled="@(!Model.CanStart)">Analyse manuscript</button>
<button class="btn btn-primary" type="submit" disabled="@(!Model.CanStart)">Save context and analyse manuscript</button>
</div>
</form>

View File

@ -38,7 +38,7 @@
new("Connect Word", 6),
new("Review Chapters", 7),
new("Review Characters", 8),
new("Analyse Manuscript", 9),
new("Story Context", 9),
new("Complete", 10)
];
@ -60,7 +60,7 @@
steps.Add(new HeaderStep($"Characters {index}", 7 + index));
}
steps.Add(new HeaderStep("Analyse Manuscript", totalSteps - 1));
steps.Add(new HeaderStep("Story Context", totalSteps - 1));
steps.Add(new HeaderStep("Complete", totalSteps));
return steps;
}

View File

@ -530,6 +530,12 @@
background: rgba(255, 255, 255, .55);
}
.onboarding-empty-state--error {
border-color: rgba(176, 54, 54, .38);
color: #8f2f2f;
background: rgba(176, 54, 54, .08);
}
.onboarding-review-chapters {
display: grid;
gap: .85rem;
@ -574,6 +580,12 @@
background: rgba(255, 255, 255, .64);
}
.onboarding-character-candidates li.is-invalid {
border-color: rgba(176, 54, 54, .48);
box-shadow: inset 0 0 0 1px rgba(176, 54, 54, .16);
background: rgba(176, 54, 54, .06);
}
.onboarding-review-chapter li {
display: grid;
gap: .45rem;