diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 3cfdc04..bdeb192 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -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() diff --git a/PlotLine/Controllers/OnboardingController.cs b/PlotLine/Controllers/OnboardingController.cs index 1a4f802..dce5565 100644 --- a/PlotLine/Controllers/OnboardingController.cs +++ b/PlotLine/Controllers/OnboardingController.cs @@ -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 Welcome() diff --git a/PlotLine/Services/OnboardingStoryIntelligenceService.cs b/PlotLine/Services/OnboardingStoryIntelligenceService.cs index 40cbc0b..eee44ac 100644 --- a/PlotLine/Services/OnboardingStoryIntelligenceService.cs +++ b/PlotLine/Services/OnboardingStoryIntelligenceService.cs @@ -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 { diff --git a/PlotLine/Views/Onboarding/ScanReview.cshtml b/PlotLine/Views/Onboarding/ScanReview.cshtml index a6c8544..5077605 100644 --- a/PlotLine/Views/Onboarding/ScanReview.cshtml +++ b/PlotLine/Views/Onboarding/ScanReview.cshtml @@ -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); }
@@ -79,7 +89,7 @@
Characters - @Model.SelectedCharacterCount / @Model.CharacterCandidateCount + @Model.SelectedCharacterCount / @visibleCharacterCount
Words @@ -114,7 +124,7 @@ } @foreach (var chapter in Model.Chapters) { -
+
Chapter @chapter.ChapterNumber @chapter.ReviewTitle @@ -187,9 +197,7 @@

@characterPageTitle

- @(isExcludedCharacterPage - ? $"Excluded candidates page {excludedPageIndex} of {excludedCharacterPageCount}." - : $"Character candidates page {characterPage} of {primaryCharacterPageCount}.") + Character candidates page @characterPage of @primaryCharacterPageCount.

@currentCharacters.Count character@(currentCharacters.Count == 1 ? string.Empty : "s") on this step @@ -200,10 +208,6 @@ Primary candidates @visibleCharacterCount.ToString("N0")
-
- Excluded - @excludedCharacters.Count.ToString("N0") -
Wizard pages @characterReviewPageCount.ToString("N0") @@ -211,19 +215,22 @@
@{ - var allCharacters = Model.CharacterCandidates.ToList(); + var allCharacters = primaryCharacters.ToList(); } @if (!currentCharacters.Any()) {

No character candidates were found for this step.

} + @if (invalidCharacterKeys.Any(key => currentCharacters.Any(candidate => string.Equals(candidate.TemporaryCharacterKey, key, StringComparison.Ordinal)))) + { +

The item that needs attention is highlighted below.

+ }
@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)); -
  • +