Keep character review within one wizard step

This commit is contained in:
Nick Beckley 2026-08-17 20:12:43 +00:00
parent 1ead298fa8
commit 1c7cf9bee1
7 changed files with 102 additions and 46 deletions

View File

@ -67,7 +67,9 @@ var tests = new (string Name, Action Test)[]
("Character sex hints are conservative and reviewable", CharacterSexHintsAreConservativeAndReviewable), ("Character sex hints are conservative and reviewable", CharacterSexHintsAreConservativeAndReviewable),
("Character discovery does not truncate scan review candidates", CharacterDiscoveryDoesNotTruncateScanReviewCandidates), ("Character discovery does not truncate scan review candidates", CharacterDiscoveryDoesNotTruncateScanReviewCandidates),
("Character discovery rejects sentence-start words", CharacterDiscoveryRejectsSentenceStartWords), ("Character discovery rejects sentence-start words", CharacterDiscoveryRejectsSentenceStartWords),
("Character discovery rejects fragments and location names", CharacterDiscoveryRejectsFragmentsAndLocationNames),
("Manuscript scan preview keeps only auto-selected character candidates", ManuscriptScanPreviewKeepsOnlyAutoSelectedCharacterCandidates), ("Manuscript scan preview keeps only auto-selected character candidates", ManuscriptScanPreviewKeepsOnlyAutoSelectedCharacterCandidates),
("Onboarding wizard keeps character pages inside one main step", OnboardingWizardKeepsCharacterPagesInsideOneMainStep),
("Scan review aliases feed Story Intelligence known character context", ScanReviewAliasesFeedStoryIntelligenceKnownCharacterContext), ("Scan review aliases feed Story Intelligence known character context", ScanReviewAliasesFeedStoryIntelligenceKnownCharacterContext),
("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions), ("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions),
("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel), ("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel),
@ -1333,6 +1335,38 @@ static void ManuscriptScanPreviewKeepsOnlyAutoSelectedCharacterCandidates()
Assert(candidates[0].Name == "Colin", "Scan preview should keep probable character candidates."); Assert(candidates[0].Name == "Colin", "Scan preview should keep probable character candidates.");
} }
static void CharacterDiscoveryRejectsFragmentsAndLocationNames()
{
var method = typeof(WordCompanionService).GetMethod("DiscoverCharacterCandidates", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "Character discovery method could not be found.");
var text = """
Bark Narrated the line break. Freedom Narrated another section.
Eventually Rob arrived and Rob spoke to Maggie.
Bristol Road curved away from Barmouth. Bristol Road appeared again.
Maggie said hello. Maggie smiled at Rob.
""";
var candidates = (IReadOnlyList<WordCompanionCharacterCandidateDto>)method!.Invoke(null, [text, false])!;
Assert(candidates.Any(candidate => candidate.Text == "Maggie"), "Real character names should still survive fragment filtering.");
Assert(candidates.Any(candidate => candidate.Text == "Rob"), "Known character names should still survive fragment filtering.");
Assert(!candidates.Any(candidate => candidate.Text is "Bark Narrated" or "Freedom Narrated" or "Eventually Rob"), "Narrative fragments must not become character candidates.");
Assert(!candidates.Any(candidate => candidate.Text == "Bristol Road"), "Location names must not become character candidates.");
}
static void OnboardingWizardKeepsCharacterPagesInsideOneMainStep()
{
var header = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Onboarding/_OnboardingJourneyHeader.cshtml"));
var review = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Onboarding/ScanReview.cshtml"));
var storyService = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/OnboardingStoryIntelligenceService.cs"));
Assert(!header.Contains("BuildDynamicSteps", StringComparison.Ordinal), "Onboarding header should not create one top-level step per character page.");
Assert(header.Contains("new(\"Review Characters\", 8)", StringComparison.Ordinal), "Onboarding header should keep character review as one main step.");
Assert(review.Contains("var currentStep = isCharacterStep ? 8 : 7;", StringComparison.Ordinal), "Scan review should keep every character page on the same main wizard step.");
Assert(review.Contains("onboarding-subprogress", StringComparison.Ordinal), "Character pagination should be shown as section-level progress.");
Assert(storyService.Contains("const int totalWizardSteps = 11;", StringComparison.Ordinal), "Story Intelligence summary/context should use the fixed wizard step count.");
}
static void StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel() static void StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel()
{ {
var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Development/StoryIntelligenceExperience.cshtml")); var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Development/StoryIntelligenceExperience.cshtml"));

View File

