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 discovery does not truncate scan review candidates", CharacterDiscoveryDoesNotTruncateScanReviewCandidates),
("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),
("Onboarding wizard keeps character pages inside one main step", OnboardingWizardKeepsCharacterPagesInsideOneMainStep),
("Scan review aliases feed Story Intelligence known character context", ScanReviewAliasesFeedStoryIntelligenceKnownCharacterContext),
("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions),
("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.");
}
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()
{
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,
StepNumber = StepNumber(step),
TotalSteps = 10,
TotalSteps = 11,
WritingJourney = state.WritingJourney,
WritingSoftware = state.WritingSoftware,
ProjectID = state.ProjectID,

View File

@ -66,9 +66,9 @@ public sealed class OnboardingStoryIntelligenceService(
var included = IncludedChapters(preview, review);
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 characterPageCount = Math.Max(1, (int)Math.Ceiling(primaryCharacterCount / 10d));
var totalWizardSteps = 7 + characterPageCount + 3;
const int storySummaryStep = 9;
const int storyContextStep = 10;
const int totalWizardSteps = 11;
return new StoryIntelligenceOverviewViewModel
{
PreviewID = preview.PreviewID,
@ -79,8 +79,8 @@ public sealed class OnboardingStoryIntelligenceService(
ApprovedChapterCount = included.Count,
ApprovedWordCount = included.Sum(chapter => chapter.Preview.WordCount),
MissingChapterTextCount = missingTextCount,
WizardCurrentStep = totalWizardSteps - 2,
WizardContextStep = totalWizardSteps - 1,
WizardCurrentStep = storySummaryStep,
WizardContextStep = storyContextStep,
WizardTotalSteps = totalWizardSteps,
ExistingBatchID = existingBatch?.BatchID,
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",
"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)
{
"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 hasLocationIndicator = words.Any(word => CharacterDiscoveryLocationIndicators.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 isSingleWord = words.Length == 1;
var hasKnownGivenName = CharacterSexHintService.Suggest(first) != CharacterSexHintService.Unknown;
@ -779,7 +784,7 @@ public sealed class WordCompanionService(
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";
if (hasLocationIndicator && evidence.DialogueEvidenceCount == 0 && !hasHonorific)
{
@ -797,7 +802,7 @@ public sealed class WordCompanionService(
Confidence = confidence,
QualityScore = score,
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";
}
if (hasLocationIndicator
&& !hasHonorific
&& !hasNameEvidence
&& evidence.DialogueEvidenceCount == 0
&& evidence.PossessiveEvidenceCount == 0)
{
return "Excluded";
}
if (hasLocationIndicator && score < 4)
{
return "Excluded";

View File

@ -27,8 +27,8 @@
var requestedCharacterPage = ViewData["CharacterReviewPage"] is int requestedPage ? requestedPage : 1;
var characterPage = Math.Clamp(requestedCharacterPage, 1, characterReviewPageCount);
var currentCharacters = primaryCharacters.Skip((characterPage - 1) * characterPageSize).Take(characterPageSize).ToList();
var currentStep = isCharacterStep ? 7 + characterPage : 7;
var totalSteps = 7 + characterReviewPageCount + 2;
var currentStep = isCharacterStep ? 8 : 7;
const int totalSteps = 11;
var characterPageTitle = $"Review Characters {characterPage} of {primaryCharacterPageCount}";
var duplicateCanonicalNames = primaryCharacters
.Where(candidate => candidate.Include && string.IsNullOrWhiteSpace(candidate.AliasTargetTemporaryCharacterKey))
@ -62,8 +62,8 @@
<div class="onboarding-copy">
<p class="eyebrow">Step @currentStep of @totalSteps</p>
<h1 id="scan-review-title">@(isCharacterStep ? characterPageTitle : "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>
<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 character section." : "Confirm the chapter and scene structure before reviewing detected characters.")</p>
</div>
@if (TempData["OnboardingReviewMessage"] is string reviewMessage)
@ -193,6 +193,15 @@
</div>
<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>
<h2>@characterPageTitle</h2>

View File

@ -1,16 +1,15 @@
@model OnboardingJourneyHeaderViewModel
@{
var characterReviewStepCount = Math.Max(1, Model.TotalSteps - 10);
var steps = Model.TotalSteps <= 10
? BuildDefaultSteps()
: BuildDynamicSteps(characterReviewStepCount, Model.TotalSteps);
var progressWidth = Model.TotalSteps <= 1
var steps = BuildDefaultSteps();
var totalSteps = Math.Max(Model.TotalSteps, steps.Count);
var currentStep = Math.Clamp(Model.CurrentStep, 1, totalSteps);
var progressWidth = totalSteps <= 1
? 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">
<span>Step @Model.CurrentStep of @Model.TotalSteps</span>
<span>Step @currentStep of @totalSteps</span>
<div class="onboarding-progress-track">
<span style="width:@progressWidth%"></span>
</div>
@ -18,7 +17,7 @@
<ol class="onboarding-stepper" style="--onboarding-step-count:@steps.Count" aria-label="Manuscript import journey">
@foreach (var step in steps)
{
<li class="@StepClass(step.Order, Model.CurrentStep)">
<li class="@StepClass(step.Order, currentStep)">
<span>@step.Order</span>
<strong>@step.Label</strong>
</li>
@ -38,34 +37,11 @@
new("Connect Word", 6),
new("Review Chapters", 7),
new("Review Characters", 8),
new("Story Context", 9),
new("Complete", 10)
new("Story Summary", 9),
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)
{
if (order < currentStep)

View File

@ -514,6 +514,30 @@
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 {
display: grid;
gap: .35rem;