From 1c7cf9bee16a81ed5a9a301001d0060fde5fc9ec Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Mon, 17 Aug 2026 20:12:43 +0000 Subject: [PATCH] Keep character review within one wizard step --- PlotLine.Tests/Program.cs | 34 ++++++++++++++ PlotLine/Services/OnboardingService.cs | 2 +- .../OnboardingStoryIntelligenceService.cs | 10 ++--- PlotLine/Services/WordCompanionService.cs | 17 ++++++- PlotLine/Views/Onboarding/ScanReview.cshtml | 17 +++++-- .../_OnboardingJourneyHeader.cshtml | 44 +++++-------------- PlotLine/wwwroot/css/onboarding.css | 24 ++++++++++ 7 files changed, 102 insertions(+), 46 deletions(-) diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 3476a12..50d0c73 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -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)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")); diff --git a/PlotLine/Services/OnboardingService.cs b/PlotLine/Services/OnboardingService.cs index 83ded78..d653608 100644 --- a/PlotLine/Services/OnboardingService.cs +++ b/PlotLine/Services/OnboardingService.cs @@ -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, diff --git a/PlotLine/Services/OnboardingStoryIntelligenceService.cs b/PlotLine/Services/OnboardingStoryIntelligenceService.cs index 76043e2..7df25bf 100644 --- a/PlotLine/Services/OnboardingStoryIntelligenceService.cs +++ b/PlotLine/Services/OnboardingStoryIntelligenceService.cs @@ -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, diff --git a/PlotLine/Services/WordCompanionService.cs b/PlotLine/Services/WordCompanionService.cs index f3ccf41..0fca4b5 100644 --- a/PlotLine/Services/WordCompanionService.cs +++ b/PlotLine/Services/WordCompanionService.cs @@ -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 CharacterDiscoveryFragmentWords = new(StringComparer.OrdinalIgnoreCase) + { + "Actually", "Eventually", "Finally", "Immediately", "Later", "Meanwhile", "Narrated", "Suddenly", "Then" + }; private static readonly HashSet 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"; diff --git a/PlotLine/Views/Onboarding/ScanReview.cshtml b/PlotLine/Views/Onboarding/ScanReview.cshtml index 46dd813..f5c53a2 100644 --- a/PlotLine/Views/Onboarding/ScanReview.cshtml +++ b/PlotLine/Views/Onboarding/ScanReview.cshtml @@ -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 @@

Step @currentStep of @totalSteps

-

@(isCharacterStep ? characterPageTitle : "Review Chapters")

-

@(isCharacterStep ? "Review this page of detected names and aliases, then continue through the wizard." : "Confirm the chapter and scene structure before reviewing detected characters.")

+

@(isCharacterStep ? "Review Characters" : "Review Chapters")

+

@(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.")

@if (TempData["OnboardingReviewMessage"] is string reviewMessage) @@ -193,6 +193,15 @@