@ -80,7 +80,7 @@ public sealed class OnboardingService(
{ {
CurrentStep = step, CurrentStep = step,
StepNumber = StepNumber(step), StepNumber = StepNumber(step),
TotalSteps = 10, TotalSteps = 11,
WritingJourney = state.WritingJourney, WritingJourney = state.WritingJourney,
WritingSoftware = state.WritingSoftware, WritingSoftware = state.WritingSoftware,
ProjectID = state.ProjectID, ProjectID = state.ProjectID,

View File

@ -66,9 +66,9 @@ public sealed class OnboardingStoryIntelligenceService(
var included = IncludedChapters(preview, review); var included = IncludedChapters(preview, review);
var existingBatch = await batchStore.GetLatestForPreviewAsync(userId, preview.PreviewID); var existingBatch = await batchStore.GetLatestForPreviewAsync(userId, preview.PreviewID);
var missingTextCount = included.Count(chapter => string.IsNullOrWhiteSpace(chapter.Preview.ChapterText)); var missingTextCount = included.Count(chapter => string.IsNullOrWhiteSpace(chapter.Preview.ChapterText));
var primaryCharacterCount = preview.CharacterCandidates.Count(candidate => !string.Equals(candidate.Category, "Excluded", StringComparison.OrdinalIgnoreCase)); const int storySummaryStep = 9;
var characterPageCount = Math.Max(1, (int)Math.Ceiling(primaryCharacterCount / 10d)); const int storyContextStep = 10;
var totalWizardSteps = 7 + characterPageCount + 3; const int totalWizardSteps = 11;
return new StoryIntelligenceOverviewViewModel return new StoryIntelligenceOverviewViewModel
{ {
PreviewID = preview.PreviewID, PreviewID = preview.PreviewID,
@ -79,8 +79,8 @@ public sealed class OnboardingStoryIntelligenceService(
ApprovedChapterCount = included.Count, ApprovedChapterCount = included.Count,
ApprovedWordCount = included.Sum(chapter => chapter.Preview.WordCount), ApprovedWordCount = included.Sum(chapter => chapter.Preview.WordCount),
MissingChapterTextCount = missingTextCount, MissingChapterTextCount = missingTextCount,
WizardCurrentStep = totalWizardSteps - 2, WizardCurrentStep = storySummaryStep,
WizardContextStep = totalWizardSteps - 1, WizardContextStep = storyContextStep,
WizardTotalSteps = totalWizardSteps, WizardTotalSteps = totalWizardSteps,
ExistingBatchID = existingBatch?.BatchID, ExistingBatchID = existingBatch?.BatchID,
CanStart = included.Count > 0 && missingTextCount == 0, CanStart = included.Count > 0 && missingTextCount == 0,

View File

@ -75,6 +75,10 @@ public sealed class WordCompanionService(
"Course", "Thing", "Things", "Still", "Err", "Er", "Erm", "Alright", "Allright", "Alrite", "New", "Course", "Thing", "Things", "Still", "Err", "Er", "Erm", "Alright", "Allright", "Alrite", "New",
"Somewhere", "Coke", "Like", "Don", "Isn" "Somewhere", "Coke", "Like", "Don", "Isn"
}; };
private static readonly HashSet<string> CharacterDiscoveryFragmentWords = new(StringComparer.OrdinalIgnoreCase)
{
"Actually", "Eventually", "Finally", "Immediately", "Later", "Meanwhile", "Narrated", "Suddenly", "Then"
};
private static readonly HashSet<string> CharacterDiscoveryRelationshipTitles = new(StringComparer.OrdinalIgnoreCase) private static readonly HashSet<string> CharacterDiscoveryRelationshipTitles = new(StringComparer.OrdinalIgnoreCase)
{ {
"Mum", "Dad", "Mother", "Father", "Gran", "Grandma", "Grandad", "Nan", "Nana", "Granny", "Brother", "Mum", "Dad", "Mother", "Father", "Gran", "Grandma", "Grandad", "Nan", "Nana", "Granny", "Brother",
@ -693,6 +697,7 @@ public sealed class WordCompanionService(
var hasTemporalWord = words.Any(word => CharacterDiscoveryTemporalWords.Contains(TrimHonorificPunctuation(word))); var hasTemporalWord = words.Any(word => CharacterDiscoveryTemporalWords.Contains(TrimHonorificPunctuation(word)));
var hasLocationIndicator = words.Any(word => CharacterDiscoveryLocationIndicators.Contains(TrimHonorificPunctuation(word))); var hasLocationIndicator = words.Any(word => CharacterDiscoveryLocationIndicators.Contains(TrimHonorificPunctuation(word)));
var hasStopWord = words.Any(word => CharacterDiscoveryStopWords.Contains(TrimHonorificPunctuation(word))); var hasStopWord = words.Any(word => CharacterDiscoveryStopWords.Contains(TrimHonorificPunctuation(word)));
var hasFragmentWord = words.Any(word => CharacterDiscoveryFragmentWords.Contains(TrimHonorificPunctuation(word)));
var isRelationshipTitle = words.Length == 1 && CharacterDiscoveryRelationshipTitles.Contains(first); var isRelationshipTitle = words.Length == 1 && CharacterDiscoveryRelationshipTitles.Contains(first);
var isSingleWord = words.Length == 1; var isSingleWord = words.Length == 1;
var hasKnownGivenName = CharacterSexHintService.Suggest(first) != CharacterSexHintService.Unknown; var hasKnownGivenName = CharacterSexHintService.Suggest(first) != CharacterSexHintService.Unknown;
@ -779,7 +784,7 @@ public sealed class WordCompanionService(
score -= 3; score -= 3;
} }
var category = CandidateCategory(score, hasStopWord, isRelationshipTitle, hasTemporalWord, hasLocationIndicator, evidence, isSingleWord, mostlySentenceStart, hasKnownGivenName || hasNameLikeWord, hasHonorific); var category = CandidateCategory(score, hasStopWord || hasFragmentWord, isRelationshipTitle, hasTemporalWord, hasLocationIndicator, evidence, isSingleWord, mostlySentenceStart, hasKnownGivenName || hasNameLikeWord, hasHonorific);
var confidence = score >= 4 ? "High" : score >= 2 ? "Medium" : "Low"; var confidence = score >= 4 ? "High" : score >= 2 ? "Medium" : "Low";
if (hasLocationIndicator && evidence.DialogueEvidenceCount == 0 && !hasHonorific) if (hasLocationIndicator && evidence.DialogueEvidenceCount == 0 && !hasHonorific)
{ {
@ -797,7 +802,7 @@ public sealed class WordCompanionService(
Confidence = confidence, Confidence = confidence,
QualityScore = score, QualityScore = score,
Category = category, Category = category,
Reason = BuildCandidateReason(evidence, hasStopWord, isRelationshipTitle, hasHonorific, hasMultiWordName, hasTemporalWord, hasLocationIndicator, mostlySentenceStart) Reason = BuildCandidateReason(evidence, hasStopWord || hasFragmentWord, isRelationshipTitle, hasHonorific, hasMultiWordName, hasTemporalWord, hasLocationIndicator, mostlySentenceStart)
}; };
} }
@ -842,6 +847,14 @@ public sealed class WordCompanionService(
{ {
return "Excluded"; return "Excluded";
} }
if (hasLocationIndicator
&& !hasHonorific
&& !hasNameEvidence
&& evidence.DialogueEvidenceCount == 0
&& evidence.PossessiveEvidenceCount == 0)
{
return "Excluded";
}
if (hasLocationIndicator && score < 4) if (hasLocationIndicator && score < 4)
{ {
return "Excluded"; return "Excluded";

View File

@ -27,8 +27,8 @@
var requestedCharacterPage = ViewData["CharacterReviewPage"] is int requestedPage ? requestedPage : 1; var requestedCharacterPage = ViewData["CharacterReviewPage"] is int requestedPage ? requestedPage : 1;
var characterPage = Math.Clamp(requestedCharacterPage, 1, characterReviewPageCount); var characterPage = Math.Clamp(requestedCharacterPage, 1, characterReviewPageCount);
var currentCharacters = 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 currentStep = isCharacterStep ? 8 : 7;
var totalSteps = 7 + characterReviewPageCount + 2; const int totalSteps = 11;
var characterPageTitle = $"Review Characters {characterPage} of {primaryCharacterPageCount}"; var characterPageTitle = $"Review Characters {characterPage} of {primaryCharacterPageCount}";
var duplicateCanonicalNames = primaryCharacters var duplicateCanonicalNames = primaryCharacters
.Where(candidate => candidate.Include && string.IsNullOrWhiteSpace(candidate.AliasTargetTemporaryCharacterKey)) .Where(candidate => candidate.Include && string.IsNullOrWhiteSpace(candidate.AliasTargetTemporaryCharacterKey))
@ -62,8 +62,8 @@
<div class="onboarding-copy"> <div class="onboarding-copy">
<p class="eyebrow">Step @currentStep of @totalSteps</p> <p class="eyebrow">Step @currentStep of @totalSteps</p>
<h1 id="scan-review-title">@(isCharacterStep ? characterPageTitle : "Review Chapters")</h1> <h1 id="scan-review-title">@(isCharacterStep ? "Review Characters" : "Review Chapters")</h1>
<p>@(isCharacterStep ? "Review this page of detected names and aliases, then continue through the wizard." : "Confirm the chapter and scene structure before reviewing detected characters.")</p> <p>@(isCharacterStep ? "Review this page of detected names and aliases, then continue through the character section." : "Confirm the chapter and scene structure before reviewing detected characters.")</p>
</div> </div>
@if (TempData["OnboardingReviewMessage"] is string reviewMessage) @if (TempData["OnboardingReviewMessage"] is string reviewMessage)
@ -193,6 +193,15 @@
</div> </div>
<aside class="onboarding-review-section" data-scan-review-panel="characters" hidden="@(!isCharacterStep)"> <aside class="onboarding-review-section" data-scan-review-panel="characters" hidden="@(!isCharacterStep)">
<div class="onboarding-subprogress" aria-label="Character review progress">
<div>
<span>Character page @characterPage of @primaryCharacterPageCount</span>
<strong>@Math.Min(characterPage * characterPageSize, primaryCharacters.Count).ToString("N0") / @primaryCharacters.Count.ToString("N0") reviewed</strong>
</div>
<div class="onboarding-progress-track">
<span style="width:@((characterPage * 100) / primaryCharacterPageCount)%"></span>
</div>
</div>
<div class="onboarding-review-section-heading"> <div class="onboarding-review-section-heading">
<div> <div>
<h2>@characterPageTitle</h2> <h2>@characterPageTitle</h2>

View File

@ -1,16 +1,15 @@
@model OnboardingJourneyHeaderViewModel @model OnboardingJourneyHeaderViewModel
@{ @{
var characterReviewStepCount = Math.Max(1, Model.TotalSteps - 10); var steps = BuildDefaultSteps();
var steps = Model.TotalSteps <= 10 var totalSteps = Math.Max(Model.TotalSteps, steps.Count);
? BuildDefaultSteps() var currentStep = Math.Clamp(Model.CurrentStep, 1, totalSteps);
: BuildDynamicSteps(characterReviewStepCount, Model.TotalSteps); var progressWidth = totalSteps <= 1
var progressWidth = Model.TotalSteps <= 1
? 100 ? 100
: ((Math.Clamp(Model.CurrentStep, 1, Model.TotalSteps) - 1) * 100) / (Model.TotalSteps - 1); : ((currentStep - 1) * 100) / (totalSteps - 1);
} }
<div class="onboarding-progress" aria-label="@Model.AriaLabel"> <div class="onboarding-progress" aria-label="@Model.AriaLabel">
<span>Step @Model.CurrentStep of @Model.TotalSteps</span> <span>Step @currentStep of @totalSteps</span>
<div class="onboarding-progress-track"> <div class="onboarding-progress-track">
<span style="width:@progressWidth%"></span> <span style="width:@progressWidth%"></span>
</div> </div>
@ -18,7 +17,7 @@
<ol class="onboarding-stepper" style="--onboarding-step-count:@steps.Count" aria-label="Manuscript import journey"> <ol class="onboarding-stepper" style="--onboarding-step-count:@steps.Count" aria-label="Manuscript import journey">
@foreach (var step in steps) @foreach (var step in steps)
{ {
<li class="@StepClass(step.Order, Model.CurrentStep)"> <li class="@StepClass(step.Order, currentStep)">
<span>@step.Order</span> <span>@step.Order</span>
<strong>@step.Label</strong> <strong>@step.Label</strong>
</li> </li>
@ -38,34 +37,11 @@
new("Connect Word", 6), new("Connect Word", 6),
new("Review Chapters", 7), new("Review Chapters", 7),
new("Review Characters", 8), new("Review Characters", 8),
new("Story Context", 9), new("Story Summary", 9),
new("Complete", 10) new("Story Context", 10),
new("Complete", 11)
]; ];
private static IReadOnlyList<HeaderStep> BuildDynamicSteps(int characterReviewStepCount, int totalSteps)
{
var steps = new List<HeaderStep>
{
new("Welcome", 1),
new("Writing Stage", 2),
new("Writing Software", 3),
new("Project", 4),
new("Book", 5),
new("Connect Word", 6),
new("Review Chapters", 7)
};
for (var index = 1; index <= characterReviewStepCount; index++)
{
steps.Add(new HeaderStep($"Characters {index}", 7 + index));
}
steps.Add(new HeaderStep("Story Summary", totalSteps - 2));
steps.Add(new HeaderStep("Story Context", totalSteps - 1));
steps.Add(new HeaderStep("Complete", totalSteps));
return steps;
}
private static string StepClass(int order, int currentStep) private static string StepClass(int order, int currentStep)
{ {
if (order < currentStep) if (order < currentStep)

View File

@ -514,6 +514,30 @@
color: var(--bs-secondary-color); color: var(--bs-secondary-color);
} }
.onboarding-subprogress {
display: grid;
gap: .55rem;
border: 1px solid rgba(47, 111, 99, .16);
border-radius: 8px;
padding: .8rem;
background: rgba(47, 111, 99, .06);
}
.onboarding-subprogress div:first-child {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
color: var(--bs-secondary-color);
font-size: .86rem;
font-weight: 800;
}
.onboarding-subprogress strong {
color: var(--bs-body-color);
white-space: nowrap;
}
.onboarding-character-filter { .onboarding-character-filter {
display: grid; display: grid;
gap: .35rem; gap: .35rem;