PlotDirector/PlotLine/Services/SceneImportResolver.cs

385 lines
17 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", "boy", "girl", "child", "person", "guard", "waiter", "waitress", "clerk", "shopkeeper");
private static readonly HashSet<string> IgnoredPovNames = CreateSet("narrator", "unknown");
private static readonly HashSet<string> 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<string> GenericAssets = CreateSet(
"the key", "key", "the letter", "letter", "the car", "car", "phone", "table", "chair", "door", "window");
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).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 (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<StoryImportEntityResolution> ResolveLocations(IEnumerable<SceneIntelligenceScene> 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<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 (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<StoryImportRelationshipPreview> ResolveRelationships(IEnumerable<SceneIntelligenceScene> scenes, IReadOnlyList<CharacterRelationship> 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<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 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"));
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<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}",
resolution.EntityType,
resolution.InputName,
resolution.Status,
resolution.MatchedName,
resolution.ConfidencePercent,
string.Join(", ", resolution.Candidates.Select(candidate => candidate.Name)));
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 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<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 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;
}
}
}