PlotDirector/PlotLine/Services/ChapterStructureValidator.cs
Nick Beckley 7ccc0447a3 Phase 20M – Chapter Structural Analysis
Phase 20N – In-Memory Chapter-to-Scene Pipeline
2026-07-04 20:49:36 +01:00

180 lines
7.5 KiB
C#

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<ChapterSceneBoundary> 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<string, JsonElement>? 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 });
}