diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 4e56cbb..47deb3e 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -21,6 +21,8 @@ var tests = new (string Name, Action Test)[] ("Repaired JSON deserialises into SceneIntelligenceScene", RepairedJsonDeserialises), ("Chapter Structure confidence 0. repairs and deserialises", ChapterStructureConfidenceRepairs), ("Chapter Structure boundary normaliser repairs gaps and overlaps", ChapterStructureBoundaryNormaliserRepairsGapsAndOverlaps), + ("Chapter Structure boundary normaliser handles terminal ghost paragraph", ChapterStructureBoundaryNormaliserHandlesTerminalGhostParagraph), + ("Chapter Structure boundary normaliser preserves genuine inverted ranges for validation", ChapterStructureBoundaryNormaliserPreservesGenuineInvertedRanges), ("Shared parser reports raw chapter output on unrecoverable JSON", SharedParserReportsRawOutput), ("Character filtering rejects generic groups", CharacterFilteringRejectsGenericGroups), ("Character filtering preserves titled names", CharacterFilteringPreservesTitledNames), @@ -67,6 +69,7 @@ var tests = new (string Name, Action Test)[] ("Structural import persists paragraph mapping and source text", StructuralImportPersistsParagraphMappingAndSourceText), ("Structural import persists canonical scene source text", StructuralImportPersistsCanonicalSceneSourceText), ("Structural import adds summaries without extra scene AI calls", StructuralImportAddsSummariesWithoutExtraSceneAiCalls), + ("Core import scan stage defers scenes and characters", CoreImportScanStageDefersScenesAndCharacters), ("Core import deterministically maps known characters", CoreImportDeterministicallyMapsKnownCharacters), ("Core import wizard orders scenes before characters", CoreImportWizardOrdersScenesBeforeCharacters), ("Core import remaps characters after review", CoreImportRemapsCharactersAfterReview), @@ -907,6 +910,89 @@ static void ChapterStructureBoundaryNormaliserRepairsGapsAndOverlaps() Assert(result.Issues.Count >= 2, "Boundary repairs should be reported as warnings."); } +static void ChapterStructureBoundaryNormaliserHandlesTerminalGhostParagraph() +{ + var chapter = new ChapterStructureModel + { + SchemaVersion = "1.0", + ChapterSummary = "A chapter with a terminal ghost paragraph response.", + SceneBoundaries = + [ + new ChapterSceneBoundary + { + SceneNumber = 1, + StartParagraph = 1, + EndParagraph = 618, + StructuralSummary = "The only real scene covers the valid structural paragraphs.", + Confidence = 0.8m, + Reason = "Continuous scene." + }, + new ChapterSceneBoundary + { + SceneNumber = 2, + StartParagraph = 619, + EndParagraph = 618, + StructuralSummary = "Invalid terminal ghost paragraph.", + Confidence = 0.1m, + Reason = "Terminal off-by-one." + } + ] + }; + + var result = ChapterStructureBoundaryNormaliser.Normalise(chapter, 618); + var boundaries = result.ChapterStructure?.SceneBoundaries; + + Assert(boundaries is { Count: 1 }, "Terminal 619 of 618 ghost boundary should be discarded."); + Assert(boundaries[0].StartParagraph == 1 && boundaries[0].EndParagraph == 618, "Final valid scene should still end at structural paragraph 618."); + Assert(result.Issues.Any(issue => issue.Message.Contains("terminal off-by-one", StringComparison.OrdinalIgnoreCase)), "Terminal off-by-one repair should be reported."); +} + +static void ChapterStructureBoundaryNormaliserPreservesGenuineInvertedRanges() +{ + var chapter = new ChapterStructureModel + { + SchemaVersion = "1.0", + ChapterSummary = "A chapter with a malformed middle boundary.", + SceneBoundaries = + [ + new ChapterSceneBoundary + { + SceneNumber = 1, + StartParagraph = 1, + EndParagraph = 10, + StructuralSummary = "Opening valid scene summary with enough detail.", + Confidence = 0.8m, + Reason = "Opening." + }, + new ChapterSceneBoundary + { + SceneNumber = 2, + StartParagraph = 11, + EndParagraph = 10, + StructuralSummary = "Malformed scene summary with inverted range.", + Confidence = 0.8m, + Reason = "Malformed." + }, + new ChapterSceneBoundary + { + SceneNumber = 3, + StartParagraph = 12, + EndParagraph = 20, + StructuralSummary = "Final valid scene summary with enough detail.", + Confidence = 0.8m, + Reason = "Ending." + } + ] + }; + + var normalised = ChapterStructureBoundaryNormaliser.Normalise(chapter, 20); + var validation = new ChapterStructureValidator().Validate(normalised.ChapterStructure, 20); + ChapterStructureBoundaryNormaliser.AddIssuesTo(validation, normalised); + + Assert(!validation.IsValid, "A genuine inverted middle range should still fail validation."); + Assert(validation.Errors.Any(error => error.Message.Contains("Start paragraph cannot be after end paragraph", StringComparison.Ordinal)), "Validation should report the inverted range."); +} + static void SharedParserReportsRawOutput() { const string raw = """{"schemaVersion":"1.0","sceneBoundaries":["""; @@ -1606,6 +1692,40 @@ static void StructuralImportAddsSummariesWithoutExtraSceneAiCalls() Assert(analyzer.Split("ExecutePromptAsync", StringSplitOptions.None).Length - 1 == 1, "Structural summaries must be produced by the existing chapter-boundary call, not extra scene calls."); } +static void CoreImportScanStageDefersScenesAndCharacters() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var companion = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js")); + var presence = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-presence.js")); + var hub = File.ReadAllText(Path.Combine(root, "Hubs/WordCompanionFollowHub.cs")); + var previewStore = File.ReadAllText(Path.Combine(root, "Services/ManuscriptScanPreviewStore.cs")); + var scanReview = File.ReadAllText(Path.Combine(root, "Views/Onboarding/ScanReview.cshtml")); + var index = File.ReadAllText(Path.Combine(root, "Views/Onboarding/Index.cshtml")); + var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs")); + var wordService = File.ReadAllText(Path.Combine(root, "Services/WordCompanionService.cs")); + + var onboardingScan = companion[companion.IndexOf("const buildOnboardingScanPreview", StringComparison.Ordinal)..companion.IndexOf("const scanCommandValue", StringComparison.Ordinal)]; + Assert(onboardingScan.Contains("sceneCount: 0", StringComparison.Ordinal), "Initial onboarding scan should report zero scenes."); + Assert(onboardingScan.Contains("characterCandidateCount: 0", StringComparison.Ordinal), "Initial onboarding scan should report zero characters."); + Assert(onboardingScan.Contains("scenes: []", StringComparison.Ordinal), "Initial onboarding scan should not send provisional scene rows."); + Assert(onboardingScan.Contains("characterCandidates: []", StringComparison.Ordinal), "Initial onboarding scan should not send provisional character rows."); + Assert(!onboardingScan.Contains("documentTextForDiscovery", StringComparison.Ordinal), "Initial onboarding scan should not prepare text for character discovery."); + Assert(!onboardingScan.Contains("/api/word-companion/manuscript/discover-characters", StringComparison.Ordinal), "Onboarding scan should not call character discovery."); + Assert(hub.Contains("SceneCount = 0", StringComparison.Ordinal), "Hub normalisation should discard scan-time scene counts."); + Assert(hub.Contains("CharacterCandidateCount = 0", StringComparison.Ordinal), "Hub normalisation should discard scan-time character counts."); + Assert(previewStore.Contains("Scenes = []", StringComparison.Ordinal), "Preview storage should keep scan-time scenes empty."); + Assert(previewStore.Contains("CharacterCandidates = []", StringComparison.Ordinal), "Preview storage should keep scan-time character candidates empty."); + Assert(scanReview.Contains("Scenes", StringComparison.Ordinal), "Review Chapters should show the Scenes status card."); + Assert(scanReview.Contains("var displayedSceneCount = isCharacterStep ? Model.SelectedSceneCount : 0;", StringComparison.Ordinal), "Review Chapters should show zero scenes before scene detection."); + Assert(scanReview.Contains("var displayedCharacterCount = isCharacterStep ? Model.SelectedCharacterCount : 0;", StringComparison.Ordinal), "Review Chapters should show zero characters before character review."); + Assert(scanReview.Contains("

