Fix structural POV resolution lifecycle
This commit is contained in:
parent
caa70be9f4
commit
58075232e7
@ -140,6 +140,9 @@ var tests = new (string Name, Action Test)[]
|
|||||||
("Story Intelligence character default changes preserve persisted results", StoryIntelligenceCharacterDefaultChangesPreservePersistedResults),
|
("Story Intelligence character default changes preserve persisted results", StoryIntelligenceCharacterDefaultChangesPreservePersistedResults),
|
||||||
("Structural POV rides existing chapter analysis", StructuralPovRidesExistingChapterAnalysis),
|
("Structural POV rides existing chapter analysis", StructuralPovRidesExistingChapterAnalysis),
|
||||||
("Structural POV persists through import resolution", StructuralPovPersistsThroughImportResolution),
|
("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),
|
("Normal scene and chapter saves persist POV", NormalSceneAndChapterSavesPersistPov),
|
||||||
("Character merge is a reusable stored procedure workflow", CharacterMergeIsReusableStoredProcedureWorkflow),
|
("Character merge is a reusable stored procedure workflow", CharacterMergeIsReusableStoredProcedureWorkflow),
|
||||||
("Story Intelligence character review suppresses resolved narrator POV", StoryIntelligenceCharacterReviewSuppressesResolvedNarratorPov),
|
("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.");
|
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()
|
static void NormalSceneAndChapterSavesPersistPov()
|
||||||
{
|
{
|
||||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
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(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(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("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.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.");
|
Assert(migration.Contains("CREATE OR ALTER PROCEDURE dbo.Chapter_Save", StringComparison.Ordinal), "New migration should own the Chapter_Save parameter update.");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -137,7 +137,10 @@ public sealed class OnboardingBuildRepository(ISqlConnectionFactory connectionFa
|
|||||||
request.SourceText,
|
request.SourceText,
|
||||||
request.SourceWordCount,
|
request.SourceWordCount,
|
||||||
request.SourceCharacterCount,
|
request.SourceCharacterCount,
|
||||||
request.ParagraphMapJson
|
request.ParagraphMapJson,
|
||||||
|
request.StructuralParsedJson,
|
||||||
|
request.DefaultPovName,
|
||||||
|
request.ExplicitNarratorName
|
||||||
},
|
},
|
||||||
commandType: CommandType.StoredProcedure);
|
commandType: CommandType.StoredProcedure);
|
||||||
}
|
}
|
||||||
@ -240,6 +243,9 @@ public sealed class StructuralChapterSourceSaveRequest
|
|||||||
public int SourceWordCount { get; init; }
|
public int SourceWordCount { get; init; }
|
||||||
public int SourceCharacterCount { get; init; }
|
public int SourceCharacterCount { get; init; }
|
||||||
public string? ParagraphMapJson { 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
|
public sealed class SceneManuscriptSourceSaveRequest
|
||||||
|
|||||||
@ -1433,7 +1433,8 @@ public sealed class ChapterService(
|
|||||||
PendingCharacterSuggestionCount = sceneRows.Sum(x => x.PendingCharacterSuggestionCount),
|
PendingCharacterSuggestionCount = sceneRows.Sum(x => x.PendingCharacterSuggestionCount),
|
||||||
PendingAssetSuggestionCount = sceneRows.Sum(x => x.PendingAssetSuggestionCount),
|
PendingAssetSuggestionCount = sceneRows.Sum(x => x.PendingAssetSuggestionCount),
|
||||||
PendingLocationSuggestionCount = sceneRows.Sum(x => x.PendingLocationSuggestionCount),
|
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)
|
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();
|
=> 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(
|
public sealed class SceneService(
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using PlotLine.Models;
|
using PlotLine.Models;
|
||||||
using PlotLine.Models.StoryIntelligence;
|
using PlotLine.Models.StoryIntelligence;
|
||||||
|
|
||||||
@ -9,7 +10,7 @@ public interface IManuscriptStructureAnalysisService
|
|||||||
Task<ManuscriptStructureAnalysisResult> AnalyseChapterAsync(ManuscriptStructureAnalysisRequest request, CancellationToken cancellationToken = default);
|
Task<ManuscriptStructureAnalysisResult> AnalyseChapterAsync(ManuscriptStructureAnalysisRequest request, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ManuscriptStructureAnalysisService(
|
public sealed partial class ManuscriptStructureAnalysisService(
|
||||||
IStoryPromptRepository prompts,
|
IStoryPromptRepository prompts,
|
||||||
IStoryPromptVersionService versions,
|
IStoryPromptVersionService versions,
|
||||||
IStoryIntelligenceClient client,
|
IStoryIntelligenceClient client,
|
||||||
@ -87,6 +88,7 @@ public sealed class ManuscriptStructureAnalysisService(
|
|||||||
var parsed = parseResult.Parsed;
|
var parsed = parseResult.Parsed;
|
||||||
var normalisation = ChapterStructureBoundaryNormaliser.Normalise(parsed, paragraphs.Count);
|
var normalisation = ChapterStructureBoundaryNormaliser.Normalise(parsed, paragraphs.Count);
|
||||||
var chapter = normalisation.ChapterStructure;
|
var chapter = normalisation.ChapterStructure;
|
||||||
|
var explicitPov = DetectExplicitNarrator(text);
|
||||||
var validation = validator.Validate(chapter, paragraphs.Count);
|
var validation = validator.Validate(chapter, paragraphs.Count);
|
||||||
ChapterStructureBoundaryNormaliser.AddIssuesTo(validation, normalisation);
|
ChapterStructureBoundaryNormaliser.AddIssuesTo(validation, normalisation);
|
||||||
var persistedJson = normalisation.Issues.Count > 0
|
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 sceneParagraphs = paragraphs.Skip(start - 1).Take(end - start + 1).ToList();
|
||||||
var sourceText = BuildSourceText(request.SourceParagraphs, startMap.WordParagraphIndex, endMap.WordParagraphIndex);
|
var sourceText = BuildSourceText(request.SourceParagraphs, startMap.WordParagraphIndex, endMap.WordParagraphIndex);
|
||||||
var suggestedTitle = CleanSuggestedTitle(boundary.SuggestedTitle, sceneNumber);
|
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
|
scenes.Add(new ManuscriptStructureSceneBoundary
|
||||||
{
|
{
|
||||||
TemporarySceneKey = $"{request.TemporaryChapterKey}-ai-scene-{sceneNumber}",
|
TemporarySceneKey = $"{request.TemporaryChapterKey}-ai-scene-{sceneNumber}",
|
||||||
@ -155,10 +158,24 @@ public sealed class ManuscriptStructureAnalysisService(
|
|||||||
SuggestedPovName = pov?.CharacterName,
|
SuggestedPovName = pov?.CharacterName,
|
||||||
SuggestedPovConfidence = pov?.Confidence,
|
SuggestedPovConfidence = pov?.Confidence,
|
||||||
SuggestedPovEvidence = pov?.Evidence,
|
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(
|
logger.LogInformation(
|
||||||
"Structural scene-boundary analysis completed. ProjectID={ProjectID} BookID={BookID} TemporaryChapterKey={TemporaryChapterKey} Scenes={SceneCount}",
|
"Structural scene-boundary analysis completed. ProjectID={ProjectID} BookID={BookID} TemporaryChapterKey={TemporaryChapterKey} Scenes={SceneCount}",
|
||||||
request.ProjectID,
|
request.ProjectID,
|
||||||
@ -166,7 +183,7 @@ public sealed class ManuscriptStructureAnalysisService(
|
|||||||
request.TemporaryChapterKey,
|
request.TemporaryChapterKey,
|
||||||
scenes.Count);
|
scenes.Count);
|
||||||
|
|
||||||
var defaultPov = CleanPovSuggestion(chapter.DefaultPov);
|
var defaultPov = explicitPov ?? CleanPovSuggestion(chapter.DefaultPov);
|
||||||
return ManuscriptStructureAnalysisResult.Succeeded(
|
return ManuscriptStructureAnalysisResult.Succeeded(
|
||||||
request.TemporaryChapterKey,
|
request.TemporaryChapterKey,
|
||||||
scenes,
|
scenes,
|
||||||
@ -477,6 +494,57 @@ public sealed class ManuscriptStructureAnalysisService(
|
|||||||
IsExplicit = suggestion?.IsExplicit
|
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
|
public sealed class ManuscriptStructureAnalysisRequest
|
||||||
@ -551,4 +619,6 @@ public sealed class ManuscriptStructureSceneBoundary
|
|||||||
public decimal? SuggestedPovConfidence { get; init; }
|
public decimal? SuggestedPovConfidence { get; init; }
|
||||||
public string? SuggestedPovEvidence { get; init; }
|
public string? SuggestedPovEvidence { get; init; }
|
||||||
public bool SuggestedPovIsOverride { get; init; }
|
public bool SuggestedPovIsOverride { get; init; }
|
||||||
|
public string? ChapterParsedJson { get; init; }
|
||||||
|
public string? ExplicitNarratorName { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using PlotLine.Data;
|
using PlotLine.Data;
|
||||||
using PlotLine.Models;
|
using PlotLine.Models;
|
||||||
using PlotLine.ViewModels;
|
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);
|
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);
|
var recoveredScenes = await AnalyseApprovedChaptersAsync(userId, preview, review, book, progress);
|
||||||
await SaveStructuralChapterSourcesAsync(preview, existingBuild);
|
await SaveStructuralChapterSourcesAsync(preview, existingBuild, recoveredScenes);
|
||||||
await SaveSceneManuscriptSourcesAsync(preview, recoveredScenes, existingBuild);
|
await SaveSceneManuscriptSourcesAsync(preview, recoveredScenes, existingBuild);
|
||||||
await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID);
|
await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID);
|
||||||
await ApplyStructuralPovAsync(preview, recoveredScenes, existingBuild);
|
await ApplyStructuralPovAsync(preview, recoveredScenes, existingBuild);
|
||||||
@ -357,7 +358,7 @@ public sealed class OnboardingService(
|
|||||||
SceneStructure = BuildSceneStructureSummary(chapterDecisions, structuralScenes, generateSceneTitles),
|
SceneStructure = BuildSceneStructureSummary(chapterDecisions, structuralScenes, generateSceneTitles),
|
||||||
MarkerWarning = result.MarkerWarning
|
MarkerWarning = result.MarkerWarning
|
||||||
};
|
};
|
||||||
await SaveStructuralChapterSourcesAsync(preview, result);
|
await SaveStructuralChapterSourcesAsync(preview, result, structuralScenes);
|
||||||
await SaveSceneManuscriptSourcesAsync(preview, structuralScenes, result);
|
await SaveSceneManuscriptSourcesAsync(preview, structuralScenes, result);
|
||||||
await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID);
|
await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID);
|
||||||
await ApplyStructuralPovAsync(preview, structuralScenes, result);
|
await ApplyStructuralPovAsync(preview, structuralScenes, result);
|
||||||
@ -482,7 +483,7 @@ public sealed class OnboardingService(
|
|||||||
if (defaultSuggestion is not null)
|
if (defaultSuggestion is not null)
|
||||||
{
|
{
|
||||||
var sample = defaultSuggestion.First();
|
var sample = defaultSuggestion.First();
|
||||||
characterIndex.TryGetValue(defaultSuggestion.Key, out var defaultCharacter);
|
ResolvePovCharacter(characterIndex, defaultSuggestion.Key, out var defaultCharacter);
|
||||||
await chapters.UpdatePovCharacterAsync(
|
await chapters.UpdatePovCharacterAsync(
|
||||||
chapterMapping.ChapterID,
|
chapterMapping.ChapterID,
|
||||||
defaultCharacter?.CharacterID,
|
defaultCharacter?.CharacterID,
|
||||||
@ -500,7 +501,7 @@ public sealed class OnboardingService(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
characterIndex.TryGetValue(suggestedName, out var character);
|
ResolvePovCharacter(characterIndex, suggestedName, out var character);
|
||||||
await scenes.UpdatePovCharacterAsync(
|
await scenes.UpdatePovCharacterAsync(
|
||||||
mappedScene.SceneID,
|
mappedScene.SceneID,
|
||||||
character?.CharacterID,
|
character?.CharacterID,
|
||||||
@ -515,31 +516,138 @@ public sealed class OnboardingService(
|
|||||||
private async Task<Dictionary<string, Character>> BuildCanonicalCharacterNameIndexAsync(int projectId)
|
private async Task<Dictionary<string, Character>> BuildCanonicalCharacterNameIndexAsync(int projectId)
|
||||||
{
|
{
|
||||||
var projectCharacters = await characters.ListCharactersAsync(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)
|
foreach (var character in projectCharacters)
|
||||||
{
|
{
|
||||||
AddCharacterName(index, character.CharacterName, character);
|
AddCharacterName(candidates, character.CharacterName, character);
|
||||||
AddCharacterName(index, character.ShortName, character);
|
AddCharacterName(candidates, character.ShortName, character);
|
||||||
var firstName = character.CharacterName.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
|
AddCharacterNameParts(candidates, character.CharacterName, character);
|
||||||
AddCharacterName(index, firstName, character);
|
|
||||||
|
|
||||||
foreach (var alias in await characters.ListAliasesAsync(character.CharacterID))
|
foreach (var alias in await characters.ListAliasesAsync(character.CharacterID))
|
||||||
{
|
{
|
||||||
AddCharacterName(index, alias.Alias, character);
|
AddCharacterName(candidates, alias.Alias, character);
|
||||||
AddCharacterName(index, alias.Alias.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(), 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);
|
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(
|
private async Task<OnboardingManuscriptBuildResult> BuildExistingStructureResultAsync(
|
||||||
@ -703,6 +811,7 @@ public sealed class OnboardingService(
|
|||||||
await scanPreviews.SaveBuildResultAsync(userId, completedResult);
|
await scanPreviews.SaveBuildResultAsync(userId, completedResult);
|
||||||
await AddScanCharacterAliasesAsync(completedReview, completedResult);
|
await AddScanCharacterAliasesAsync(completedReview, completedResult);
|
||||||
await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID);
|
await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID);
|
||||||
|
await ResolvePersistedStructuralPovAsync(preview.ProjectID, preview.BookID);
|
||||||
return completedResult;
|
return completedResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -847,9 +956,16 @@ public sealed class OnboardingService(
|
|||||||
.ToList();
|
.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 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)
|
foreach (var chapter in preview.Chapters)
|
||||||
{
|
{
|
||||||
if (!chapterIds.TryGetValue(chapter.TemporaryChapterKey, out var chapterId)
|
if (!chapterIds.TryGetValue(chapter.TemporaryChapterKey, out var chapterId)
|
||||||
@ -866,7 +982,10 @@ public sealed class OnboardingService(
|
|||||||
SourceText = chapter.ChapterText,
|
SourceText = chapter.ChapterText,
|
||||||
SourceWordCount = CountWords(chapter.ChapterText),
|
SourceWordCount = CountWords(chapter.ChapterText),
|
||||||
SourceCharacterCount = chapter.ChapterText.Length,
|
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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
51
PlotLine/Sql/166_Phase26B_StructuralPovAudit.sql
Normal file
51
PlotLine/Sql/166_Phase26B_StructuralPovAudit.sql
Normal 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
|
||||||
@ -301,6 +301,7 @@ public sealed class ChapterDetailViewModel
|
|||||||
public int PendingAssetSuggestionCount { get; set; }
|
public int PendingAssetSuggestionCount { get; set; }
|
||||||
public int PendingLocationSuggestionCount { get; set; }
|
public int PendingLocationSuggestionCount { get; set; }
|
||||||
public IReadOnlyList<SelectListItem> CharacterOptions { get; set; } = [];
|
public IReadOnlyList<SelectListItem> CharacterOptions { get; set; } = [];
|
||||||
|
public string PovDisplayLabel { get; set; } = "Not detected";
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class SceneEditViewModel
|
public sealed class SceneEditViewModel
|
||||||
|
|||||||
@ -34,12 +34,7 @@
|
|||||||
{
|
{
|
||||||
<span class="status-pill">@Model.Chapter.ChapterPurposeName</span>
|
<span class="status-pill">@Model.Chapter.ChapterPurposeName</span>
|
||||||
}
|
}
|
||||||
@{
|
<span class="status-pill">POV: @Model.PovDisplayLabel</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>
|
|
||||||
@if (!string.IsNullOrWhiteSpace(Model.Chapter.Summary))
|
@if (!string.IsNullOrWhiteSpace(Model.Chapter.Summary))
|
||||||
{
|
{
|
||||||
<p class="lead-text">@Model.Chapter.Summary</p>
|
<p class="lead-text">@Model.Chapter.Summary</p>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user