using System.Text.Json; using PlotLine.Models; using PlotLine.Models.StoryIntelligence; namespace PlotLine.Services; public static class ChapterStructureBoundaryNormaliser { public static ChapterStructureBoundaryNormalisationResult Normalise(ChapterStructureModel? chapterStructure, int paragraphCount) { if (chapterStructure?.SceneBoundaries is null || chapterStructure.SceneBoundaries.Count < 2 || paragraphCount <= 0) { return new ChapterStructureBoundaryNormalisationResult(chapterStructure, []); } var issues = new List(); var boundaries = chapterStructure.SceneBoundaries; var finalIndex = boundaries.Count - 1; var final = boundaries[finalIndex]; var previous = boundaries[finalIndex - 1]; var phantomParagraph = paragraphCount + 1; if (IsTrailingEmptyBoundary(final, previous, paragraphCount, phantomParagraph) || IsTrailingInvalidPlaceholder(final, previous, paragraphCount, phantomParagraph)) { boundaries = boundaries .Take(finalIndex) .Select((boundary, index) => CopyBoundary(boundary, index + 1)) .ToList(); issues.Add(new ValidationIssue { Severity = "Warning", Path = $"sceneBoundaries[{finalIndex}]", Message = BuildNormalisationMessage(final, finalIndex, paragraphCount, phantomParagraph), SuggestedFix = "Do not return a final scene boundary unless it references an actual supplied paragraph." }); } else if (IsTrailingTerminalOffByOne(final, previous, paragraphCount, phantomParagraph)) { boundaries = boundaries .Take(finalIndex) .Select((boundary, index) => CopyBoundary(boundary, index + 1)) .ToList(); issues.Add(new ValidationIssue { Severity = "Warning", Path = $"sceneBoundaries[{finalIndex}]", Message = $"Discarded trailing terminal off-by-one scene boundary {phantomParagraph}-{paragraphCount} because previous scene already ended at the final structural paragraph {paragraphCount}.", SuggestedFix = $"Do not return a boundary starting after the final supplied paragraph {paragraphCount}." }); } var boundaryModel = ReferenceEquals(boundaries, chapterStructure.SceneBoundaries) ? chapterStructure : new ChapterStructureModel { SchemaVersion = chapterStructure.SchemaVersion, ChapterSummary = chapterStructure.ChapterSummary, SceneBoundaries = boundaries.ToList(), ExtensionData = chapterStructure.ExtensionData }; var continuity = RepairContinuity(boundaryModel, boundaries, paragraphCount); issues.AddRange(continuity.Issues); return new ChapterStructureBoundaryNormalisationResult(continuity.ChapterStructure, issues); } public static void AddIssuesTo(ValidationResult validation, ChapterStructureBoundaryNormalisationResult normalisation) { foreach (var issue in normalisation.Issues) { validation.Warnings.Add(issue); } } public static string SerialiseParsedModel(ChapterStructureModel? chapterStructure, ChapterStructureBoundaryNormalisationResult normalisation, JsonSerializerOptions jsonOptions) { if (chapterStructure is null) { return string.Empty; } if (normalisation.Issues.Count == 0) { return JsonSerializer.Serialize(chapterStructure, jsonOptions); } return JsonSerializer.Serialize( new { chapterStructure.SchemaVersion, chapterStructure.ChapterSummary, chapterStructure.DefaultPov, chapterStructure.MixedPov, chapterStructure.SceneBoundaries, Normalisation = normalisation.Issues.Select(issue => new { issue.Severity, issue.Path, issue.Message, issue.SuggestedFix }) }, jsonOptions); } private static ChapterSceneBoundary CopyBoundary(ChapterSceneBoundary boundary, int sceneNumber) => new() { SceneNumber = sceneNumber, StartParagraph = boundary.StartParagraph, EndParagraph = boundary.EndParagraph, SuggestedTitle = boundary.SuggestedTitle, StructuralSummary = boundary.StructuralSummary, StructuralSetting = boundary.StructuralSetting, PovOverride = boundary.PovOverride, Confidence = boundary.Confidence, Reason = boundary.Reason, ExtensionData = boundary.ExtensionData }; private static ChapterStructureBoundaryNormalisationResult RepairContinuity( ChapterStructureModel chapterStructure, IReadOnlyList sourceBoundaries, int paragraphCount) { if (sourceBoundaries.Count == 0) { return new ChapterStructureBoundaryNormalisationResult(chapterStructure, []); } var issues = new List(); var ordered = sourceBoundaries .Select((boundary, index) => new { Boundary = boundary, OriginalIndex = index }) .OrderBy(item => item.Boundary.StartParagraph ?? int.MaxValue) .ThenBy(item => item.Boundary.EndParagraph ?? int.MaxValue) .ToList(); if (ordered.Where((item, index) => item.OriginalIndex != index).Any()) { issues.Add(new ValidationIssue { Severity = "Warning", Path = "sceneBoundaries", Message = "Reordered scene boundaries by paragraph position before validation.", SuggestedFix = "Return scene boundaries in reading order." }); } var changed = issues.Count > 0; var repaired = new List(); var expectedStart = 1; for (var index = 0; index < ordered.Count; index++) { var boundary = ordered[index].Boundary; if (!boundary.StartParagraph.HasValue || !boundary.EndParagraph.HasValue) { repaired.Add(CopyBoundary(boundary, index + 1)); continue; } var start = boundary.StartParagraph.Value; var end = boundary.EndParagraph.Value; if (start != expectedStart) { issues.Add(new ValidationIssue { Severity = "Warning", Path = $"sceneBoundaries[{index}].startParagraph", Message = $"Adjusted scene {index + 1:N0} start paragraph from {start:N0} to {expectedStart:N0} to remove an AI boundary gap or overlap.", SuggestedFix = $"Use startParagraph {expectedStart:N0} for this boundary." }); start = expectedStart; changed = true; } if (end < start) { issues.Add(new ValidationIssue { Severity = "Warning", Path = $"sceneBoundaries[{index}].endParagraph", Message = $"Scene {index + 1:N0} has start paragraph {start:N0} after end paragraph {end:N0}; this cannot be safely normalised.", SuggestedFix = "Return an endParagraph greater than or equal to startParagraph." }); } if (end > paragraphCount) { issues.Add(new ValidationIssue { Severity = "Warning", Path = $"sceneBoundaries[{index}].endParagraph", Message = $"Clamped scene {index + 1:N0} end paragraph from {end:N0} to the chapter paragraph count {paragraphCount:N0}.", SuggestedFix = $"Use an endParagraph from 1 to {paragraphCount:N0}." }); end = paragraphCount; changed = true; } repaired.Add(new ChapterSceneBoundary { SceneNumber = index + 1, StartParagraph = start, EndParagraph = end, SuggestedTitle = boundary.SuggestedTitle, StructuralSummary = boundary.StructuralSummary, StructuralSetting = boundary.StructuralSetting, PovOverride = boundary.PovOverride, Confidence = boundary.Confidence, Reason = boundary.Reason, ExtensionData = boundary.ExtensionData }); expectedStart = end + 1; } if (expectedStart <= paragraphCount && repaired.Count > 0) { var final = repaired[^1]; issues.Add(new ValidationIssue { Severity = "Warning", Path = $"sceneBoundaries[{repaired.Count - 1}].endParagraph", Message = $"Extended the final scene boundary to paragraph {paragraphCount:N0} so detected scenes cover the full chapter.", SuggestedFix = $"End the final scene at paragraph {paragraphCount:N0}." }); repaired[^1] = new ChapterSceneBoundary { SceneNumber = final.SceneNumber, StartParagraph = final.StartParagraph, EndParagraph = paragraphCount, SuggestedTitle = final.SuggestedTitle, StructuralSummary = final.StructuralSummary, StructuralSetting = final.StructuralSetting, PovOverride = final.PovOverride, Confidence = final.Confidence, Reason = final.Reason, ExtensionData = final.ExtensionData }; changed = true; } if (!changed) { return new ChapterStructureBoundaryNormalisationResult(chapterStructure, []); } return new ChapterStructureBoundaryNormalisationResult(new ChapterStructureModel { SchemaVersion = chapterStructure.SchemaVersion, ChapterSummary = chapterStructure.ChapterSummary, DefaultPov = chapterStructure.DefaultPov, MixedPov = chapterStructure.MixedPov, SceneBoundaries = repaired, ExtensionData = chapterStructure.ExtensionData }, issues); } private static bool IsTrailingEmptyBoundary( ChapterSceneBoundary final, ChapterSceneBoundary previous, int paragraphCount, int phantomParagraph) => final.StartParagraph == phantomParagraph && final.EndParagraph == phantomParagraph && previous.EndParagraph == paragraphCount && final.StartParagraph > paragraphCount && final.EndParagraph > paragraphCount; private static bool IsTrailingInvalidPlaceholder( ChapterSceneBoundary final, ChapterSceneBoundary previous, int paragraphCount, int phantomParagraph) { if (!final.StartParagraph.HasValue || !final.EndParagraph.HasValue || previous.EndParagraph != paragraphCount) { return false; } var invalidTrailingRange = final.StartParagraph.Value > paragraphCount && (final.EndParagraph.Value <= paragraphCount || final.EndParagraph.Value == phantomParagraph || final.EndParagraph.Value < final.StartParagraph.Value); return invalidTrailingRange && HasPlaceholderSignal(final); } private static bool IsTrailingTerminalOffByOne( ChapterSceneBoundary final, ChapterSceneBoundary previous, int paragraphCount, int phantomParagraph) => final.StartParagraph == phantomParagraph && final.EndParagraph == paragraphCount && previous.EndParagraph == paragraphCount; private static bool HasPlaceholderSignal(ChapterSceneBoundary boundary) { if (boundary.Confidence <= 0.05m) { return true; } var reason = boundary.Reason ?? string.Empty; return reason.Contains("invalid", StringComparison.OrdinalIgnoreCase) || reason.Contains("placeholder", StringComparison.OrdinalIgnoreCase) || reason.Contains("ensure coverage", StringComparison.OrdinalIgnoreCase) || reason.Contains("coverage", StringComparison.OrdinalIgnoreCase); } private static string BuildNormalisationMessage( ChapterSceneBoundary final, int finalIndex, int paragraphCount, int phantomParagraph) { var start = final.StartParagraph ?? 0; var end = final.EndParagraph ?? 0; if (start == phantomParagraph && end == phantomParagraph) { return $"Discarded trailing empty scene boundary {phantomParagraph}-{phantomParagraph} because submitted chapter has {paragraphCount} paragraphs and previous scene already ended at {paragraphCount}."; } var sceneNumber = final.SceneNumber ?? finalIndex + 1; return $"Discarded trailing invalid placeholder scene {sceneNumber} with range {start}-{end} because previous scene already ended at paragraph {paragraphCount}."; } } public sealed record ChapterStructureBoundaryNormalisationResult( ChapterStructureModel? ChapterStructure, IReadOnlyList Issues);