using System.Text.Json; using PlotLine.Models; using PlotLine.Models.StoryIntelligence; namespace PlotLine.Services; public interface IStorySceneValidator { ValidationResult Validate(SceneIntelligenceScene? scene); } public sealed class StorySceneValidator : IStorySceneValidator { private static readonly HashSet LocationTypes = CreateSet("named", "generic", "unclear"); private static readonly HashSet CharacterRoles = CreateSet("present", "pointOfView", "speaker", "mentioned", "unclear"); private static readonly HashSet KnowledgeChangeTypes = CreateSet("Learns", "Realises", "IsTold", "Discovers", "Confirms", "Suspects", "Misunderstands"); private static readonly HashSet ObservationTypes = CreateSet("CharacterAction", "CharacterKnowledge", "CharacterLocation", "RelationshipSignal", "AssetInteraction", "LocationSignal", "TimelineSignal", "QuestionSignal"); private static readonly HashSet EntityTypes = CreateSet("Character", "Location", "Asset", "Knowledge", "Relationship", "Timeline", "Scene", "Unknown"); private static readonly HashSet PreferredMetrics = CreateSet("action", "emotion", "tension", "conflict", "mystery", "romance", "humour", "revelation", "pacingIntensity"); private static readonly HashSet RelationshipSignals = CreateSet("trust", "mistrust", "affection", "fear", "authority", "secrecy", "conflict", "dependence", "protection", "rivalry", "obligation"); public ValidationResult Validate(SceneIntelligenceScene? scene) { var result = new ValidationResult(); try { if (scene is null) { AddError(result, "$", "Scene Intelligence JSON could not be deserialised into the V1 model.", "Return one valid Scene Intelligence JSON object."); return result; } ValidateRoot(scene, result); ValidateSceneReference(scene.SceneReference, result); ValidateSummary(scene.Summary, result); ValidateScenePurpose(scene.ScenePurpose, result); ValidatePointOfView(scene.PointOfView, result); ValidateSetting(scene.Setting, result); ValidateCharacters(scene.Characters, result); ValidateLocations(scene.Locations, result); ValidateAssets(scene.Assets, result); ValidateRelationships(scene.Relationships, result); ValidateKnowledgeChanges(scene.KnowledgeChanges, result); ValidateTimelineClues(scene.TimelineClues, result); ValidateQuestionsRaised(scene.QuestionsRaised, result); ValidateQuestionsAnswered(scene.QuestionsAnswered, result); ValidateObservations(scene.Observations, result); ValidateMetrics(scene.Metrics, result); ValidateSourceLimits(scene.SourceLimits, 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 ValidateRoot(SceneIntelligenceScene scene, ValidationResult result) { RequiredString(scene.SchemaVersion, "schemaVersion", result); if (!string.Equals(scene.SchemaVersion, "1.0", StringComparison.Ordinal)) { AddError(result, "schemaVersion", "Schema version must be \"1.0\".", "Return schemaVersion as \"1.0\"."); } RequiredObject(scene.SceneReference, "sceneReference", result); RequiredObject(scene.Summary, "summary", result); RequiredObject(scene.ScenePurpose, "scenePurpose", result); RequiredObject(scene.PointOfView, "pointOfView", result); RequiredObject(scene.Setting, "setting", result); RequiredArray(scene.Characters, "characters", result); RequiredArray(scene.Locations, "locations", result); RequiredArray(scene.Assets, "assets", result); RequiredArray(scene.Relationships, "relationships", result); RequiredArray(scene.KnowledgeChanges, "knowledgeChanges", result); RequiredArray(scene.TimelineClues, "timelineClues", result); RequiredArray(scene.QuestionsRaised, "questionsRaised", result); RequiredArray(scene.QuestionsAnswered, "questionsAnswered", result); RequiredArray(scene.Observations, "observations", result); RequiredObject(scene.Metrics, "metrics", result); RequiredObject(scene.SourceLimits, "sourceLimits", result); Unknown(scene.ExtensionData, "$", result); } private static void ValidateSceneReference(SceneIntelligenceSceneReference? value, ValidationResult result) { if (value is null) { return; } RequiredScalar(value.ProjectId, "sceneReference.projectId", result, allowNull: true); RequiredScalar(value.BookId, "sceneReference.bookId", result, allowNull: true); RequiredScalar(value.ChapterId, "sceneReference.chapterId", result, allowNull: true); RequiredScalar(value.SceneId, "sceneReference.sceneId", result, allowNull: true); RequiredScalar(value.ChapterNumber, "sceneReference.chapterNumber", result, allowNull: true); RequiredScalar(value.SceneNumber, "sceneReference.sceneNumber", result, allowNull: true); RequiredString(value.SourceLabel, "sceneReference.sourceLabel", result); Unknown(value.ExtensionData, "sceneReference", result); } private static void ValidateSummary(SceneIntelligenceSummary? value, ValidationResult result) { if (value is null) { return; } RequiredString(value.Short, "summary.short", result); RequiredString(value.Detailed, "summary.detailed", result); Confidence(value.Confidence, "summary.confidence", result); Unknown(value.ExtensionData, "summary", result); } private static void ValidateScenePurpose(SceneIntelligenceScenePurpose? value, ValidationResult result) { if (value is null) { return; } RequiredString(value.ObservedFunction, "scenePurpose.observedFunction", result); Confidence(value.Confidence, "scenePurpose.confidence", result); Unknown(value.ExtensionData, "scenePurpose", result); } private static void ValidatePointOfView(SceneIntelligencePointOfView? value, ValidationResult result) { if (value is null) { return; } RequiredScalar(value.CharacterName, "pointOfView.characterName", result, allowNull: true); RequiredScalar(value.NarrativeMode, "pointOfView.narrativeMode", result, allowNull: true); Confidence(value.Confidence, "pointOfView.confidence", result); RequiredScalar(value.Evidence, "pointOfView.evidence", result, allowNull: true); Unknown(value.ExtensionData, "pointOfView", result); } private static void ValidateSetting(SceneIntelligenceSetting? value, ValidationResult result) { if (value is null) { return; } RequiredScalar(value.TimeOfDay, "setting.timeOfDay", result, allowNull: true); RequiredScalar(value.DateOrTimeReference, "setting.dateOrTimeReference", result, allowNull: true); RequiredScalar(value.LocationName, "setting.locationName", result, allowNull: true); Enum(value.LocationType, "setting.locationType", LocationTypes, result); RequiredScalar(value.GenericRoomType, "setting.genericRoomType", result, allowNull: true); RequiredScalar(value.ParentLocationHint, "setting.parentLocationHint", result, allowNull: true); Confidence(value.Confidence, "setting.confidence", result); if (string.Equals(value.LocationType, "generic", StringComparison.Ordinal) && string.IsNullOrWhiteSpace(value.GenericRoomType)) { AddWarning(result, "setting.genericRoomType", "Generic setting has no generic room type.", "Include genericRoomType when the manuscript supports it."); } Unknown(value.ExtensionData, "setting", result); } private static void ValidateCharacters(List? values, ValidationResult result) { if (values is null) { return; } DuplicateNames(values.Select((item, index) => (item.Name, Path: $"characters[{index}].name")), "characters", result); for (var i = 0; i < values.Count; i++) { var item = values[i]; var path = $"characters[{i}]"; RequiredString(item.Name, $"{path}.name", result); Enum(item.RoleInScene, $"{path}.roleInScene", CharacterRoles, result); RequiredScalar(item.MentionedOnly, $"{path}.mentionedOnly", result); RequiredArray(item.Aliases, $"{path}.aliases", result); RequiredArray(item.Actions, $"{path}.actions", result); Confidence(item.Confidence, $"{path}.confidence", result); RequiredScalar(item.Notes, $"{path}.notes", result, allowNull: true); if (item.MentionedOnly == true && !string.Equals(item.RoleInScene, "mentioned", StringComparison.Ordinal)) { AddWarning(result, $"{path}.roleInScene", "Mentioned-only character should usually use roleInScene \"mentioned\".", "Use roleInScene \"mentioned\" for absent references."); } Unknown(item.ExtensionData, path, result); } } private static void ValidateLocations(List? values, ValidationResult result) { if (values is null) { return; } DuplicateNames(values.Select((item, index) => (item.Name, Path: $"locations[{index}].name")), "locations", result); for (var i = 0; i < values.Count; i++) { var item = values[i]; var path = $"locations[{i}]"; RequiredString(item.Name, $"{path}.name", result); Enum(item.LocationType, $"{path}.locationType", LocationTypes, result); RequiredScalar(item.GenericRoomType, $"{path}.genericRoomType", result, allowNull: true); RequiredScalar(item.ParentLocationHint, $"{path}.parentLocationHint", result, allowNull: true); RequiredScalar(item.PresentInScene, $"{path}.presentInScene", result); RequiredScalar(item.MentionedOnly, $"{path}.mentionedOnly", result); Confidence(item.Confidence, $"{path}.confidence", result); RequiredScalar(item.Notes, $"{path}.notes", result, allowNull: true); if (item.PresentInScene == false && item.MentionedOnly == false) { AddWarning(result, $"{path}.mentionedOnly", "Location is not present but is not marked mentioned-only.", "Set mentionedOnly to true when a location is only discussed."); } Unknown(item.ExtensionData, path, result); } } private static void ValidateAssets(List? values, ValidationResult result) { if (values is null) { return; } DuplicateNames(values.Select((item, index) => (item.Name, Path: $"assets[{index}].name")), "assets", result); for (var i = 0; i < values.Count; i++) { var item = values[i]; var path = $"assets[{i}]"; RequiredString(item.Name, $"{path}.name", result); RequiredScalar(item.AssetType, $"{path}.assetType", result, allowNull: true); RequiredScalar(item.Status, $"{path}.status", result, allowNull: true); RequiredScalar(item.OwnerOrHolder, $"{path}.ownerOrHolder", result, allowNull: true); RequiredScalar(item.MentionedOnly, $"{path}.mentionedOnly", result); Confidence(item.Confidence, $"{path}.confidence", result); RequiredScalar(item.Notes, $"{path}.notes", result, allowNull: true); Unknown(item.ExtensionData, path, result); } } private static void ValidateRelationships(List? values, ValidationResult result) { if (values is null) { return; } var seen = new HashSet(StringComparer.OrdinalIgnoreCase); for (var i = 0; i < values.Count; i++) { var item = values[i]; var path = $"relationships[{i}]"; RequiredString(item.CharacterA, $"{path}.characterA", result); RequiredString(item.CharacterB, $"{path}.characterB", result); RequiredString(item.RelationshipSignal, $"{path}.relationshipSignal", result); if (!string.IsNullOrWhiteSpace(item.RelationshipSignal) && !RelationshipSignals.Contains(item.RelationshipSignal)) { AddWarning(result, $"{path}.relationshipSignal", "Relationship signal is outside the preferred V1 neutral terms.", "Use a supported neutral signal when one fits."); } RequiredString(item.Evidence, $"{path}.evidence", result); Confidence(item.Confidence, $"{path}.confidence", result); var key = string.Join("|", new[] { item.CharacterA, item.CharacterB, item.RelationshipSignal }.Select(value => value?.Trim().ToLowerInvariant())); if (!string.IsNullOrWhiteSpace(key) && !seen.Add(key)) { AddWarning(result, path, "Duplicate relationship signal found.", "Merge duplicate scene-local relationship signals when appropriate."); } Unknown(item.ExtensionData, path, result); } } private static void ValidateKnowledgeChanges(List? values, ValidationResult result) { if (values is null) { return; } for (var i = 0; i < values.Count; i++) { var item = values[i]; var path = $"knowledgeChanges[{i}]"; RequiredString(item.RecipientCharacter, $"{path}.recipientCharacter", result); RequiredString(item.KnowledgeItem, $"{path}.knowledgeItem", result); Enum(item.ChangeType, $"{path}.changeType", KnowledgeChangeTypes, result); RequiredScalar(item.SourceCharacter, $"{path}.sourceCharacter", result, allowNull: true); RequiredScalar(item.SourceType, $"{path}.sourceType", result, allowNull: true); RequiredString(item.Evidence, $"{path}.evidence", result); Confidence(item.Confidence, $"{path}.confidence", result); Unknown(item.ExtensionData, path, result); } } private static void ValidateTimelineClues(List? values, ValidationResult result) { if (values is null) { return; } for (var i = 0; i < values.Count; i++) { var item = values[i]; var path = $"timelineClues[{i}]"; RequiredString(item.Clue, $"{path}.clue", result); RequiredScalar(item.RelativeOrder, $"{path}.relativeOrder", result, allowNull: true); RequiredScalar(item.AbsoluteDate, $"{path}.absoluteDate", result, allowNull: true); RequiredString(item.Evidence, $"{path}.evidence", result); Confidence(item.Confidence, $"{path}.confidence", result); Unknown(item.ExtensionData, path, result); } } private static void ValidateQuestionsRaised(List? values, ValidationResult result) { if (values is null) { return; } for (var i = 0; i < values.Count; i++) { var item = values[i]; var path = $"questionsRaised[{i}]"; RequiredString(item.Question, $"{path}.question", result); RequiredString(item.Scope, $"{path}.scope", result); RequiredString(item.Evidence, $"{path}.evidence", result); Confidence(item.Confidence, $"{path}.confidence", result); Unknown(item.ExtensionData, path, result); } } private static void ValidateQuestionsAnswered(List? values, ValidationResult result) { if (values is null) { return; } for (var i = 0; i < values.Count; i++) { var item = values[i]; var path = $"questionsAnswered[{i}]"; RequiredString(item.Question, $"{path}.question", result); RequiredString(item.Answer, $"{path}.answer", result); RequiredString(item.Evidence, $"{path}.evidence", result); Confidence(item.Confidence, $"{path}.confidence", result); Unknown(item.ExtensionData, path, result); } } private static void ValidateObservations(List? values, ValidationResult result) { if (values is null) { return; } for (var i = 0; i < values.Count; i++) { var item = values[i]; var path = $"observations[{i}]"; Enum(item.ObservationType, $"{path}.observationType", ObservationTypes, result); Enum(item.SubjectEntityType, $"{path}.subjectEntityType", EntityTypes, result); RequiredString(item.SubjectName, $"{path}.subjectName", result); NullableEnum(item.ObjectEntityType, $"{path}.objectEntityType", EntityTypes, result); RequiredScalar(item.ObjectName, $"{path}.objectName", result, allowNull: true); RequiredString(item.Predicate, $"{path}.predicate", result); RequiredString(item.Description, $"{path}.description", result); RequiredString(item.Evidence, $"{path}.evidence", result); Confidence(item.Confidence, $"{path}.confidence", result); if (string.IsNullOrWhiteSpace(item.ObjectEntityType) != string.IsNullOrWhiteSpace(item.ObjectName)) { AddWarning(result, path, "Observation object entity type and object name are inconsistent.", "Use both objectEntityType and objectName, or set both to null when no object applies."); } Unknown(item.ExtensionData, path, result); } } private static void ValidateMetrics(Dictionary? metrics, ValidationResult result) { if (metrics is null) { return; } foreach (var (name, metric) in metrics) { var path = $"metrics.{name}"; if (!PreferredMetrics.Contains(name)) { AddWarning(result, path, "Metric is outside the preferred V1 metric set.", "Use preferred V1 metrics unless the schema is intentionally expanded."); } if (metric is null) { AddError(result, path, "Metric object is required when a metric is present.", "Return { \"score\": 1-10, \"confidence\": 0.0-1.0 }."); continue; } if (!metric.Score.HasValue) { AddError(result, $"{path}.score", "Metric score is required.", "Return a score from 1 to 10."); } else if (metric.Score < 1 || metric.Score > 10) { AddError(result, $"{path}.score", "Metric score must be from 1 to 10.", "Use the V1 1-10 descriptive intensity scale."); } Confidence(metric.Confidence, $"{path}.confidence", result); Unknown(metric.ExtensionData, path, result); } } private static void ValidateSourceLimits(SceneIntelligenceSourceLimits? value, ValidationResult result) { if (value is null) { return; } RequiredScalar(value.ContainsAmbiguity, "sourceLimits.containsAmbiguity", result); RequiredArray(value.AmbiguityNotes, "sourceLimits.ambiguityNotes", result); Unknown(value.ExtensionData, "sourceLimits", result); } 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 V1 confidence scale."); } } private static void Enum(string? value, string path, HashSet allowed, ValidationResult result) { RequiredString(value, path, result); if (!string.IsNullOrWhiteSpace(value) && !allowed.Contains(value)) { AddError(result, path, $"Value \"{value}\" is not allowed.", $"Use one of: {string.Join(", ", allowed)}."); } } private static void NullableEnum(string? value, string path, HashSet allowed, ValidationResult result) { if (!string.IsNullOrWhiteSpace(value) && !allowed.Contains(value)) { AddError(result, path, $"Value \"{value}\" is not allowed.", $"Use null or one of: {string.Join(", ", allowed)}."); } } 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 scene."); } } private static void RequiredScalar(T? value, string path, ValidationResult result, bool allowNull = false) { if (value is null && !allowNull) { AddError(result, path, "Required property is missing or null.", "Return the required property with a supported value."); } } private static void RequiredObject(T? value, string path, ValidationResult result) { if (value is null) { AddError(result, path, "Required object is missing or null.", "Return the required V1 object."); } } private static void RequiredArray(List? value, string path, ValidationResult result) { if (value is null) { AddError(result, path, "Required array is missing or null.", "Return an empty array when no items are supported."); } } private static void RequiredObject(Dictionary? value, string path, ValidationResult result) { if (value is null) { AddError(result, path, "Required object is missing or null.", "Return an empty object when no metrics are supported."); } } private static void DuplicateNames(IEnumerable<(string? Name, string Path)> values, string collectionPath, ValidationResult result) { var seen = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var (name, path) in values) { if (string.IsNullOrWhiteSpace(name)) { continue; } var key = name.Trim(); if (seen.TryGetValue(key, out var existingPath)) { AddWarning(result, path, $"Duplicate entity name also appears at {existingPath}.", $"Avoid duplicate names in {collectionPath} unless they represent distinct supported entities."); } else { seen[key] = path; } } } 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 Scene Intelligence Schema 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 }); private static HashSet CreateSet(params string[] values) => new(values, StringComparer.Ordinal); }