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 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)) { var normalisedBoundaries = boundaries .Take(finalIndex) .Select((boundary, index) => CopyBoundary(boundary, index + 1)) .ToList(); var normalised = new ChapterStructureModel { SchemaVersion = chapterStructure.SchemaVersion, ChapterSummary = chapterStructure.ChapterSummary, SceneBoundaries = normalisedBoundaries, ExtensionData = chapterStructure.ExtensionData }; var issue = 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." }; return new ChapterStructureBoundaryNormalisationResult(normalised, [issue]); } return new ChapterStructureBoundaryNormalisationResult(chapterStructure, []); } 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.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, Confidence = boundary.Confidence, Reason = boundary.Reason, ExtensionData = boundary.ExtensionData }; 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 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);