Fix structural POV resolution lifecycle

This commit is contained in:
Nick Beckley 2026-08-28 12:41:44 +00:00
parent caa70be9f4
commit 58075232e7
8 changed files with 376 additions and 30 deletions

View File

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

View File

@ -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

View File

@ -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<SelectListItem> ToOptionalSelectList<T>(IEnumerable<T> rows, Func<T, int> value, Func<T, string> 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<Scene> 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(

View File

@ -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<ManuscriptStructureAnalysisResult> 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*(?<name>[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; }
}

View File

@ -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<Dictionary<string, Character>> BuildCanonicalCharacterNameIndexAsync(int projectId)
{
var projectCharacters = await characters.ListCharactersAsync(projectId);
var index = new Dictionary<string, Character>(StringComparer.OrdinalIgnoreCase);
var candidates = new Dictionary<string, Dictionary<int, Character>>(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<string, Character> index, string? name, Character character)
private static void AddCharacterName(IDictionary<string, Dictionary<int, Character>> index, string? name, Character character)
{
var key = NormalisePovName(name);
if (string.IsNullOrWhiteSpace(key))
{
return;
}
if (!index.TryGetValue(key, out var matches))
{
matches = new Dictionary<int, Character>();
index.Add(key, matches);
}
matches.TryAdd(character.CharacterID, character);
}
private static void AddCharacterNameParts(IDictionary<string, Dictionary<int, Character>> 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<int> 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<string, Character> 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<OnboardingManuscriptBuildResult> 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<ManuscriptStructureSceneBoundary>? 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<string, ManuscriptStructureSceneBoundary?>(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
});
}
}

View File

@ -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

View File

@ -301,6 +301,7 @@ public sealed class ChapterDetailViewModel
public int PendingAssetSuggestionCount { get; set; }
public int PendingLocationSuggestionCount { get; set; }
public IReadOnlyList<SelectListItem> CharacterOptions { get; set; } = [];
public string PovDisplayLabel { get; set; } = "Not detected";
}
public sealed class SceneEditViewModel

View File

@ -34,12 +34,7 @@
{
<span class="status-pill">@Model.Chapter.ChapterPurposeName</span>
}
@{
var chapterPov = Model.CharacterOptions.FirstOrDefault(option => option.Value == Model.Chapter.POVCharacterID?.ToString())?.Text
?? Model.Chapter.POVSuggestedName
?? "Not detected";
}
<span class="status-pill">POV: @chapterPov</span>
<span class="status-pill">POV: @Model.PovDisplayLabel</span>
@if (!string.IsNullOrWhiteSpace(Model.Chapter.Summary))
{
<p class="lead-text">@Model.Chapter.Summary</p>