PlotDirector/PlotLine/Services/SceneImportResolver.cs
2026-07-05 19:45:23 +01:00

518 lines
24 KiB
C#

using System.Text.Json;
using PlotLine.Data;
using PlotLine.Models;
namespace PlotLine.Services;
public interface ISceneImportResolver
{
Task<StoryIntelligenceImportPreview> ResolveAsync(StoryIntelligenceSavedRun run, IReadOnlyList<StoryIntelligenceSavedSceneResult> sceneResults);
}
public sealed class SceneImportResolver(
ICharacterRepository characters,
ILocationRepository locations,
IAssetRepository assets,
ILogger<SceneImportResolver> logger) : ISceneImportResolver
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
private static readonly HashSet<string> GenericPeople = CreateSet(
"the receptionist", "receptionist", "old man", "young woman", "police officer", "doctor", "nurse", "driver",
"man", "woman", "women", "boy", "girl", "child", "children", "person", "people", "guard", "security guard",
"cashier", "elderly attendant", "attendant", "driver of car behind", "shop assistant", "crowd", "family",
"group of women", "small group", "women (small group)", "waiter", "waitress", "clerk", "shopkeeper");
private static readonly HashSet<string> IgnoredPovNames = CreateSet("narrator", "unknown");
private static readonly HashSet<string> GenericLocations = CreateSet(
"car park", "house", "kitchen", "stairwell", "stair", "doorway", "corridor", "room",
"bedroom", "bathroom", "office", "reception", "sixth floor", "floor", "car", "hall", "table", "chair");
private static readonly HashSet<string> EnvironmentalAssets = CreateSet(
"pink towel", "mug of tea", "magazine", "fire door", "old oak door", "iron handle", "reception desk",
"stained-glass windows", "stained glass windows", "chair", "desk", "window", "windows", "cup", "floor",
"wall", "door", "table", "towel", "mug", "handle");
private static readonly HashSet<string> StoryAssetNames = CreateSet(
"car", "vehicle", "passport", "letter", "key", "photograph", "photo", "bank book", "bank account",
"necklace", "weapon", "folder", "document", "diary", "telephone number", "phone number", "scrap of paper", "wallet");
public async Task<StoryIntelligenceImportPreview> ResolveAsync(StoryIntelligenceSavedRun run, IReadOnlyList<StoryIntelligenceSavedSceneResult> 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<SceneIntelligenceScene>()
.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, characterResolutions).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<StoryImportEntityResolution> ResolveCharacters(IEnumerable<SceneIntelligenceScene> 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 (IsGenericPerson(name))
{
yield return LogResolution(new StoryImportEntityResolution
{
InputName = name,
EntityType = "Character",
Status = StoryImportResolutionStatuses.UnresolvedPersonReference,
Confidence = item.Confidence,
Notes = "Rejected: generic unnamed role; would not create a Character record."
});
continue;
}
yield return LogResolution(MatchEntity("Character", name, item.Confidence, index, StoryImportResolutionStatuses.NewCharacter));
}
}
private IEnumerable<StoryImportEntityResolution> ResolveLocations(IEnumerable<SceneIntelligenceScene> scenes, EntityIndex index)
{
foreach (var group in scenes
.SelectMany(scene => scene.Locations ?? [])
.Where(location => !string.IsNullOrWhiteSpace(location.Name))
.GroupBy(location => NormaliseLocationName(location.Name), StringComparer.OrdinalIgnoreCase))
{
var item = 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 = "Internal/generic location note; would not create a separate Location record."
});
continue;
}
var resolution = MatchEntity("Location", group.Key, item.Confidence, index, StoryImportResolutionStatuses.NewLocation);
var variants = group.Select(location => Clean(location.Name)).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
if (!string.Equals(group.Key, name, StringComparison.OrdinalIgnoreCase) || variants.Count > 1)
{
resolution = resolution.Status == StoryImportResolutionStatuses.NewLocation && variants.Count > 1
? WithStatusAndNotes(resolution, StoryImportResolutionStatuses.PossibleDuplicate, $"Possible duplicate location variant(s): {string.Join(", ", variants)}.")
: WithNotes(resolution, $"Merged location variant(s): {string.Join(", ", variants)}.");
logger.LogInformation("Merged Location {InputName} -> {NormalisedName}. Reason={Reason}", name, group.Key, "Location normalisation.");
}
yield return LogResolution(resolution);
}
}
private IEnumerable<StoryImportEntityResolution> ResolveAssets(IEnumerable<SceneIntelligenceScene> 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 (IsRejectedAsset(item))
{
yield return LogResolution(new StoryImportEntityResolution
{
InputName = name,
EntityType = "Asset",
Status = StoryImportResolutionStatuses.Ignored,
Confidence = item.Confidence,
Notes = "Rejected: environmental object or low-value prop; would not create an Asset record."
});
continue;
}
yield return LogResolution(MatchEntity("Asset", name, item.Confidence, index, StoryImportResolutionStatuses.NewAsset));
}
}
private static IEnumerable<StoryImportRelationshipPreview> ResolveRelationships(
IEnumerable<SceneIntelligenceScene> scenes,
IReadOnlyList<CharacterRelationship> existingRelationships,
IReadOnlyList<StoryImportEntityResolution> characterResolutions)
{
var resolvedCharacters = characterResolutions
.Where(item => item.Status == StoryImportResolutionStatuses.Matched)
.GroupBy(item => Clean(item.InputName), StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.First().MatchedName ?? group.First().InputName, StringComparer.OrdinalIgnoreCase);
var grouped = scenes
.SelectMany(scene => (scene.Relationships ?? []).Select(relationship => new
{
Relationship = relationship,
SceneNumber = scene.SceneReference?.SceneNumber.HasValue == true ? Convert.ToInt32(scene.SceneReference.SceneNumber.Value) : (int?)null
}))
.Where(item => !string.IsNullOrWhiteSpace(item.Relationship.CharacterA)
&& !string.IsNullOrWhiteSpace(item.Relationship.CharacterB)
&& resolvedCharacters.ContainsKey(Clean(item.Relationship.CharacterA))
&& resolvedCharacters.ContainsKey(Clean(item.Relationship.CharacterB)))
.GroupBy(item => RelationshipKey(resolvedCharacters[Clean(item.Relationship.CharacterA!)], resolvedCharacters[Clean(item.Relationship.CharacterB!)], item.Relationship.RelationshipSignal));
foreach (var group in grouped)
{
var first = group.First().Relationship;
var characterA = resolvedCharacters[Clean(first.CharacterA)];
var characterB = resolvedCharacters[Clean(first.CharacterB)];
var exists = existingRelationships.Any(existing =>
NamesMatch(existing.CharacterAName, characterA) && NamesMatch(existing.CharacterBName, characterB)
|| NamesMatch(existing.CharacterAName, characterB) && NamesMatch(existing.CharacterBName, characterA));
yield return new StoryImportRelationshipPreview
{
Action = exists ? "Would Update Relationship" : "Would Create Relationship",
CharacterA = characterA,
CharacterB = characterB,
Signal = first.RelationshipSignal ?? "relationship signal",
Confidence = group.Max(item => item.Relationship.Confidence),
SupportingSceneNumbers = group.Select(item => item.SceneNumber).Where(number => number.HasValue).Select(number => number!.Value).Distinct().Order().ToList()
};
}
}
private static IEnumerable<StoryImportKnowledgePreview> ResolveKnowledge(IEnumerable<SceneIntelligenceScene> 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<StoryImportTimelinePreview> ResolveTimeline(IEnumerable<SceneIntelligenceScene> 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 static StoryImportEntityResolution WithNotes(StoryImportEntityResolution resolution, string notes)
=> WithStatusAndNotes(resolution, resolution.Status, notes);
private static StoryImportEntityResolution WithStatusAndNotes(StoryImportEntityResolution resolution, string status, string notes)
=> new()
{
InputName = resolution.InputName,
Status = status,
EntityType = resolution.EntityType,
MatchedName = resolution.MatchedName,
MatchedID = resolution.MatchedID,
Confidence = resolution.Confidence,
Candidates = resolution.Candidates,
Notes = notes
};
private async Task<EntityIndex> BuildCharacterIndexAsync(int projectId)
{
var rows = new List<EntityIndexRow>();
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<EntityIndex> BuildLocationIndexAsync(int projectId)
{
var rows = new List<EntityIndexRow>();
foreach (var location in await locations.ListByProjectAsync(projectId))
{
rows.Add(new EntityIndexRow(location.LocationID, location.LocationName, location.LocationName, "Name"));
var normalised = NormaliseLocationName(location.LocationName);
if (!string.Equals(normalised, location.LocationName, StringComparison.OrdinalIgnoreCase))
{
rows.Add(new EntityIndexRow(location.LocationID, location.LocationName, normalised, "Normalised name"));
}
foreach (var alias in await locations.ListAliasesAsync(location.LocationID))
{
rows.Add(new EntityIndexRow(location.LocationID, location.LocationName, alias.Alias, "Alias"));
var normalisedAlias = NormaliseLocationName(alias.Alias);
if (!string.Equals(normalisedAlias, alias.Alias, StringComparison.OrdinalIgnoreCase))
{
rows.Add(new EntityIndexRow(location.LocationID, location.LocationName, normalisedAlias, "Normalised alias"));
}
}
}
return new EntityIndex(rows);
}
private async Task<EntityIndex> BuildAssetIndexAsync(int projectId)
{
var rows = new List<EntityIndexRow>();
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} Notes={Notes}",
resolution.EntityType,
resolution.InputName,
resolution.Status,
resolution.MatchedName,
resolution.ConfidencePercent,
string.Join(", ", resolution.Candidates.Select(candidate => candidate.Name)),
resolution.Notes);
return resolution;
}
private static IEnumerable<string> BuildWarnings(
IReadOnlyList<StoryImportEntityResolution> characterResolutions,
IReadOnlyList<StoryImportEntityResolution> locationResolutions,
IReadOnlyList<StoryImportEntityResolution> 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<SceneIntelligenceScene>(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<SceneIntelligenceScene>(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 bool IsGenericPerson(string name)
{
var clean = Clean(name).ToLowerInvariant();
return GenericPeople.Contains(clean)
|| clean.StartsWith("the ", StringComparison.OrdinalIgnoreCase) && GenericPeople.Contains(clean[4..])
|| clean.Contains(" group", StringComparison.OrdinalIgnoreCase)
|| clean.Contains("crowd", StringComparison.OrdinalIgnoreCase);
}
private static bool IsRejectedAsset(SceneIntelligenceAsset asset)
{
var name = Clean(asset.Name).ToLowerInvariant();
if (EnvironmentalAssets.Contains(name))
{
return true;
}
if (StoryAssetNames.Contains(RemoveLeadingArticle(name)))
{
return false;
}
var signalText = $"{asset.Status} {asset.OwnerOrHolder} {asset.Notes} {asset.AssetType}".ToLowerInvariant();
return !ContainsStoryAssetSignal(signalText);
}
private static bool ContainsStoryAssetSignal(string value)
=> value.Contains("sought", StringComparison.OrdinalIgnoreCase)
|| value.Contains("lost", StringComparison.OrdinalIgnoreCase)
|| value.Contains("hidden", StringComparison.OrdinalIgnoreCase)
|| value.Contains("stolen", StringComparison.OrdinalIgnoreCase)
|| value.Contains("exchanged", StringComparison.OrdinalIgnoreCase)
|| value.Contains("missing", StringComparison.OrdinalIgnoreCase)
|| value.Contains("important", StringComparison.OrdinalIgnoreCase)
|| value.Contains("document", StringComparison.OrdinalIgnoreCase)
|| value.Contains("evidence", StringComparison.OrdinalIgnoreCase);
private static string NormaliseLocationName(string? value)
{
var clean = RemoveLeadingArticle(Clean(value));
var lower = clean.ToLowerInvariant();
var suffixes = new[]
{
" sixth floor reception", " sixth-floor reception", " reception", " sixth floor", " office", " corridor", " stairwell"
};
foreach (var suffix in suffixes)
{
if (lower.EndsWith(suffix, StringComparison.OrdinalIgnoreCase) && clean.Length > suffix.Length)
{
return clean[..^suffix.Length].Trim();
}
}
return clean;
}
private static string Clean(string? value)
=> string.Join(' ', (value ?? string.Empty).Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
private static string RemoveLeadingArticle(string value)
=> value.StartsWith("the ", StringComparison.OrdinalIgnoreCase) ? value[4..].Trim() : value;
private static bool NamesMatch(string? first, string? second)
=> string.Equals(Clean(first), Clean(second), StringComparison.OrdinalIgnoreCase);
private static string RelationshipKey(string characterA, string characterB, string? signal)
{
var names = new[] { Clean(characterA), Clean(characterB) }.Order(StringComparer.OrdinalIgnoreCase).ToArray();
return $"{names[0]}|{names[1]}|{Clean(signal).ToLowerInvariant()}";
}
private static HashSet<string> 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<EntityIndexRow> rows)
{
public IReadOnlyList<StoryImportResolutionCandidate> Find(string value)
{
var clean = Clean(value);
var withoutArticle = RemoveLeadingArticle(clean);
var matches = rows
.Where(row => string.Equals(Clean(row.MatchValue), clean, StringComparison.Ordinal)
|| string.Equals(Clean(row.MatchValue), clean, StringComparison.OrdinalIgnoreCase)
|| string.Equals(RemoveLeadingArticle(Clean(row.MatchValue)), withoutArticle, 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;
}
}
}