From 58075232e714fa32d769deb552efc44d9cb9b4e5 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Fri, 28 Aug 2026 12:41:44 +0000 Subject: [PATCH] Fix structural POV resolution lifecycle --- PlotLine.Tests/Program.cs | 79 ++++++++- PlotLine/Data/OnboardingBuildRepository.cs | 8 +- PlotLine/Services/CoreServices.cs | 29 +++- .../ManuscriptStructureAnalysisService.cs | 78 ++++++++- PlotLine/Services/OnboardingService.cs | 153 ++++++++++++++++-- .../Sql/166_Phase26B_StructuralPovAudit.sql | 51 ++++++ PlotLine/ViewModels/CoreViewModels.cs | 1 + PlotLine/Views/Chapters/Details.cshtml | 7 +- 8 files changed, 376 insertions(+), 30 deletions(-) create mode 100644 PlotLine/Sql/166_Phase26B_StructuralPovAudit.sql diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index ca45a92..253d29b 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -140,6 +140,9 @@ var tests = new (string Name, Action Test)[] ("Story Intelligence character default changes preserve persisted results", StoryIntelligenceCharacterDefaultChangesPreservePersistedResults), ("Structural POV rides existing chapter analysis", StructuralPovRidesExistingChapterAnalysis), ("Structural POV persists through import resolution", StructuralPovPersistsThroughImportResolution), + ("Explicit narrator labels detect deterministic POV", ExplicitNarratorLabelsDetectDeterministicPov), + ("AI POV JSON parses into structural models", AiPovJsonParsesIntoStructuralModels), + ("Post character review resolves persisted structural POV", PostCharacterReviewResolvesPersistedStructuralPov), ("Normal scene and chapter saves persist POV", NormalSceneAndChapterSavesPersistPov), ("Character merge is a reusable stored procedure workflow", CharacterMergeIsReusableStoredProcedureWorkflow), ("Story Intelligence character review suppresses resolved narrator POV", StoryIntelligenceCharacterReviewSuppressesResolvedNarratorPov), @@ -3396,6 +3399,80 @@ static void StructuralPovPersistsThroughImportResolution() Assert(migration.Contains("@OverwriteAuthor bit = 0", StringComparison.Ordinal), "Structural POV updates should not overwrite author POV by default."); } +static void ExplicitNarratorLabelsDetectDeterministicPov() +{ + var beth = ManuscriptStructureAnalysisService.DetectExplicitNarrator("Narrated by Beth - 7th May 1980\n\nI opened the door."); + var maggie = ManuscriptStructureAnalysisService.DetectExplicitNarrator("Narrated by: Maggie\n\nI checked my makeup."); + var narrator = ManuscriptStructureAnalysisService.DetectExplicitNarrator("narrator: Miss Grant\n\nThe test began."); + var pov = ManuscriptStructureAnalysisService.DetectExplicitNarrator("POV: Mrs Patterson\n\nShe waited."); + var absent = ManuscriptStructureAnalysisService.DetectExplicitNarrator("The morning was quiet.\n\nI opened the curtains."); + var numbered = StoryIntelligenceParagraphs.Number(StoryIntelligenceParagraphs.Split("Narrated by Beth\n\nI opened the door.")); + + Assert(beth?.CharacterName == "Beth", "Narrated by Beth should detect Beth."); + Assert(maggie?.CharacterName == "Maggie", "Narrated by: Maggie should detect Maggie."); + Assert(narrator?.CharacterName == "Miss Grant", "Narrator: should support titled names."); + Assert(pov?.CharacterName == "Mrs Patterson", "POV: should support titled names."); + Assert(beth?.Confidence == 0.99m && beth.IsExplicit == true, "Explicit narrator detection should be high-confidence explicit metadata."); + Assert(absent is null, "Plain prose should not produce deterministic narrator metadata."); + Assert(numbered.Contains("Narrated by Beth", StringComparison.Ordinal), "Structural preprocessing should preserve the narrator line in prompt input."); +} + +static void AiPovJsonParsesIntoStructuralModels() +{ + const string json = """ + { + "chapterTitle": "Danger in the Dark", + "chapterSummary": "Maggie attends Rosie's birthday.", + "defaultPov": { "characterName": "Maggie", "confidence": 0.93, "evidence": "Narrated by Maggie", "isExplicit": true }, + "mixedPov": true, + "sceneBoundaries": [ + { + "sceneNumber": 1, + "startParagraph": 1, + "endParagraph": 4, + "structuralSummary": "Maggie phones Rosie.", + "suggestedTitle": "Morning Call", + "povOverride": null, + "confidence": 0.9, + "reason": "Time and action shift." + }, + { + "sceneNumber": 2, + "startParagraph": 5, + "endParagraph": 8, + "structuralSummary": "Beth takes over the viewpoint.", + "suggestedTitle": "Beth Outside", + "povOverride": { "characterName": "Beth", "confidence": 0.88, "evidence": "Viewpoint shifts to Beth.", "isExplicit": false }, + "confidence": 0.82, + "reason": "Viewpoint shift." + } + ] + } + """; + + var parsed = JsonSerializer.Deserialize(json, JsonOptions()); + Assert(parsed?.DefaultPov?.CharacterName == "Maggie", "AI-returned defaultPov should parse."); + Assert(parsed?.MixedPov == true, "AI-returned mixedPov should parse."); + Assert(parsed is not null && parsed.SceneBoundaries[1].PovOverride?.CharacterName == "Beth", "AI-returned scene povOverride should parse."); +} + +static void PostCharacterReviewResolvesPersistedStructuralPov() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs")); + var migration = File.ReadAllText(Path.Combine(root, "Sql/166_Phase26B_StructuralPovAudit.sql")); + + var aliases = onboarding.IndexOf("await AddScanCharacterAliasesAsync(completedReview, completedResult)", StringComparison.Ordinal); + var remap = onboarding.IndexOf("await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID)", aliases, StringComparison.Ordinal); + var resolve = onboarding.IndexOf("await ResolvePersistedStructuralPovAsync(preview.ProjectID, preview.BookID)", remap, StringComparison.Ordinal); + Assert(aliases >= 0 && remap > aliases && resolve > remap, "POV should resolve after accepted aliases and deterministic character mapping."); + Assert(onboarding.Contains("NormalisePovName", StringComparison.Ordinal), "POV resolution should normalise case and punctuation."); + Assert(onboarding.Contains("AddCharacterNameParts", StringComparison.Ordinal), "POV resolution should support unambiguous first names, surnames and title-stripped variants."); + Assert(onboarding.Contains("string.Equals(scene.POVSource, \"Author\"", StringComparison.Ordinal), "POV resolution should preserve author-edited scene POV."); + Assert(migration.Contains("StructuralParsedJson", StringComparison.Ordinal), "Structural source audit should persist parsed structural JSON."); + Assert(migration.Contains("ExplicitNarratorName", StringComparison.Ordinal), "Structural source audit should persist deterministic narrator metadata."); +} + static void NormalSceneAndChapterSavesPersistPov() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); @@ -3410,7 +3487,7 @@ static void NormalSceneAndChapterSavesPersistPov() Assert(services.Contains("POVCharacterID = model.POVCharacterID", StringComparison.Ordinal), "Normal scene/chapter services should persist submitted POV values."); Assert(chaptersEdit.Contains("asp-for=\"POVCharacterID\"", StringComparison.Ordinal), "Chapter edit should expose a compact POV selector."); Assert(chaptersDetails.Contains("Apply to scenes", StringComparison.Ordinal), "Chapter details should allow POV propagation to scenes."); - Assert(chaptersDetails.Contains("POV: @chapterPov", StringComparison.Ordinal), "Chapter details should display compact POV status."); + Assert(chaptersDetails.Contains("POV: @Model.PovDisplayLabel", StringComparison.Ordinal), "Chapter details should display compact POV status."); Assert(migration.Contains("CREATE OR ALTER PROCEDURE dbo.Scene_Save", StringComparison.Ordinal), "New migration should own the Scene_Save parameter update."); Assert(migration.Contains("CREATE OR ALTER PROCEDURE dbo.Chapter_Save", StringComparison.Ordinal), "New migration should own the Chapter_Save parameter update."); } diff --git a/PlotLine/Data/OnboardingBuildRepository.cs b/PlotLine/Data/OnboardingBuildRepository.cs index 2841847..bfd1679 100644 --- a/PlotLine/Data/OnboardingBuildRepository.cs +++ b/PlotLine/Data/OnboardingBuildRepository.cs @@ -137,7 +137,10 @@ public sealed class OnboardingBuildRepository(ISqlConnectionFactory connectionFa request.SourceText, request.SourceWordCount, request.SourceCharacterCount, - request.ParagraphMapJson + request.ParagraphMapJson, + request.StructuralParsedJson, + request.DefaultPovName, + request.ExplicitNarratorName }, commandType: CommandType.StoredProcedure); } @@ -240,6 +243,9 @@ public sealed class StructuralChapterSourceSaveRequest public int SourceWordCount { get; init; } public int SourceCharacterCount { get; init; } public string? ParagraphMapJson { get; init; } + public string? StructuralParsedJson { get; init; } + public string? DefaultPovName { get; init; } + public string? ExplicitNarratorName { get; init; } } public sealed class SceneManuscriptSourceSaveRequest diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index 9a70065..c974b9b 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -1433,7 +1433,8 @@ public sealed class ChapterService( PendingCharacterSuggestionCount = sceneRows.Sum(x => x.PendingCharacterSuggestionCount), PendingAssetSuggestionCount = sceneRows.Sum(x => x.PendingAssetSuggestionCount), PendingLocationSuggestionCount = sceneRows.Sum(x => x.PendingLocationSuggestionCount), - CharacterOptions = ToOptionalSelectList(await characters.ListCharactersAsync(project.ProjectID), x => x.CharacterID, x => x.CharacterName) + CharacterOptions = ToOptionalSelectList(await characters.ListCharactersAsync(project.ProjectID), x => x.CharacterID, x => x.CharacterName), + PovDisplayLabel = BuildChapterPovDisplay(chapter, sceneRows) }; } @@ -1557,6 +1558,32 @@ public sealed class ChapterService( internal static IReadOnlyList ToOptionalSelectList(IEnumerable rows, Func value, Func text) => rows.Select(x => new SelectListItem(text(x), value(x).ToString())).Prepend(new SelectListItem("None", string.Empty)).ToList(); + + private static string BuildChapterPovDisplay(Chapter chapter, IReadOnlyList scenes) + { + if (!string.IsNullOrWhiteSpace(chapter.POVCharacterName)) + { + return chapter.POVCharacterName; + } + + if (!string.IsNullOrWhiteSpace(chapter.POVSuggestedName)) + { + return chapter.POVSuggestedName; + } + + var scenePovs = scenes + .Select(scene => scene.POVCharacterName ?? scene.POVSuggestedName) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + return scenePovs.Count switch + { + 0 => "Not detected", + 1 => scenePovs[0]!, + _ => "Mixed" + }; + } } public sealed class SceneService( diff --git a/PlotLine/Services/ManuscriptStructureAnalysisService.cs b/PlotLine/Services/ManuscriptStructureAnalysisService.cs index 0e8889d..647c3e0 100644 --- a/PlotLine/Services/ManuscriptStructureAnalysisService.cs +++ b/PlotLine/Services/ManuscriptStructureAnalysisService.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.RegularExpressions; using PlotLine.Models; using PlotLine.Models.StoryIntelligence; @@ -9,7 +10,7 @@ public interface IManuscriptStructureAnalysisService Task AnalyseChapterAsync(ManuscriptStructureAnalysisRequest request, CancellationToken cancellationToken = default); } -public sealed class ManuscriptStructureAnalysisService( +public sealed partial class ManuscriptStructureAnalysisService( IStoryPromptRepository prompts, IStoryPromptVersionService versions, IStoryIntelligenceClient client, @@ -87,6 +88,7 @@ public sealed class ManuscriptStructureAnalysisService( var parsed = parseResult.Parsed; var normalisation = ChapterStructureBoundaryNormaliser.Normalise(parsed, paragraphs.Count); var chapter = normalisation.ChapterStructure; + var explicitPov = DetectExplicitNarrator(text); var validation = validator.Validate(chapter, paragraphs.Count); ChapterStructureBoundaryNormaliser.AddIssuesTo(validation, normalisation); var persistedJson = normalisation.Issues.Count > 0 @@ -136,7 +138,8 @@ public sealed class ManuscriptStructureAnalysisService( var sceneParagraphs = paragraphs.Skip(start - 1).Take(end - start + 1).ToList(); var sourceText = BuildSourceText(request.SourceParagraphs, startMap.WordParagraphIndex, endMap.WordParagraphIndex); var suggestedTitle = CleanSuggestedTitle(boundary.SuggestedTitle, sceneNumber); - var pov = CleanPovSuggestion(boundary.PovOverride) ?? CleanPovSuggestion(chapter.DefaultPov); + var aiDefaultPov = CleanPovSuggestion(chapter.DefaultPov); + var pov = CleanPovSuggestion(boundary.PovOverride) ?? explicitPov ?? aiDefaultPov; scenes.Add(new ManuscriptStructureSceneBoundary { TemporarySceneKey = $"{request.TemporaryChapterKey}-ai-scene-{sceneNumber}", @@ -155,10 +158,24 @@ public sealed class ManuscriptStructureAnalysisService( SuggestedPovName = pov?.CharacterName, SuggestedPovConfidence = pov?.Confidence, SuggestedPovEvidence = pov?.Evidence, - SuggestedPovIsOverride = boundary.PovOverride is not null && !string.IsNullOrWhiteSpace(boundary.PovOverride.CharacterName) + SuggestedPovIsOverride = boundary.PovOverride is not null && !string.IsNullOrWhiteSpace(boundary.PovOverride.CharacterName), + ChapterParsedJson = persistedJson, + ExplicitNarratorName = explicitPov?.CharacterName }); } + var rawPov = CleanPovSuggestion(chapter.DefaultPov); + logger.LogInformation( + "Structural POV audit. ProjectID={ProjectID} BookID={BookID} PreviewID={PreviewID} TemporaryChapterKey={TemporaryChapterKey} ExplicitNarrator={ExplicitNarrator} AiDefaultPov={AiDefaultPov} MixedPov={MixedPov} RawStructuralJson={RawStructuralJson}", + request.ProjectID, + request.BookID, + request.PreviewID, + request.TemporaryChapterKey, + explicitPov?.CharacterName, + rawPov?.CharacterName, + chapter.MixedPov, + parseResult.RawJson); + logger.LogInformation( "Structural scene-boundary analysis completed. ProjectID={ProjectID} BookID={BookID} TemporaryChapterKey={TemporaryChapterKey} Scenes={SceneCount}", request.ProjectID, @@ -166,7 +183,7 @@ public sealed class ManuscriptStructureAnalysisService( request.TemporaryChapterKey, scenes.Count); - var defaultPov = CleanPovSuggestion(chapter.DefaultPov); + var defaultPov = explicitPov ?? CleanPovSuggestion(chapter.DefaultPov); return ManuscriptStructureAnalysisResult.Succeeded( request.TemporaryChapterKey, scenes, @@ -477,6 +494,57 @@ public sealed class ManuscriptStructureAnalysisService( IsExplicit = suggestion?.IsExplicit }; } + + public static ChapterStructurePovSuggestion? DetectExplicitNarrator(string? chapterText) + { + if (string.IsNullOrWhiteSpace(chapterText)) + { + return null; + } + + var paragraphs = StoryIntelligenceParagraphs.Split(chapterText).Take(6); + foreach (var paragraph in paragraphs) + { + var match = ExplicitNarratorRegex().Match(paragraph.Trim()); + if (!match.Success) + { + continue; + } + + var name = CleanPovName(match.Groups["name"].Value); + if (string.IsNullOrWhiteSpace(name)) + { + continue; + } + + return new ChapterStructurePovSuggestion + { + CharacterName = name, + Confidence = 0.99m, + Evidence = match.Value.Trim(), + IsExplicit = true + }; + } + + return null; + } + + [GeneratedRegex(@"^\s*(?:narrated\s+by|narrated\s+by\s*:|narrator\s*:|pov\s*:)\s*(?[A-Z][\p{L}'’.-]*(?:\s+[A-Z][\p{L}'’.-]*){0,3})", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex ExplicitNarratorRegex(); + + private static string? CleanPovName(string? name) + { + var clean = Trim(name, 200); + if (string.IsNullOrWhiteSpace(clean)) + { + return null; + } + + clean = Regex.Replace(clean, @"\s*[–—-]\s*.*$", string.Empty).Trim(); + clean = Regex.Replace(clean, @"\s+\d{1,2}(?:st|nd|rd|th)?\b.*$", string.Empty, RegexOptions.IgnoreCase).Trim(); + clean = clean.Trim(':', '-', '–', '—', '.', ' '); + return string.IsNullOrWhiteSpace(clean) ? null : clean; + } } public sealed class ManuscriptStructureAnalysisRequest @@ -551,4 +619,6 @@ public sealed class ManuscriptStructureSceneBoundary public decimal? SuggestedPovConfidence { get; init; } public string? SuggestedPovEvidence { get; init; } public bool SuggestedPovIsOverride { get; init; } + public string? ChapterParsedJson { get; init; } + public string? ExplicitNarratorName { get; init; } } diff --git a/PlotLine/Services/OnboardingService.cs b/PlotLine/Services/OnboardingService.cs index c4fb8e4..4d409bc 100644 --- a/PlotLine/Services/OnboardingService.cs +++ b/PlotLine/Services/OnboardingService.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.RegularExpressions; using PlotLine.Data; using PlotLine.Models; using PlotLine.ViewModels; @@ -272,7 +273,7 @@ public sealed class OnboardingService( { await PublishBuildProgress(userId, preview.PreviewID, "Completing saved scene source text before updating Word markers...", 65, progress, existingBuild); var recoveredScenes = await AnalyseApprovedChaptersAsync(userId, preview, review, book, progress); - await SaveStructuralChapterSourcesAsync(preview, existingBuild); + await SaveStructuralChapterSourcesAsync(preview, existingBuild, recoveredScenes); await SaveSceneManuscriptSourcesAsync(preview, recoveredScenes, existingBuild); await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID); await ApplyStructuralPovAsync(preview, recoveredScenes, existingBuild); @@ -357,7 +358,7 @@ public sealed class OnboardingService( SceneStructure = BuildSceneStructureSummary(chapterDecisions, structuralScenes, generateSceneTitles), MarkerWarning = result.MarkerWarning }; - await SaveStructuralChapterSourcesAsync(preview, result); + await SaveStructuralChapterSourcesAsync(preview, result, structuralScenes); await SaveSceneManuscriptSourcesAsync(preview, structuralScenes, result); await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID); await ApplyStructuralPovAsync(preview, structuralScenes, result); @@ -482,7 +483,7 @@ public sealed class OnboardingService( if (defaultSuggestion is not null) { var sample = defaultSuggestion.First(); - characterIndex.TryGetValue(defaultSuggestion.Key, out var defaultCharacter); + ResolvePovCharacter(characterIndex, defaultSuggestion.Key, out var defaultCharacter); await chapters.UpdatePovCharacterAsync( chapterMapping.ChapterID, defaultCharacter?.CharacterID, @@ -500,7 +501,7 @@ public sealed class OnboardingService( continue; } - characterIndex.TryGetValue(suggestedName, out var character); + ResolvePovCharacter(characterIndex, suggestedName, out var character); await scenes.UpdatePovCharacterAsync( mappedScene.SceneID, character?.CharacterID, @@ -515,31 +516,138 @@ public sealed class OnboardingService( private async Task> BuildCanonicalCharacterNameIndexAsync(int projectId) { var projectCharacters = await characters.ListCharactersAsync(projectId); - var index = new Dictionary(StringComparer.OrdinalIgnoreCase); + var candidates = new Dictionary>(StringComparer.OrdinalIgnoreCase); foreach (var character in projectCharacters) { - AddCharacterName(index, character.CharacterName, character); - AddCharacterName(index, character.ShortName, character); - var firstName = character.CharacterName.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(); - AddCharacterName(index, firstName, character); + AddCharacterName(candidates, character.CharacterName, character); + AddCharacterName(candidates, character.ShortName, character); + AddCharacterNameParts(candidates, character.CharacterName, character); foreach (var alias in await characters.ListAliasesAsync(character.CharacterID)) { - AddCharacterName(index, alias.Alias, character); - AddCharacterName(index, alias.Alias.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(), character); + AddCharacterName(candidates, alias.Alias, character); + AddCharacterNameParts(candidates, alias.Alias, character); } } - return index; + return candidates + .Where(item => item.Value.Count == 1) + .ToDictionary(item => item.Key, item => item.Value.Values.Single(), StringComparer.OrdinalIgnoreCase); } - private static void AddCharacterName(IDictionary index, string? name, Character character) + private static void AddCharacterName(IDictionary> index, string? name, Character character) + { + var key = NormalisePovName(name); + if (string.IsNullOrWhiteSpace(key)) + { + return; + } + + if (!index.TryGetValue(key, out var matches)) + { + matches = new Dictionary(); + index.Add(key, matches); + } + + matches.TryAdd(character.CharacterID, character); + } + + private static void AddCharacterNameParts(IDictionary> index, string? name, Character character) { var clean = Clean(name); - if (!string.IsNullOrWhiteSpace(clean) && !index.ContainsKey(clean)) + if (string.IsNullOrWhiteSpace(clean)) { - index.Add(clean, character); + return; } + + var titleStripped = Regex.Replace(clean, @"^(mr|mrs|miss|ms|dr|doctor|aunt|uncle)\.?\s+", string.Empty, RegexOptions.IgnoreCase).Trim(); + if (!string.Equals(titleStripped, clean, StringComparison.OrdinalIgnoreCase)) + { + AddCharacterName(index, titleStripped, character); + } + + var tokens = titleStripped.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (tokens.Length > 0) + { + AddCharacterName(index, tokens[0], character); + AddCharacterName(index, tokens[^1], character); + } + } + + private static string NormalisePovName(string? name) + { + var clean = Clean(name); + if (string.IsNullOrWhiteSpace(clean)) + { + return string.Empty; + } + + clean = Regex.Replace(clean, @"^(mr|mrs|miss|ms|dr|doctor|aunt|uncle)\.?\s+", string.Empty, RegexOptions.IgnoreCase).Trim(); + clean = Regex.Replace(clean, @"[^\p{L}\p{N}]+", " ").Trim(); + return clean.ToLowerInvariant(); + } + + private async Task ResolvePersistedStructuralPovAsync(int projectId, int bookId) + { + var index = await BuildCanonicalCharacterNameIndexAsync(projectId); + var resolved = 0; + foreach (var chapter in await chapters.ListByBookAsync(bookId)) + { + var chapterScenes = await scenes.ListByChapterAsync(chapter.ChapterID); + if (!string.Equals(chapter.POVSource, "Author", StringComparison.OrdinalIgnoreCase) + && ResolvePovCharacter(index, chapter.POVSuggestedName ?? chapter.POVCharacterName, out var chapterCharacter)) + { + await chapters.UpdatePovCharacterAsync(chapter.ChapterID, chapterCharacter.CharacterID, "Structural", chapter.POVConfidence, chapter.POVEvidence, null); + resolved++; + } + + foreach (var scene in chapterScenes) + { + if (string.Equals(scene.POVSource, "Author", StringComparison.OrdinalIgnoreCase) + || !ResolvePovCharacter(index, scene.POVSuggestedName ?? scene.POVCharacterName, out var sceneCharacter)) + { + continue; + } + + await scenes.UpdatePovCharacterAsync( + scene.SceneID, + sceneCharacter.CharacterID, + string.IsNullOrWhiteSpace(scene.POVSource) ? "StructuralResolved" : scene.POVSource, + scene.POVConfidence, + scene.POVEvidence, + null); + resolved++; + } + + var resolvedScenePovs = chapterScenes + .Where(scene => !string.Equals(scene.POVSource, "Author", StringComparison.OrdinalIgnoreCase)) + .Select(scene => ResolvePovCharacter(index, scene.POVSuggestedName ?? scene.POVCharacterName, out var character) ? character.CharacterID : scene.POVCharacterID) + .Where(id => id.HasValue) + .Select(id => id!.Value) + .Distinct() + .ToList(); + if (!string.Equals(chapter.POVSource, "Author", StringComparison.OrdinalIgnoreCase) + && !chapter.POVCharacterID.HasValue + && resolvedScenePovs.Count == 1) + { + await chapters.UpdatePovCharacterAsync(chapter.ChapterID, resolvedScenePovs[0], "StructuralDerived"); + resolved++; + } + } + + return resolved; + } + + private static bool ResolvePovCharacter(IReadOnlyDictionary index, string? name, out Character character) + { + var key = NormalisePovName(name); + if (!string.IsNullOrWhiteSpace(key) && index.TryGetValue(key, out character!)) + { + return true; + } + + character = null!; + return false; } private async Task BuildExistingStructureResultAsync( @@ -703,6 +811,7 @@ public sealed class OnboardingService( await scanPreviews.SaveBuildResultAsync(userId, completedResult); await AddScanCharacterAliasesAsync(completedReview, completedResult); await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID); + await ResolvePersistedStructuralPovAsync(preview.ProjectID, preview.BookID); return completedResult; } @@ -847,9 +956,16 @@ public sealed class OnboardingService( .ToList(); } - private async Task SaveStructuralChapterSourcesAsync(ManuscriptScanPreview preview, OnboardingManuscriptBuildResult result) + private async Task SaveStructuralChapterSourcesAsync( + ManuscriptScanPreview preview, + OnboardingManuscriptBuildResult result, + IReadOnlyList? structuralScenes = null) { var chapterIds = result.ChapterMappings.ToDictionary(item => item.TemporaryChapterKey, item => item.ChapterID, StringComparer.Ordinal); + var chapterPov = structuralScenes? + .GroupBy(scene => scene.TemporaryChapterKey) + .ToDictionary(group => group.Key, group => group.FirstOrDefault(scene => !string.IsNullOrWhiteSpace(scene.SuggestedPovName)), StringComparer.Ordinal) + ?? new Dictionary(StringComparer.Ordinal); foreach (var chapter in preview.Chapters) { if (!chapterIds.TryGetValue(chapter.TemporaryChapterKey, out var chapterId) @@ -866,7 +982,10 @@ public sealed class OnboardingService( SourceText = chapter.ChapterText, SourceWordCount = CountWords(chapter.ChapterText), SourceCharacterCount = chapter.ChapterText.Length, - ParagraphMapJson = JsonSerializer.Serialize(chapter.ParagraphMap) + ParagraphMapJson = JsonSerializer.Serialize(chapter.ParagraphMap), + StructuralParsedJson = chapterPov.GetValueOrDefault(chapter.TemporaryChapterKey)?.ChapterParsedJson, + DefaultPovName = chapterPov.GetValueOrDefault(chapter.TemporaryChapterKey)?.SuggestedPovName, + ExplicitNarratorName = chapterPov.GetValueOrDefault(chapter.TemporaryChapterKey)?.ExplicitNarratorName }); } } diff --git a/PlotLine/Sql/166_Phase26B_StructuralPovAudit.sql b/PlotLine/Sql/166_Phase26B_StructuralPovAudit.sql new file mode 100644 index 0000000..08f144c --- /dev/null +++ b/PlotLine/Sql/166_Phase26B_StructuralPovAudit.sql @@ -0,0 +1,51 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +IF COL_LENGTH(N'dbo.ManuscriptStructuralChapterSources', N'StructuralParsedJson') IS NULL + ALTER TABLE dbo.ManuscriptStructuralChapterSources ADD StructuralParsedJson nvarchar(max) NULL; +GO + +IF COL_LENGTH(N'dbo.ManuscriptStructuralChapterSources', N'DefaultPovName') IS NULL + ALTER TABLE dbo.ManuscriptStructuralChapterSources ADD DefaultPovName nvarchar(200) NULL; +GO + +IF COL_LENGTH(N'dbo.ManuscriptStructuralChapterSources', N'ExplicitNarratorName') IS NULL + ALTER TABLE dbo.ManuscriptStructuralChapterSources ADD ExplicitNarratorName nvarchar(200) NULL; +GO + +CREATE OR ALTER PROCEDURE dbo.ManuscriptStructuralChapterSource_Save + @ChapterID int, + @ProjectID int, + @BookID int, + @SourceText nvarchar(max), + @SourceWordCount int, + @SourceCharacterCount int, + @ParagraphMapJson nvarchar(max) = NULL, + @StructuralParsedJson nvarchar(max) = NULL, + @DefaultPovName nvarchar(200) = NULL, + @ExplicitNarratorName nvarchar(200) = NULL +AS +BEGIN + SET NOCOUNT ON; + + MERGE dbo.ManuscriptStructuralChapterSources AS target + USING (SELECT @ChapterID AS ChapterID) AS source + ON target.ChapterID = source.ChapterID + WHEN MATCHED THEN + UPDATE SET ProjectID = @ProjectID, + BookID = @BookID, + SourceText = @SourceText, + SourceWordCount = @SourceWordCount, + SourceCharacterCount = @SourceCharacterCount, + ParagraphMapJson = @ParagraphMapJson, + StructuralParsedJson = COALESCE(@StructuralParsedJson, StructuralParsedJson), + DefaultPovName = COALESCE(NULLIF(LTRIM(RTRIM(@DefaultPovName)), N''), DefaultPovName), + ExplicitNarratorName = COALESCE(NULLIF(LTRIM(RTRIM(@ExplicitNarratorName)), N''), ExplicitNarratorName), + UpdatedUtc = SYSUTCDATETIME() + WHEN NOT MATCHED THEN + INSERT (ChapterID, ProjectID, BookID, SourceText, SourceWordCount, SourceCharacterCount, ParagraphMapJson, StructuralParsedJson, DefaultPovName, ExplicitNarratorName) + VALUES (@ChapterID, @ProjectID, @BookID, @SourceText, @SourceWordCount, @SourceCharacterCount, @ParagraphMapJson, @StructuralParsedJson, NULLIF(LTRIM(RTRIM(@DefaultPovName)), N''), NULLIF(LTRIM(RTRIM(@ExplicitNarratorName)), N'')); +END; +GO diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index 91f37c3..9b2502a 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -301,6 +301,7 @@ public sealed class ChapterDetailViewModel public int PendingAssetSuggestionCount { get; set; } public int PendingLocationSuggestionCount { get; set; } public IReadOnlyList CharacterOptions { get; set; } = []; + public string PovDisplayLabel { get; set; } = "Not detected"; } public sealed class SceneEditViewModel diff --git a/PlotLine/Views/Chapters/Details.cshtml b/PlotLine/Views/Chapters/Details.cshtml index 5730dca..d264e88 100644 --- a/PlotLine/Views/Chapters/Details.cshtml +++ b/PlotLine/Views/Chapters/Details.cshtml @@ -34,12 +34,7 @@ { @Model.Chapter.ChapterPurposeName } - @{ - var chapterPov = Model.CharacterOptions.FirstOrDefault(option => option.Value == Model.Chapter.POVCharacterID?.ToString())?.Text - ?? Model.Chapter.POVSuggestedName - ?? "Not detected"; - } - POV: @chapterPov + POV: @Model.PovDisplayLabel @if (!string.IsNullOrWhiteSpace(Model.Chapter.Summary)) {

@Model.Chapter.Summary