Chapters

", StringComparison.Ordinal), "Review Chapters section heading should not mention scenes."); + Assert(index.Contains("data-onboarding-scan-scenes", StringComparison.Ordinal), "Scan page should display the Scenes counter."); + Assert(presence.Contains("Opening chapter review...", StringComparison.Ordinal), "Successful scan should show the immediate auto-navigation state."); + Assert(onboarding.Contains("DiscoverCoreImportCharacterCandidates(canonicalSceneSources)", StringComparison.Ordinal), "Character candidates should be generated after canonical scene sources exist."); + Assert(wordService.Contains("DiscoverCoreImportCharacterCandidates", StringComparison.Ordinal), "Post-scene character discovery should be explicit."); + Assert(wordService.Contains("Source SceneIDs", StringComparison.Ordinal), "Post-scene character candidates should carry canonical scene evidence."); +} + static void CoreImportDeterministicallyMapsKnownCharacters() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); diff --git a/PlotLine/Docs/AI/Chapter-Structure-Prompt-V2.md b/PlotLine/Docs/AI/Chapter-Structure-Prompt-V2.md index 974d202..25e95e2 100644 --- a/PlotLine/Docs/AI/Chapter-Structure-Prompt-V2.md +++ b/PlotLine/Docs/AI/Chapter-Structure-Prompt-V2.md @@ -86,6 +86,7 @@ Each `startParagraph` and `endParagraph` must refer to those explicit paragraph Never invent paragraph numbers. Never return an `endParagraph` greater than the highest paragraph number supplied. +The final scene must end at the highest paragraph number supplied. Do not create a separate final scene for a closing line unless it corresponds to an actual supplied paragraph number. If the final beat belongs to the previous scene, include it in the previous scene. diff --git a/PlotLine/Hubs/WordCompanionFollowHub.cs b/PlotLine/Hubs/WordCompanionFollowHub.cs index a7f5b86..370b7f6 100644 --- a/PlotLine/Hubs/WordCompanionFollowHub.cs +++ b/PlotLine/Hubs/WordCompanionFollowHub.cs @@ -345,7 +345,6 @@ public sealed class WordCompanionFollowHub( private static ManuscriptScanPreview NormalisePreviewContext(ManuscriptScanPreview preview, int userId, UserOnboardingState state) { - var characterCandidates = WordCompanionService.DiscoverManuscriptScanCharacterCandidates(preview); return new() { PreviewID = preview.PreviewID, @@ -358,12 +357,12 @@ public sealed class WordCompanionFollowHub( CompanionDocumentIdentifier = preview.CompanionDocumentIdentifier, TotalWordCount = preview.TotalWordCount, ChapterCount = preview.ChapterCount, - SceneCount = preview.SceneCount, - CharacterCandidateCount = characterCandidates.Count, + SceneCount = 0, + CharacterCandidateCount = 0, CreatedUtc = preview.CreatedUtc, Chapters = preview.Chapters, - Scenes = preview.Scenes, - CharacterCandidates = characterCandidates + Scenes = [], + CharacterCandidates = [] }; } } diff --git a/PlotLine/Services/ChapterStructureBoundaryNormaliser.cs b/PlotLine/Services/ChapterStructureBoundaryNormaliser.cs index 24b0ccd..ad75bd5 100644 --- a/PlotLine/Services/ChapterStructureBoundaryNormaliser.cs +++ b/PlotLine/Services/ChapterStructureBoundaryNormaliser.cs @@ -36,8 +36,32 @@ public static class ChapterStructureBoundaryNormaliser SuggestedFix = "Do not return a final scene boundary unless it references an actual supplied paragraph." }); } + else if (IsTrailingTerminalOffByOne(final, previous, paragraphCount, phantomParagraph)) + { + boundaries = boundaries + .Take(finalIndex) + .Select((boundary, index) => CopyBoundary(boundary, index + 1)) + .ToList(); - var continuity = RepairContinuity(chapterStructure, boundaries, paragraphCount); + issues.Add(new ValidationIssue + { + Severity = "Warning", + Path = $"sceneBoundaries[{finalIndex}]", + Message = $"Discarded trailing terminal off-by-one scene boundary {phantomParagraph}-{paragraphCount} because previous scene already ended at the final structural paragraph {paragraphCount}.", + SuggestedFix = $"Do not return a boundary starting after the final supplied paragraph {paragraphCount}." + }); + } + + var boundaryModel = ReferenceEquals(boundaries, chapterStructure.SceneBoundaries) + ? chapterStructure + : new ChapterStructureModel + { + SchemaVersion = chapterStructure.SchemaVersion, + ChapterSummary = chapterStructure.ChapterSummary, + SceneBoundaries = boundaries.ToList(), + ExtensionData = chapterStructure.ExtensionData + }; + var continuity = RepairContinuity(boundaryModel, boundaries, paragraphCount); issues.AddRange(continuity.Issues); return new ChapterStructureBoundaryNormalisationResult(continuity.ChapterStructure, issues); } @@ -152,11 +176,9 @@ public static class ChapterStructureBoundaryNormaliser { Severity = "Warning", Path = $"sceneBoundaries[{index}].endParagraph", - Message = $"Adjusted scene {index + 1:N0} end paragraph from {end:N0} to {start:N0} after boundary continuity repair.", + Message = $"Scene {index + 1:N0} has start paragraph {start:N0} after end paragraph {end:N0}; this cannot be safely normalised.", SuggestedFix = "Return an endParagraph greater than or equal to startParagraph." }); - end = start; - changed = true; } if (end > paragraphCount) @@ -252,6 +274,15 @@ public static class ChapterStructureBoundaryNormaliser return invalidTrailingRange && HasPlaceholderSignal(final); } + private static bool IsTrailingTerminalOffByOne( + ChapterSceneBoundary final, + ChapterSceneBoundary previous, + int paragraphCount, + int phantomParagraph) + => final.StartParagraph == phantomParagraph + && final.EndParagraph == paragraphCount + && previous.EndParagraph == paragraphCount; + private static bool HasPlaceholderSignal(ChapterSceneBoundary boundary) { if (boundary.Confidence <= 0.05m) diff --git a/PlotLine/Services/ManuscriptScanPreviewStore.cs b/PlotLine/Services/ManuscriptScanPreviewStore.cs index 623ea03..94d1726 100644 --- a/PlotLine/Services/ManuscriptScanPreviewStore.cs +++ b/PlotLine/Services/ManuscriptScanPreviewStore.cs @@ -16,6 +16,7 @@ public interface IManuscriptScanPreviewStore Task GetPreviewAsync(int userId, Guid previewId); Task GetReviewAsync(int userId, Guid previewId); Task SaveReviewAsync(int userId, ManuscriptScanReviewDecision review); + Task SaveCharacterCandidatesAsync(int userId, Guid previewId, IReadOnlyList candidates); Task BuildProgressAsync(int userId, Guid previewId, string message, int percentComplete, OnboardingManuscriptBuildResult? result = null); Task GetBuildResultAsync(int userId, Guid previewId); Task SaveBuildResultAsync(int userId, OnboardingManuscriptBuildResult result); @@ -92,8 +93,8 @@ public sealed class ManuscriptScanPreviewStore( session.Message = "Scan complete"; session.PercentComplete = 100; session.ChapterCount = stored.ChapterCount; - session.SceneCount = stored.SceneCount; - session.CharacterCandidateCount = stored.CharacterCandidateCount; + session.SceneCount = 0; + session.CharacterCandidateCount = 0; session.TotalWordCount = stored.TotalWordCount; session.PreviewID = stored.PreviewID; session.UpdatedUtc = stored.CreatedUtc; @@ -193,6 +194,45 @@ public sealed class ManuscriptScanPreviewStore( return Task.FromResult(stored); } + public Task SaveCharacterCandidatesAsync(int userId, Guid previewId, IReadOnlyList candidates) + { + if (!previews.TryGetValue(previewId, out var preview) || preview.UserID != userId) + { + return Task.FromResult(null); + } + + var cleaned = candidates + .Where(candidate => !string.IsNullOrWhiteSpace(candidate.Name)) + .ToList(); + var updated = new ManuscriptScanPreview + { + PreviewID = preview.PreviewID, + UserID = preview.UserID, + OnboardingID = preview.OnboardingID, + ProjectID = preview.ProjectID, + BookID = preview.BookID, + Source = preview.Source, + DocumentTitle = preview.DocumentTitle, + CompanionDocumentIdentifier = preview.CompanionDocumentIdentifier, + TotalWordCount = preview.TotalWordCount, + ChapterCount = preview.ChapterCount, + SceneCount = preview.SceneCount, + CharacterCandidateCount = cleaned.Count, + CreatedUtc = preview.CreatedUtc, + Chapters = preview.Chapters, + Scenes = preview.Scenes, + CharacterCandidates = cleaned + }; + previews[previewId] = updated; + if (sessions.TryGetValue((userId, preview.OnboardingID), out var session)) + { + session.CharacterCandidateCount = cleaned.Count; + session.UpdatedUtc = DateTime.UtcNow; + } + + return Task.FromResult(updated); + } + public Task BuildProgressAsync(int userId, Guid previewId, string message, int percentComplete, OnboardingManuscriptBuildResult? result = null) { if (!previews.TryGetValue(previewId, out var preview) || preview.UserID != userId) @@ -256,7 +296,6 @@ public sealed class ManuscriptScanPreviewStore( private async Task BuildDefaultReviewAsync(ManuscriptScanPreview preview) { - var aiHints = await GetAiSexHintsAsync(preview.CharacterCandidates); return new ManuscriptScanReviewDecision { PreviewID = preview.PreviewID, @@ -271,18 +310,7 @@ public sealed class ManuscriptScanPreviewStore( ChapterNumber = chapter.ChapterNumber }) .ToList(), - Scenes = preview.Scenes - .OrderBy(scene => scene.TemporaryChapterKey) - .ThenBy(scene => scene.SceneNumberWithinChapter) - .Select(scene => new ManuscriptScanSceneReviewDecision - { - TemporarySceneKey = scene.TemporarySceneKey, - TemporaryChapterKey = scene.TemporaryChapterKey, - Include = true, - Title = string.IsNullOrWhiteSpace(scene.Title) ? $"Scene {scene.SceneNumberWithinChapter}" : scene.Title, - SceneNumberWithinChapter = scene.SceneNumberWithinChapter - }) - .ToList(), + Scenes = [], Characters = preview.CharacterCandidates .Select(candidate => new ManuscriptScanCharacterReviewDecision { @@ -290,9 +318,7 @@ public sealed class ManuscriptScanPreviewStore( Include = string.Equals(candidate.Category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase), Name = candidate.Name, Category = candidate.Category, - SexHint = aiHints.TryGetValue(candidate.TemporaryCharacterKey, out var aiHint) - ? aiHint - : CharacterSexHintService.Suggest(candidate.Name) + SexHint = CharacterSexHintService.Suggest(candidate.Name) }) .ToList() }; @@ -353,11 +379,11 @@ file static class ManuscriptScanPreviewExtensions CompanionDocumentIdentifier = preview.CompanionDocumentIdentifier, TotalWordCount = preview.TotalWordCount, ChapterCount = preview.ChapterCount, - SceneCount = preview.SceneCount, - CharacterCandidateCount = preview.CharacterCandidateCount, + SceneCount = 0, + CharacterCandidateCount = 0, CreatedUtc = preview.CreatedUtc == default ? DateTime.UtcNow : preview.CreatedUtc, Chapters = preview.Chapters, - Scenes = preview.Scenes, - CharacterCandidates = preview.CharacterCandidates + Scenes = [], + CharacterCandidates = [] }; } diff --git a/PlotLine/Services/ManuscriptStructureAnalysisService.cs b/PlotLine/Services/ManuscriptStructureAnalysisService.cs index 47bac1b..54ccec1 100644 --- a/PlotLine/Services/ManuscriptStructureAnalysisService.cs +++ b/PlotLine/Services/ManuscriptStructureAnalysisService.cs @@ -73,9 +73,17 @@ public sealed class ManuscriptStructureAnalysisService( if (!validation.IsValid || chapter?.SceneBoundaries is null || chapter.SceneBoundaries.Count == 0) { + var diagnostic = BuildBoundaryDiagnostic(chapter, request.ParagraphMap, paragraphs.Count); + logger.LogWarning( + "Structural scene-boundary validation failed. ProjectID={ProjectID} BookID={BookID} TemporaryChapterKey={TemporaryChapterKey} StructuralParagraphCount={StructuralParagraphCount} Diagnostic={Diagnostic}", + request.ProjectID, + request.BookID, + request.TemporaryChapterKey, + paragraphs.Count, + diagnostic); return ManuscriptStructureAnalysisResult.Failed( request.TemporaryChapterKey, - BuildValidationMessage(validation), + $"{BuildValidationMessage(validation)} {diagnostic}", persistedJson); } @@ -215,6 +223,42 @@ public sealed class ManuscriptStructureAnalysisService( return first; } + private static string BuildBoundaryDiagnostic( + ChapterStructureModel? chapter, + IReadOnlyList paragraphMap, + int structuralParagraphCount) + { + if (chapter?.SceneBoundaries is null || chapter.SceneBoundaries.Count == 0) + { + return $"Structural paragraphs: 1..{structuralParagraphCount:N0}; no scene boundaries were returned."; + } + + var finalMap = paragraphMap + .Where(item => item.StructuralParagraphOrdinal == structuralParagraphCount) + .Select(item => item.WordParagraphIndex) + .FirstOrDefault(); + var ranges = chapter.SceneBoundaries + .OrderBy(boundary => boundary.SceneNumber ?? int.MaxValue) + .Select(boundary => + { + var number = boundary.SceneNumber?.ToString("N0") ?? "?"; + var start = boundary.StartParagraph; + var end = boundary.EndParagraph; + var startWord = start.HasValue + ? paragraphMap.FirstOrDefault(item => item.StructuralParagraphOrdinal == start.Value)?.WordParagraphIndex + : null; + var endWord = end.HasValue + ? paragraphMap.FirstOrDefault(item => item.StructuralParagraphOrdinal == end.Value)?.WordParagraphIndex + : null; + return $"scene {number}: structural {Display(start)}-{Display(end)}, Word {Display(startWord)}-{Display(endWord)}"; + }); + + return $"Structural paragraphs: 1..{structuralParagraphCount:N0}; final structural paragraph maps to Word paragraph {finalMap:N0}; returned {string.Join("; ", ranges)}."; + } + + private static string Display(int? value) + => value.HasValue ? value.Value.ToString("N0") : "?"; + private static int CountWords(string value) => string.IsNullOrWhiteSpace(value) ? 0 diff --git a/PlotLine/Services/OnboardingService.cs b/PlotLine/Services/OnboardingService.cs index ff9922a..eca104f 100644 --- a/PlotLine/Services/OnboardingService.cs +++ b/PlotLine/Services/OnboardingService.cs @@ -339,6 +339,9 @@ public sealed class OnboardingService( await SaveStructuralChapterSourcesAsync(preview, result); await SaveSceneManuscriptSourcesAsync(preview, structuralScenes, result); await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID); + var canonicalSceneSources = await builds.ListSceneManuscriptSourcesByBookAsync(preview.BookID); + var characterCandidates = WordCompanionService.DiscoverCoreImportCharacterCandidates(canonicalSceneSources); + await scanPreviews.SaveCharacterCandidatesAsync(userId, preview.PreviewID, characterCandidates); await scanPreviews.SaveBuildResultAsync(userId, result); await PublishBuildProgress(userId, preview.PreviewID, "Updating Word document with scene markers. Keep Word open while the document is saved.", 90, progress); return result; diff --git a/PlotLine/Services/WordCompanionService.cs b/PlotLine/Services/WordCompanionService.cs index 830a058..3a5fb21 100644 --- a/PlotLine/Services/WordCompanionService.cs +++ b/PlotLine/Services/WordCompanionService.cs @@ -723,6 +723,48 @@ public sealed class WordCompanionService( .ToList(); } + public static IReadOnlyList DiscoverCoreImportCharacterCandidates( + IReadOnlyList sourceScenes) + { + var manuscriptText = string.Join("\n\n", sourceScenes + .OrderBy(scene => scene.ChapterID) + .ThenBy(scene => scene.SourceStartParagraph ?? int.MaxValue) + .ThenBy(scene => scene.SceneID) + .Select(scene => scene.SourceText) + .Where(text => !string.IsNullOrWhiteSpace(text))); + if (string.IsNullOrWhiteSpace(manuscriptText)) + { + return []; + } + + var sceneIds = sourceScenes + .Where(scene => !string.IsNullOrWhiteSpace(scene.SourceText)) + .Select(scene => scene.SceneID) + .Distinct() + .Order() + .ToList(); + var sceneEvidence = sceneIds.Count == 0 + ? "Discovered from canonical scene source text." + : $"Discovered from canonical scene source text. Source SceneIDs: {string.Join(", ", sceneIds)}."; + + return DiscoverCharacterCandidates(manuscriptText) + .Where(IsAutoSelectedScanCharacterCandidate) + .Select((candidate, index) => new ManuscriptScanCharacterCandidatePreview + { + TemporaryCharacterKey = $"post-scene-{StableCandidateKey(candidate.Text, index)}", + Name = candidate.Text, + MentionCount = candidate.MentionCount, + QualityScore = candidate.QualityScore, + Category = candidate.Category, + Reason = string.IsNullOrWhiteSpace(candidate.Reason) + ? sceneEvidence + : $"{candidate.Reason} {sceneEvidence}", + IsExistingCharacterMatch = candidate.IsExistingCharacterMatch, + SuggestedImportance = candidate.Confidence + }) + .ToList(); + } + private static WordCompanionCharacterCandidateDto? BuildCandidateDto(CharacterCandidateEvidence evidence) { if (string.IsNullOrWhiteSpace(evidence.Text) || evidence.Text.Length > 200) diff --git a/PlotLine/Views/Onboarding/Index.cshtml b/PlotLine/Views/Onboarding/Index.cshtml index 0fcb12f..7f76a30 100644 --- a/PlotLine/Views/Onboarding/Index.cshtml +++ b/PlotLine/Views/Onboarding/Index.cshtml @@ -293,6 +293,10 @@ Chapters @Model.ScanState.ChapterCount +
+ Scenes + @Model.ScanState.SceneCount +
Characters @Model.ScanState.CharacterCandidateCount diff --git a/PlotLine/Views/Onboarding/ScanReview.cshtml b/PlotLine/Views/Onboarding/ScanReview.cshtml index 814c10f..365f2d5 100644 --- a/PlotLine/Views/Onboarding/ScanReview.cshtml +++ b/PlotLine/Views/Onboarding/ScanReview.cshtml @@ -55,6 +55,9 @@ && !includedPrimaryKeys.Contains(candidate.AliasTargetTemporaryCharacterKey))) .Select(candidate => candidate.TemporaryCharacterKey) .ToHashSet(StringComparer.Ordinal); + var displayedSceneCount = isCharacterStep ? Model.SelectedSceneCount : 0; + var displayedCharacterCount = isCharacterStep ? Model.SelectedCharacterCount : 0; + var displayedCharacterTotal = isCharacterStep ? visibleCharacterCount : 0; }
@@ -87,9 +90,13 @@ Chapters @Model.SelectedChapterCount / @Model.ChapterCount
+
+ Scenes + @displayedSceneCount.ToString("N0") +
Characters - @Model.SelectedCharacterCount / @visibleCharacterCount + @displayedCharacterCount.ToString("N0") / @displayedCharacterTotal.ToString("N0")
Words @@ -108,7 +115,7 @@