using System.Text.Json; using PlotLine.Data; using PlotLine.Models; namespace PlotLine.Services; public interface ISceneImportResolver { Task ResolveAsync(StoryIntelligenceSavedRun run, IReadOnlyList sceneResults); } public sealed class SceneImportResolver( ICharacterRepository characters, ILocationRepository locations, IAssetRepository assets, ILogger logger) : ISceneImportResolver { private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true }; private static readonly HashSet GenericPeople = CreateSet( "the receptionist", "receptionist", "old man", "young woman", "police officer", "doctor", "nurse", "driver", "man", "woman", "boy", "girl", "child", "person", "guard", "waiter", "waitress", "clerk", "shopkeeper"); private static readonly HashSet IgnoredPovNames = CreateSet("narrator", "unknown"); private static readonly HashSet GenericLocations = CreateSet( "car park", "road", "house", "kitchen", "stairwell", "stair", "doorway", "corridor", "street", "room", "bedroom", "bathroom", "office", "car", "church", "hall", "table", "chair"); private static readonly HashSet GenericAssets = CreateSet( "the key", "key", "the letter", "letter", "the car", "car", "phone", "table", "chair", "door", "window"); public async Task ResolveAsync(StoryIntelligenceSavedRun run, IReadOnlyList sceneResults) { if (run.ProjectID is not int projectId) { return new StoryIntelligenceImportPreview { Warnings = ["Import Preview requires a project id to resolve existing PlotDirector entities."] }; } var scenes = sceneResults .Where(scene => scene.ValidationErrorsCount == 0) .Select(TryReadScene) .Where(scene => scene is not null) .Cast() .ToList(); var characterIndex = await BuildCharacterIndexAsync(projectId); var locationIndex = await BuildLocationIndexAsync(projectId); var assetIndex = await BuildAssetIndexAsync(projectId); var existingRelationships = await characters.ListRelationshipsByProjectAsync(projectId); var characterResolutions = ResolveCharacters(scenes, characterIndex).ToList(); var locationResolutions = ResolveLocations(scenes, locationIndex).ToList(); var assetResolutions = ResolveAssets(scenes, assetIndex).ToList(); var relationshipPreview = ResolveRelationships(scenes, existingRelationships).ToList(); var knowledgePreview = ResolveKnowledge(scenes).ToList(); var timelinePreview = ResolveTimeline(scenes).ToList(); var warnings = BuildWarnings(characterResolutions, locationResolutions, assetResolutions).ToList(); return new StoryIntelligenceImportPreview { Characters = characterResolutions, Locations = locationResolutions, Assets = assetResolutions, Relationships = relationshipPreview, Knowledge = knowledgePreview, Timeline = timelinePreview, Warnings = warnings }; } private IEnumerable ResolveCharacters(IEnumerable scenes, EntityIndex index) { foreach (var item in scenes .SelectMany(scene => (scene.Characters ?? []).Select(character => new CharacterMention(character.Name, character.Confidence, character.Notes))) .Concat(scenes.Select(scene => new CharacterMention(scene.PointOfView?.CharacterName, scene.PointOfView?.Confidence, "Point of view character."))) .Where(item => !string.IsNullOrWhiteSpace(item.Name)) .GroupBy(item => Clean(item.Name), StringComparer.OrdinalIgnoreCase) .Select(group => group.First())) { var name = Clean(item.Name); if (IgnoredPovNames.Contains(name)) { yield return LogResolution(new StoryImportEntityResolution { InputName = name, EntityType = "Character", Status = StoryImportResolutionStatuses.Ignored, Confidence = item.Confidence, Notes = "Narrator identity is intentionally left for later chapter metadata resolution." }); continue; } if (GenericPeople.Contains(name.ToLowerInvariant())) { yield return LogResolution(new StoryImportEntityResolution { InputName = name, EntityType = "Character", Status = StoryImportResolutionStatuses.UnresolvedPersonReference, Confidence = item.Confidence, Notes = "Generic person reference; would not create a Character record." }); continue; } yield return LogResolution(MatchEntity("Character", name, item.Confidence, index, StoryImportResolutionStatuses.NewCharacter)); } } private IEnumerable ResolveLocations(IEnumerable scenes, EntityIndex index) { foreach (var item in scenes .SelectMany(scene => scene.Locations ?? []) .Where(location => !string.IsNullOrWhiteSpace(location.Name)) .GroupBy(location => Clean(location.Name), StringComparer.OrdinalIgnoreCase) .Select(group => group.First())) { var name = Clean(item.Name); if (IsGenericLocation(item)) { yield return LogResolution(new StoryImportEntityResolution { InputName = name, EntityType = "Location", Status = StoryImportResolutionStatuses.GenericReference, Confidence = item.Confidence, Notes = "Generic location reference; would not create a Location record." }); continue; } yield return LogResolution(MatchEntity("Location", name, item.Confidence, index, StoryImportResolutionStatuses.NewLocation)); } } private IEnumerable ResolveAssets(IEnumerable scenes, EntityIndex index) { foreach (var item in scenes .SelectMany(scene => scene.Assets ?? []) .Where(asset => !string.IsNullOrWhiteSpace(asset.Name)) .GroupBy(asset => Clean(asset.Name), StringComparer.OrdinalIgnoreCase) .Select(group => group.First())) { var name = Clean(item.Name); if (GenericAssets.Contains(name.ToLowerInvariant())) { yield return LogResolution(new StoryImportEntityResolution { InputName = name, EntityType = "Asset", Status = StoryImportResolutionStatuses.GenericReference, Confidence = item.Confidence, Notes = "Generic object reference; would not create an Asset record unless later confirmed as meaningful." }); continue; } yield return LogResolution(MatchEntity("Asset", name, item.Confidence, index, StoryImportResolutionStatuses.NewAsset)); } } private static IEnumerable ResolveRelationships(IEnumerable scenes, IReadOnlyList existingRelationships) { foreach (var relationship in scenes.SelectMany(scene => scene.Relationships ?? [])) { if (string.IsNullOrWhiteSpace(relationship.CharacterA) || string.IsNullOrWhiteSpace(relationship.CharacterB)) { continue; } var exists = existingRelationships.Any(existing => NamesMatch(existing.CharacterAName, relationship.CharacterA) && NamesMatch(existing.CharacterBName, relationship.CharacterB) || NamesMatch(existing.CharacterAName, relationship.CharacterB) && NamesMatch(existing.CharacterBName, relationship.CharacterA)); yield return new StoryImportRelationshipPreview { Action = exists ? "Would Update Relationship" : "Would Create Relationship", CharacterA = relationship.CharacterA, CharacterB = relationship.CharacterB, Signal = relationship.RelationshipSignal ?? "relationship signal", Confidence = relationship.Confidence }; } } private static IEnumerable ResolveKnowledge(IEnumerable scenes) => scenes.SelectMany(scene => scene.KnowledgeChanges ?? []) .Where(change => !string.IsNullOrWhiteSpace(change.RecipientCharacter) && !string.IsNullOrWhiteSpace(change.KnowledgeItem)) .Select(change => new StoryImportKnowledgePreview { Character = change.RecipientCharacter!, KnowledgeItem = change.KnowledgeItem!, OldState = "Unknown", NewState = string.IsNullOrWhiteSpace(change.ChangeType) ? "Would update" : change.ChangeType!, Confidence = change.Confidence }); private static IEnumerable ResolveTimeline(IEnumerable scenes) => scenes.SelectMany(scene => (scene.TimelineClues ?? []).Select(clue => new StoryImportTimelinePreview { SceneNumber = scene.SceneReference?.SceneNumber.HasValue == true ? Convert.ToInt32(scene.SceneReference.SceneNumber.Value) : null, SceneDate = !string.IsNullOrWhiteSpace(clue.AbsoluteDate) ? clue.AbsoluteDate! : clue.Clue ?? "Timeline clue", Confidence = clue.Confidence })); private StoryImportEntityResolution MatchEntity(string entityType, string inputName, decimal? confidence, EntityIndex index, string newStatus) { var candidates = index.Find(inputName); return candidates.Count switch { 0 => new StoryImportEntityResolution { InputName = inputName, EntityType = entityType, Status = newStatus, Confidence = confidence }, 1 => new StoryImportEntityResolution { InputName = inputName, EntityType = entityType, Status = StoryImportResolutionStatuses.Matched, MatchedID = candidates[0].EntityID, MatchedName = candidates[0].Name, Confidence = confidence, Candidates = candidates }, _ => new StoryImportEntityResolution { InputName = inputName, EntityType = entityType, Status = StoryImportResolutionStatuses.Ambiguous, Confidence = confidence, Candidates = candidates } }; } private async Task BuildCharacterIndexAsync(int projectId) { var rows = new List(); foreach (var character in await characters.ListCharactersAsync(projectId)) { rows.Add(new EntityIndexRow(character.CharacterID, character.CharacterName, character.CharacterName, "Name")); if (!string.IsNullOrWhiteSpace(character.ShortName)) { rows.Add(new EntityIndexRow(character.CharacterID, character.CharacterName, character.ShortName!, "Display name")); } foreach (var alias in await characters.ListAliasesAsync(character.CharacterID)) { rows.Add(new EntityIndexRow(character.CharacterID, character.CharacterName, alias.Alias, "Alias")); } } return new EntityIndex(rows); } private async Task BuildLocationIndexAsync(int projectId) { var rows = new List(); foreach (var location in await locations.ListByProjectAsync(projectId)) { rows.Add(new EntityIndexRow(location.LocationID, location.LocationName, location.LocationName, "Name")); foreach (var alias in await locations.ListAliasesAsync(location.LocationID)) { rows.Add(new EntityIndexRow(location.LocationID, location.LocationName, alias.Alias, "Alias")); } } return new EntityIndex(rows); } private async Task BuildAssetIndexAsync(int projectId) { var rows = new List(); foreach (var asset in await assets.ListAssetsAsync(projectId)) { rows.Add(new EntityIndexRow(asset.StoryAssetID, asset.AssetName, asset.AssetName, "Name")); foreach (var alias in await assets.ListAliasesAsync(asset.StoryAssetID)) { rows.Add(new EntityIndexRow(asset.StoryAssetID, asset.AssetName, alias.Alias, "Alias")); } } return new EntityIndex(rows); } private StoryImportEntityResolution LogResolution(StoryImportEntityResolution resolution) { logger.LogInformation( "Story Intelligence Import Preview resolved {EntityType}. Input={InputName} Status={Status} Resolved={ResolvedName} Confidence={ConfidencePercent} Candidates={Candidates}", resolution.EntityType, resolution.InputName, resolution.Status, resolution.MatchedName, resolution.ConfidencePercent, string.Join(", ", resolution.Candidates.Select(candidate => candidate.Name))); return resolution; } private static IEnumerable BuildWarnings( IReadOnlyList characterResolutions, IReadOnlyList locationResolutions, IReadOnlyList assetResolutions) { var unresolved = characterResolutions.Concat(locationResolutions).Concat(assetResolutions) .Count(item => item.Status is StoryImportResolutionStatuses.Ambiguous or StoryImportResolutionStatuses.UnresolvedPersonReference); yield return unresolved == 0 ? "No unresolved entities." : $"{unresolved:N0} unresolved or ambiguous reference(s)."; } private static SceneIntelligenceScene? TryReadScene(StoryIntelligenceSavedSceneResult result) { if (string.IsNullOrWhiteSpace(result.ParsedJson)) { return null; } try { var direct = JsonSerializer.Deserialize(result.ParsedJson, JsonOptions); if (!string.IsNullOrWhiteSpace(direct?.SchemaVersion)) { return direct; } using var document = JsonDocument.Parse(result.ParsedJson); if (document.RootElement.TryGetProperty("parsedScene", out var parsedScene)) { return parsedScene.Deserialize(JsonOptions); } } catch (JsonException) { } return null; } private static bool IsGenericLocation(SceneIntelligenceLocation location) => string.Equals(location.LocationType, "generic", StringComparison.OrdinalIgnoreCase) || GenericLocations.Contains(Clean(location.Name).ToLowerInvariant()); private static string Clean(string? value) => string.Join(' ', (value ?? string.Empty).Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); private static bool NamesMatch(string? first, string? second) => string.Equals(Clean(first), Clean(second), StringComparison.OrdinalIgnoreCase); private static HashSet CreateSet(params string[] values) => new(values, StringComparer.OrdinalIgnoreCase); private sealed record EntityIndexRow(int EntityID, string EntityName, string MatchValue, string MatchReason); private sealed record CharacterMention(string? Name, decimal? Confidence, string? Notes); private sealed class EntityIndex(IReadOnlyList rows) { public IReadOnlyList Find(string value) { var clean = Clean(value); var matches = rows .Where(row => string.Equals(Clean(row.MatchValue), clean, StringComparison.Ordinal) || string.Equals(Clean(row.MatchValue), clean, StringComparison.OrdinalIgnoreCase)) .GroupBy(row => row.EntityID) .Select(group => { var first = group.First(); return new StoryImportResolutionCandidate { EntityID = first.EntityID, Name = first.EntityName, MatchReason = first.MatchReason }; }) .ToList(); return matches; } } }