using System.Text.Json; using PlotLine.Models; using PlotLine.Models.StoryIntelligence; namespace PlotLine.Services; public interface IChapterStructureValidator { ValidationResult Validate(ChapterStructureModel? chapterStructure, int paragraphCount); } public sealed class ChapterStructureValidator : IChapterStructureValidator { public ValidationResult Validate(ChapterStructureModel? chapterStructure, int paragraphCount) { var result = new ValidationResult(); try { if (chapterStructure is null) { AddError(result, "$", "Chapter Structure JSON could not be deserialised.", "Return one valid Chapter Structure JSON object."); return result; } if (paragraphCount <= 0) { AddError(result, "chapterText", "Chapter text must contain at least one non-empty paragraph.", "Enter chapter text with one or more paragraphs."); } RequiredString(chapterStructure.SchemaVersion, "schemaVersion", result); if (!string.Equals(chapterStructure.SchemaVersion, "1.0", StringComparison.Ordinal)) { AddError(result, "schemaVersion", "Schema version must be \"1.0\".", "Return schemaVersion as \"1.0\"."); } RequiredString(chapterStructure.ChapterSummary, "chapterSummary", result); if (chapterStructure.SceneBoundaries is null) { AddError(result, "sceneBoundaries", "Scene boundaries array is required.", "Return sceneBoundaries as an array."); } else { ValidateBoundaries(chapterStructure.SceneBoundaries, paragraphCount, result); } Unknown(chapterStructure.ExtensionData, "$", result); if (result.IsValid) { AddInfo(result, "$", "Validation passed.", "No action needed."); } } catch (Exception ex) { AddError(result, "$", $"Validator failed internally: {ex.Message}", "Review validator implementation for this response shape."); } return result; } private static void ValidateBoundaries(List boundaries, int paragraphCount, ValidationResult result) { if (boundaries.Count == 0) { AddError(result, "sceneBoundaries", "At least one scene boundary is required.", "Return one boundary covering the chapter if no scene split is supported."); return; } var expectedStart = 1; for (var i = 0; i < boundaries.Count; i++) { var boundary = boundaries[i]; var path = $"sceneBoundaries[{i}]"; var expectedSceneNumber = i + 1; if (!boundary.SceneNumber.HasValue) { AddError(result, $"{path}.sceneNumber", "Scene number is required.", $"Return sceneNumber {expectedSceneNumber}."); } else if (boundary.SceneNumber.Value != expectedSceneNumber) { AddError(result, $"{path}.sceneNumber", "Scene numbers must be sequential from 1.", $"Use sceneNumber {expectedSceneNumber} for this boundary."); } if (!boundary.StartParagraph.HasValue) { AddError(result, $"{path}.startParagraph", "Start paragraph is required.", "Return the first paragraph number for this boundary."); } if (!boundary.EndParagraph.HasValue) { AddError(result, $"{path}.endParagraph", "End paragraph is required.", "Return the last paragraph number for this boundary."); } if (boundary.StartParagraph.HasValue && boundary.EndParagraph.HasValue) { if (boundary.StartParagraph.Value > boundary.EndParagraph.Value) { AddError(result, path, "Start paragraph cannot be after end paragraph.", "Use an ordered inclusive paragraph range."); } if (boundary.StartParagraph.Value != expectedStart) { AddError(result, $"{path}.startParagraph", "Scene boundaries must not overlap or leave gaps.", $"Use startParagraph {expectedStart}."); } if (boundary.StartParagraph.Value < 1 || boundary.EndParagraph.Value < 1) { AddError(result, path, "Paragraph numbers must be 1 or greater.", "Use paragraph numbers from the submitted chapter text."); } if (paragraphCount > 0 && boundary.EndParagraph.Value > paragraphCount) { AddError(result, $"{path}.endParagraph", "End paragraph exceeds the submitted chapter paragraph count.", $"Use a value from 1 to {paragraphCount}."); } expectedStart = boundary.EndParagraph.Value + 1; } Confidence(boundary.Confidence, $"{path}.confidence", result); RequiredString(boundary.Reason, $"{path}.reason", result); Unknown(boundary.ExtensionData, path, result); } if (paragraphCount > 0 && expectedStart <= paragraphCount) { AddError(result, "sceneBoundaries", "Scene boundaries do not cover the full chapter.", $"Add a boundary ending at paragraph {paragraphCount}."); } } private static void Confidence(decimal? value, string path, ValidationResult result) { if (!value.HasValue) { AddError(result, path, "Confidence is required.", "Return a confidence value from 0.0 to 1.0."); return; } if (value < 0 || value > 1) { AddError(result, path, "Confidence must be from 0.0 to 1.0.", "Use the 0.0 to 1.0 confidence scale."); } } private static void RequiredString(string? value, string path, ValidationResult result) { if (value is null) { AddError(result, path, "Required string property is missing or null.", "Return a supported string value."); } else if (string.IsNullOrWhiteSpace(value)) { AddError(result, path, "Required string property is empty.", "Return a non-empty string supported by the chapter structure."); } } private static void Unknown(Dictionary? values, string path, ValidationResult result) { if (values is null || values.Count == 0) { return; } foreach (var propertyName in values.Keys.Order(StringComparer.Ordinal)) { AddWarning(result, path == "$" ? propertyName : $"{path}.{propertyName}", "Unknown property is not part of Chapter Structure V1.", "Remove this property or update the schema version intentionally."); } } private static void AddError(ValidationResult result, string path, string message, string suggestedFix) => result.Errors.Add(new ValidationIssue { Severity = "Error", Path = path, Message = message, SuggestedFix = suggestedFix }); private static void AddWarning(ValidationResult result, string path, string message, string suggestedFix) => result.Warnings.Add(new ValidationIssue { Severity = "Warning", Path = path, Message = message, SuggestedFix = suggestedFix }); private static void AddInfo(ValidationResult result, string path, string message, string suggestedFix) => result.Information.Add(new ValidationIssue { Severity = "Information", Path = path, Message = message, SuggestedFix = suggestedFix }); }