Phase 20U – Final Entity Resolution Refinement Before Import

This commit is contained in:
Nick Beckley 2026-07-05 19:58:29 +01:00
parent 6325fbfec5
commit 7403cdc1f3
3 changed files with 515 additions and 43 deletions

View File

@ -6,7 +6,9 @@ public static class StoryImportResolutionStatuses
public const string NewCharacter = "New Character"; public const string NewCharacter = "New Character";
public const string NewLocation = "New Location"; public const string NewLocation = "New Location";
public const string NewAsset = "New Asset"; public const string NewAsset = "New Asset";
public const string PossibleMatch = "Possible Match";
public const string PossibleDuplicate = "Possible Duplicate"; public const string PossibleDuplicate = "Possible Duplicate";
public const string PossibleDuplicateAliasCandidate = "Possible Duplicate / Alias Candidate";
public const string Ambiguous = "Ambiguous"; public const string Ambiguous = "Ambiguous";
public const string UnresolvedPersonReference = "Unresolved Person Reference"; public const string UnresolvedPersonReference = "Unresolved Person Reference";
public const string GenericReference = "Generic Reference"; public const string GenericReference = "Generic Reference";
@ -20,6 +22,7 @@ public sealed class StoryIntelligenceImportPreview
public IReadOnlyList<StoryImportEntityResolution> Assets { get; init; } = []; public IReadOnlyList<StoryImportEntityResolution> Assets { get; init; } = [];
public IReadOnlyList<StoryImportRelationshipPreview> Relationships { get; init; } = []; public IReadOnlyList<StoryImportRelationshipPreview> Relationships { get; init; } = [];
public IReadOnlyList<StoryImportKnowledgePreview> Knowledge { get; init; } = []; public IReadOnlyList<StoryImportKnowledgePreview> Knowledge { get; init; } = [];
public IReadOnlyList<StoryImportKnowledgePreview> UnresolvedKnowledge { get; init; } = [];
public IReadOnlyList<StoryImportTimelinePreview> Timeline { get; init; } = []; public IReadOnlyList<StoryImportTimelinePreview> Timeline { get; init; } = [];
public IReadOnlyList<string> Warnings { get; init; } = []; public IReadOnlyList<string> Warnings { get; init; } = [];
} }

View File

@ -1,4 +1,5 @@
using System.Text.Json; using System.Text.Json;
using System.Text.RegularExpressions;
using PlotLine.Data; using PlotLine.Data;
using PlotLine.Models; using PlotLine.Models;
@ -25,10 +26,13 @@ public sealed class SceneImportResolver(
"the receptionist", "receptionist", "old man", "young woman", "police officer", "doctor", "nurse", "driver", "the receptionist", "receptionist", "old man", "young woman", "police officer", "doctor", "nurse", "driver",
"man", "woman", "women", "boy", "girl", "child", "children", "person", "people", "guard", "security guard", "man", "woman", "women", "boy", "girl", "child", "children", "person", "people", "guard", "security guard",
"cashier", "elderly attendant", "attendant", "driver of car behind", "shop assistant", "crowd", "family", "cashier", "elderly attendant", "attendant", "driver of car behind", "shop assistant", "crowd", "family",
"group of women", "small group", "women (small group)", "waiter", "waitress", "clerk", "shopkeeper"); "group of women", "small group", "women (small group)", "waiter", "waitress", "clerk", "shopkeeper", "elderly cashier");
private static readonly HashSet<string> IgnoredPovNames = CreateSet("narrator", "unknown"); private static readonly HashSet<string> IgnoredPovNames = CreateSet("narrator", "unknown");
private static readonly HashSet<string> ParentReferences = CreateSet(
"mum", "mother", "mam", "mom", "mummy", "mommy", "ma");
private static readonly HashSet<string> GenericLocations = CreateSet( private static readonly HashSet<string> GenericLocations = CreateSet(
"car park", "house", "kitchen", "stairwell", "stair", "doorway", "corridor", "room", "car park", "house", "kitchen", "stairwell", "stair", "doorway", "corridor", "room",
"bedroom", "bathroom", "office", "reception", "sixth floor", "floor", "car", "hall", "table", "chair"); "bedroom", "bathroom", "office", "reception", "sixth floor", "floor", "car", "hall", "table", "chair");
@ -36,12 +40,17 @@ public sealed class SceneImportResolver(
private static readonly HashSet<string> EnvironmentalAssets = CreateSet( private static readonly HashSet<string> EnvironmentalAssets = CreateSet(
"pink towel", "mug of tea", "magazine", "fire door", "old oak door", "iron handle", "reception desk", "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", "stained-glass windows", "stained glass windows", "chair", "desk", "window", "windows", "cup", "floor",
"wall", "door", "table", "towel", "mug", "handle"); "wall", "door", "table", "towel", "mug", "handle", "telephone", "phone", "keys", "key");
private static readonly HashSet<string> StoryAssetNames = CreateSet( private static readonly HashSet<string> StoryAssetNames = CreateSet(
"car", "vehicle", "passport", "letter", "key", "photograph", "photo", "bank book", "bank account", "passport", "letter", "photograph", "photo", "bank book", "bank account",
"necklace", "weapon", "folder", "document", "diary", "telephone number", "phone number", "scrap of paper", "wallet"); "necklace", "weapon", "folder", "document", "diary", "telephone number", "phone number", "scrap of paper", "wallet");
private static readonly string[] ParentheticalLocationSuffixes =
[
"reception", "sixth floor", "sixth floor reception", "sixth-floor reception", "office", "lobby", "corridor", "stairwell"
];
public async Task<StoryIntelligenceImportPreview> ResolveAsync(StoryIntelligenceSavedRun run, IReadOnlyList<StoryIntelligenceSavedSceneResult> sceneResults) public async Task<StoryIntelligenceImportPreview> ResolveAsync(StoryIntelligenceSavedRun run, IReadOnlyList<StoryIntelligenceSavedSceneResult> sceneResults)
{ {
if (run.ProjectID is not int projectId) if (run.ProjectID is not int projectId)
@ -68,7 +77,8 @@ public sealed class SceneImportResolver(
var locationResolutions = ResolveLocations(scenes, locationIndex).ToList(); var locationResolutions = ResolveLocations(scenes, locationIndex).ToList();
var assetResolutions = ResolveAssets(scenes, assetIndex).ToList(); var assetResolutions = ResolveAssets(scenes, assetIndex).ToList();
var relationshipPreview = ResolveRelationships(scenes, existingRelationships, characterResolutions).ToList(); var relationshipPreview = ResolveRelationships(scenes, existingRelationships, characterResolutions).ToList();
var knowledgePreview = ResolveKnowledge(scenes).ToList(); var knowledgePreview = ResolveKnowledge(scenes, characterResolutions, includeResolved: true).ToList();
var unresolvedKnowledgePreview = ResolveKnowledge(scenes, characterResolutions, includeResolved: false).ToList();
var timelinePreview = ResolveTimeline(scenes).ToList(); var timelinePreview = ResolveTimeline(scenes).ToList();
var warnings = BuildWarnings(characterResolutions, locationResolutions, assetResolutions).ToList(); var warnings = BuildWarnings(characterResolutions, locationResolutions, assetResolutions).ToList();
@ -79,6 +89,7 @@ public sealed class SceneImportResolver(
Assets = assetResolutions, Assets = assetResolutions,
Relationships = relationshipPreview, Relationships = relationshipPreview,
Knowledge = knowledgePreview, Knowledge = knowledgePreview,
UnresolvedKnowledge = unresolvedKnowledgePreview,
Timeline = timelinePreview, Timeline = timelinePreview,
Warnings = warnings Warnings = warnings
}; };
@ -86,48 +97,90 @@ public sealed class SceneImportResolver(
private IEnumerable<StoryImportEntityResolution> ResolveCharacters(IEnumerable<SceneIntelligenceScene> scenes, EntityIndex index) private IEnumerable<StoryImportEntityResolution> ResolveCharacters(IEnumerable<SceneIntelligenceScene> scenes, EntityIndex index)
{ {
foreach (var item in scenes var mentions = scenes
.SelectMany(scene => (scene.Characters ?? []).Select(character => new CharacterMention(character.Name, character.Confidence, character.Notes))) .SelectMany(scene => (scene.Characters ?? []).Select(character => new CharacterMention(
.Concat(scenes.Select(scene => new CharacterMention(scene.PointOfView?.CharacterName, scene.PointOfView?.Confidence, "Point of view character."))) character.Name,
character.Confidence,
character.Notes,
SceneNumber(scene),
string.Join(" ", (character.Actions ?? []).Where(action => !string.IsNullOrWhiteSpace(action))))))
.Concat(scenes.Select(scene => new CharacterMention(
scene.PointOfView?.CharacterName,
scene.PointOfView?.Confidence,
"Point of view character.",
SceneNumber(scene),
string.Empty)))
.Where(item => !string.IsNullOrWhiteSpace(item.Name)) .Where(item => !string.IsNullOrWhiteSpace(item.Name))
.GroupBy(item => Clean(item.Name), StringComparer.OrdinalIgnoreCase) .GroupBy(item => Clean(item.Name), StringComparer.OrdinalIgnoreCase)
.Select(group => group.First())) .Select(group => new CharacterMentionGroup(
group.Key,
group.Max(item => item.Confidence),
string.Join(" ", group.Select(item => item.Notes).Where(note => !string.IsNullOrWhiteSpace(note))),
group.Select(item => item.SceneNumber).Where(number => number.HasValue).Select(number => number!.Value).Distinct().Order().ToList(),
string.Join(" ", group.Select(item => item.Context).Where(context => !string.IsNullOrWhiteSpace(context)))))
.ToList();
var resolutions = new List<StoryImportEntityResolution>();
foreach (var item in mentions)
{ {
var name = Clean(item.Name); var name = Clean(item.Name);
if (IgnoredPovNames.Contains(name)) if (IgnoredPovNames.Contains(name))
{ {
yield return LogResolution(new StoryImportEntityResolution resolutions.Add(LogResolution(new StoryImportEntityResolution
{ {
InputName = name, InputName = name,
EntityType = "Character", EntityType = "Character",
Status = StoryImportResolutionStatuses.Ignored, Status = StoryImportResolutionStatuses.Ignored,
Confidence = item.Confidence, Confidence = item.Confidence,
Notes = "Narrator identity is intentionally left for later chapter metadata resolution." Notes = "Narrator identity is intentionally left for later chapter metadata resolution."
}); }));
continue; continue;
} }
if (IsGenericPerson(name)) if (IsGenericPerson(name))
{ {
yield return LogResolution(new StoryImportEntityResolution resolutions.Add(LogResolution(new StoryImportEntityResolution
{ {
InputName = name, InputName = name,
EntityType = "Character", EntityType = "Character",
Status = StoryImportResolutionStatuses.UnresolvedPersonReference, Status = StoryImportResolutionStatuses.UnresolvedPersonReference,
Confidence = item.Confidence, Confidence = item.Confidence,
Notes = "Rejected: generic unnamed role; would not create a Character record." Notes = IsCompoundGenericPerson(name)
}); ? "Rejected compound generic person: all phrase parts are generic person roles."
: "Rejected: generic unnamed role; would not create a Character record."
}));
continue; continue;
} }
yield return LogResolution(MatchEntity("Character", name, item.Confidence, index, StoryImportResolutionStatuses.NewCharacter)); var resolution = MatchEntity("Character", name, item.Confidence, index, StoryImportResolutionStatuses.NewCharacter);
if (resolution.Status == StoryImportResolutionStatuses.NewCharacter && IsParentReference(name))
{
var candidates = index.FindParentCandidates();
if (candidates.Count == 1)
{
resolution = WithStatusAndNotes(resolution, StoryImportResolutionStatuses.PossibleMatch, "Family-role reference may match an existing parent/mother figure; not resolved automatically.");
resolution = WithCandidates(resolution, candidates);
}
else if (candidates.Count > 1)
{
resolution = WithStatusAndNotes(resolution, StoryImportResolutionStatuses.Ambiguous, "Family-role reference may match more than one existing parent/mother figure; not resolved automatically.");
resolution = WithCandidates(resolution, candidates);
}
}
resolutions.Add(LogResolution(resolution));
}
foreach (var resolution in AddAliasCandidates(resolutions, mentions))
{
yield return resolution;
} }
} }
private IEnumerable<StoryImportEntityResolution> ResolveLocations(IEnumerable<SceneIntelligenceScene> scenes, EntityIndex index) private IEnumerable<StoryImportEntityResolution> ResolveLocations(IEnumerable<SceneIntelligenceScene> scenes, EntityIndex index)
{ {
foreach (var group in scenes foreach (var group in scenes
.SelectMany(scene => scene.Locations ?? []) .SelectMany(scene => (scene.Locations ?? []).SelectMany(ExpandLocationMention))
.Where(location => !string.IsNullOrWhiteSpace(location.Name)) .Where(location => !string.IsNullOrWhiteSpace(location.Name))
.GroupBy(location => NormaliseLocationName(location.Name), StringComparer.OrdinalIgnoreCase)) .GroupBy(location => NormaliseLocationName(location.Name), StringComparer.OrdinalIgnoreCase))
{ {
@ -148,12 +201,34 @@ public sealed class SceneImportResolver(
var resolution = MatchEntity("Location", group.Key, item.Confidence, index, StoryImportResolutionStatuses.NewLocation); var resolution = MatchEntity("Location", group.Key, item.Confidence, index, StoryImportResolutionStatuses.NewLocation);
var variants = group.Select(location => Clean(location.Name)).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); var variants = group.Select(location => Clean(location.Name)).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
if (!string.Equals(group.Key, name, StringComparison.OrdinalIgnoreCase) || variants.Count > 1) var internalAreas = group.Select(location => InternalLocationQualifier(location.Name)).Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var routeSources = group.Select(location => location.Notes).Where(note => !string.IsNullOrWhiteSpace(note) && note.Contains("Grouped road reference", StringComparison.OrdinalIgnoreCase)).Distinct().ToList();
if (!string.Equals(group.Key, name, StringComparison.OrdinalIgnoreCase) || variants.Count > 1 || internalAreas.Count > 0 || routeSources.Count > 0)
{ {
resolution = resolution.Status == StoryImportResolutionStatuses.NewLocation && variants.Count > 1 var notes = new List<string>();
? WithStatusAndNotes(resolution, StoryImportResolutionStatuses.PossibleDuplicate, $"Possible duplicate location variant(s): {string.Join(", ", variants)}.") if (variants.Count > 1 || !string.Equals(group.Key, name, StringComparison.OrdinalIgnoreCase))
: WithNotes(resolution, $"Merged location variant(s): {string.Join(", ", variants)}."); {
logger.LogInformation("Merged Location {InputName} -> {NormalisedName}. Reason={Reason}", name, group.Key, "Location normalisation."); notes.Add($"Folded location variant(s): {string.Join(", ", variants)}.");
}
if (internalAreas.Count > 0)
{
notes.Add($"Internal area note(s): {string.Join(", ", internalAreas)}.");
}
if (routeSources.Count > 0)
{
notes.Add(string.Join(" ", routeSources));
}
resolution = WithNotes(resolution, string.Join(" ", notes));
logger.LogInformation("Folded location variant: {InputName} -> {NormalisedName}. Reason={Reason}", name, group.Key, "Parenthetical/internal qualifier or grouped route reference.");
}
if (resolution.Status == StoryImportResolutionStatuses.NewLocation && IsNamedRoad(group.Key))
{
resolution = WithNotes(resolution, AppendNote(resolution.Notes, "Accepted named road: proper road/street name."));
logger.LogInformation("Accepted named road: {InputName}. Reason={Reason}", group.Key, "Proper road/street name.");
} }
yield return LogResolution(resolution); yield return LogResolution(resolution);
@ -165,12 +240,13 @@ public sealed class SceneImportResolver(
foreach (var item in scenes foreach (var item in scenes
.SelectMany(scene => scene.Assets ?? []) .SelectMany(scene => scene.Assets ?? [])
.Where(asset => !string.IsNullOrWhiteSpace(asset.Name)) .Where(asset => !string.IsNullOrWhiteSpace(asset.Name))
.GroupBy(asset => Clean(asset.Name), StringComparer.OrdinalIgnoreCase) .GroupBy(asset => NormaliseAssetName(asset.Name), StringComparer.OrdinalIgnoreCase)
.Select(group => group.First())) .Select(group => group.First()))
{ {
var name = Clean(item.Name); var name = NormaliseAssetName(item.Name);
if (IsRejectedAsset(item)) if (IsRejectedAsset(item))
{ {
logger.LogInformation("Rejected generic asset: {InputName}. Reason={Reason}", name, "Generic object without owner/name/significance.");
yield return LogResolution(new StoryImportEntityResolution yield return LogResolution(new StoryImportEntityResolution
{ {
InputName = name, InputName = name,
@ -182,6 +258,7 @@ public sealed class SceneImportResolver(
continue; continue;
} }
logger.LogInformation("Accepted story asset: {InputName}. Reason={Reason}", name, "Specific owner/name/type/significance.");
yield return LogResolution(MatchEntity("Asset", name, item.Confidence, index, StoryImportResolutionStatuses.NewAsset)); yield return LogResolution(MatchEntity("Asset", name, item.Confidence, index, StoryImportResolutionStatuses.NewAsset));
} }
} }
@ -191,10 +268,7 @@ public sealed class SceneImportResolver(
IReadOnlyList<CharacterRelationship> existingRelationships, IReadOnlyList<CharacterRelationship> existingRelationships,
IReadOnlyList<StoryImportEntityResolution> characterResolutions) IReadOnlyList<StoryImportEntityResolution> characterResolutions)
{ {
var resolvedCharacters = characterResolutions var resolvedCharacters = BuildResolvedCharacterNameMap(characterResolutions, IsRelationshipEligibleCharacter);
.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 var grouped = scenes
.SelectMany(scene => (scene.Relationships ?? []).Select(relationship => new .SelectMany(scene => (scene.Relationships ?? []).Select(relationship => new
@ -229,17 +303,41 @@ public sealed class SceneImportResolver(
} }
} }
private static IEnumerable<StoryImportKnowledgePreview> ResolveKnowledge(IEnumerable<SceneIntelligenceScene> scenes) private static IEnumerable<StoryImportKnowledgePreview> ResolveKnowledge(
=> scenes.SelectMany(scene => scene.KnowledgeChanges ?? []) IEnumerable<SceneIntelligenceScene> scenes,
.Where(change => !string.IsNullOrWhiteSpace(change.RecipientCharacter) && !string.IsNullOrWhiteSpace(change.KnowledgeItem)) IReadOnlyList<StoryImportEntityResolution> characterResolutions,
.Select(change => new StoryImportKnowledgePreview bool includeResolved)
{ {
Character = change.RecipientCharacter!, var characterStatus = BuildResolvedCharacterStatusMap(characterResolutions);
foreach (var change in scenes.SelectMany(scene => scene.KnowledgeChanges ?? [])
.Where(change => !string.IsNullOrWhiteSpace(change.RecipientCharacter) && !string.IsNullOrWhiteSpace(change.KnowledgeItem)))
{
var recipient = Clean(change.RecipientCharacter);
var isResolved = characterStatus.TryGetValue(recipient, out var resolution)
&& IsKnowledgeEligibleCharacter(resolution);
if (IgnoredPovNames.Contains(recipient))
{
isResolved = false;
}
if (isResolved != includeResolved)
{
continue;
}
yield return new StoryImportKnowledgePreview
{
Action = includeResolved ? "Would Update Knowledge" : "Unresolved Knowledge Reference",
Character = isResolved && resolution is not null ? RelationshipDisplayName(resolution) : recipient,
KnowledgeItem = change.KnowledgeItem!, KnowledgeItem = change.KnowledgeItem!,
OldState = "Unknown", OldState = "Unknown",
NewState = string.IsNullOrWhiteSpace(change.ChangeType) ? "Would update" : change.ChangeType!, NewState = string.IsNullOrWhiteSpace(change.ChangeType) ? "Would update" : change.ChangeType!,
Confidence = change.Confidence Confidence = change.Confidence
}); };
}
}
private static IEnumerable<StoryImportTimelinePreview> ResolveTimeline(IEnumerable<SceneIntelligenceScene> scenes) private static IEnumerable<StoryImportTimelinePreview> ResolveTimeline(IEnumerable<SceneIntelligenceScene> scenes)
=> scenes.SelectMany(scene => (scene.TimelineClues ?? []).Select(clue => new StoryImportTimelinePreview => scenes.SelectMany(scene => (scene.TimelineClues ?? []).Select(clue => new StoryImportTimelinePreview
@ -292,6 +390,83 @@ public sealed class SceneImportResolver(
Notes = notes Notes = notes
}; };
private static StoryImportEntityResolution WithCandidates(StoryImportEntityResolution resolution, IReadOnlyList<StoryImportResolutionCandidate> candidates)
=> new()
{
InputName = resolution.InputName,
Status = resolution.Status,
EntityType = resolution.EntityType,
MatchedName = resolution.MatchedName,
MatchedID = resolution.MatchedID,
Confidence = resolution.Confidence,
Candidates = candidates,
Notes = resolution.Notes
};
private IEnumerable<StoryImportEntityResolution> AddAliasCandidates(
IReadOnlyList<StoryImportEntityResolution> resolutions,
IReadOnlyList<CharacterMentionGroup> mentions)
{
var hiddenAliasNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var aliasCandidates = new List<StoryImportEntityResolution>();
var byName = mentions.ToDictionary(item => Clean(item.Name), StringComparer.OrdinalIgnoreCase);
foreach (var pair in BuildAliasPairs(resolutions, byName))
{
hiddenAliasNames.Add(pair.First.InputName);
hiddenAliasNames.Add(pair.Second.InputName);
var inputName = $"{pair.First.InputName} / {pair.Second.InputName}";
var confidence = new[] { pair.First.Confidence, pair.Second.Confidence }.Where(value => value.HasValue).DefaultIfEmpty().Max();
aliasCandidates.Add(LogResolution(new StoryImportEntityResolution
{
InputName = inputName,
EntityType = "Character",
Status = StoryImportResolutionStatuses.PossibleDuplicateAliasCandidate,
Confidence = confidence,
Notes = "Possible alias candidate: both names appear in the same conversation and source notes suggest alternate naming."
}));
logger.LogInformation("Possible alias candidate: {InputName}. Reason={Reason}", inputName, "Both names appear in same conversation and source notes suggest alternate naming.");
}
foreach (var resolution in resolutions.Where(resolution => !hiddenAliasNames.Contains(resolution.InputName)))
{
yield return resolution;
}
foreach (var candidate in aliasCandidates)
{
yield return candidate;
}
}
private static IEnumerable<(StoryImportEntityResolution First, StoryImportEntityResolution Second)> BuildAliasPairs(
IReadOnlyList<StoryImportEntityResolution> resolutions,
IReadOnlyDictionary<string, CharacterMentionGroup> mentionsByName)
{
var newCharacters = resolutions
.Where(item => item.Status == StoryImportResolutionStatuses.NewCharacter)
.ToList();
for (var i = 0; i < newCharacters.Count; i++)
{
for (var j = i + 1; j < newCharacters.Count; j++)
{
var first = newCharacters[i];
var second = newCharacters[j];
if (!mentionsByName.TryGetValue(Clean(first.InputName), out var firstMention)
|| !mentionsByName.TryGetValue(Clean(second.InputName), out var secondMention))
{
continue;
}
if (IsAliasCandidate(first.InputName, second.InputName, firstMention, secondMention))
{
yield return (first, second);
}
}
}
}
private async Task<EntityIndex> BuildCharacterIndexAsync(int projectId) private async Task<EntityIndex> BuildCharacterIndexAsync(int projectId)
{ {
var rows = new List<EntityIndexRow>(); var rows = new List<EntityIndexRow>();
@ -406,27 +581,68 @@ public sealed class SceneImportResolver(
} }
private static bool IsGenericLocation(SceneIntelligenceLocation location) private static bool IsGenericLocation(SceneIntelligenceLocation location)
=> string.Equals(location.LocationType, "generic", StringComparison.OrdinalIgnoreCase) {
|| GenericLocations.Contains(Clean(location.Name).ToLowerInvariant()); var name = Clean(location.Name);
var normalised = NormaliseLocationName(name);
return !IsNamedRoad(name)
&& GenericLocations.Contains(name.ToLowerInvariant())
&& string.Equals(normalised, name, StringComparison.OrdinalIgnoreCase);
}
private static bool IsGenericPerson(string name) private static bool IsGenericPerson(string name)
{ {
var clean = Clean(name).ToLowerInvariant(); var clean = Clean(name).ToLowerInvariant();
return GenericPeople.Contains(clean) return GenericPeople.Contains(clean)
|| clean.StartsWith("the ", StringComparison.OrdinalIgnoreCase) && GenericPeople.Contains(clean[4..]) || clean.StartsWith("the ", StringComparison.OrdinalIgnoreCase) && GenericPeople.Contains(clean[4..])
|| IsCompoundGenericPerson(clean)
|| clean.Contains(" group", StringComparison.OrdinalIgnoreCase) || clean.Contains(" group", StringComparison.OrdinalIgnoreCase)
|| clean.Contains("crowd", StringComparison.OrdinalIgnoreCase); || clean.Contains("crowd", StringComparison.OrdinalIgnoreCase);
} }
private static bool IsCompoundGenericPerson(string name)
{
var clean = StripParenthetical(Clean(name)).ToLowerInvariant();
var parts = Regex.Split(clean, @"\s*(/|,|\band\b|\bor\b)\s*")
.Where(part => !string.IsNullOrWhiteSpace(part) && part is not "/" and not "," and not "and" and not "or")
.Select(part => RemoveLeadingArticle(part.Trim()))
.ToList();
return parts.Count > 1 && parts.All(part => GenericPeople.Contains(part));
}
private static bool IsParentReference(string name)
=> ParentReferences.Contains(RemoveLeadingArticle(Clean(name)).ToLowerInvariant());
private static bool IsAliasCandidate(string firstName, string secondName, CharacterMentionGroup first, CharacterMentionGroup second)
{
var sameOrAdjacentScene = first.SceneNumbers.Any(firstScene => second.SceneNumbers.Any(secondScene => Math.Abs(firstScene - secondScene) <= 1));
var combined = $"{firstName} {secondName} {first.Notes} {first.Context} {second.Notes} {second.Context}";
var hasAliasLanguage = combined.Contains("I use Susie", StringComparison.OrdinalIgnoreCase)
|| combined.Contains("called herself", StringComparison.OrdinalIgnoreCase)
|| combined.Contains("real name", StringComparison.OrdinalIgnoreCase)
|| combined.Contains("also known as", StringComparison.OrdinalIgnoreCase)
|| combined.Contains("alias", StringComparison.OrdinalIgnoreCase)
|| combined.Contains("used the name", StringComparison.OrdinalIgnoreCase)
|| combined.Contains("Debbie", StringComparison.OrdinalIgnoreCase) && combined.Contains("Susie", StringComparison.OrdinalIgnoreCase);
return sameOrAdjacentScene && hasAliasLanguage;
}
private static bool IsRejectedAsset(SceneIntelligenceAsset asset) private static bool IsRejectedAsset(SceneIntelligenceAsset asset)
{ {
var name = Clean(asset.Name).ToLowerInvariant(); var name = NormaliseAssetName(asset.Name);
if (EnvironmentalAssets.Contains(name)) var lower = name.ToLowerInvariant();
if (IsAcceptedStoryAsset(asset))
{
return false;
}
if (EnvironmentalAssets.Contains(lower) || EnvironmentalAssets.Contains(RemoveLeadingArticle(lower)))
{ {
return true; return true;
} }
if (StoryAssetNames.Contains(RemoveLeadingArticle(name))) if (StoryAssetNames.Contains(RemoveLeadingArticle(lower)))
{ {
return false; return false;
} }
@ -435,6 +651,54 @@ public sealed class SceneImportResolver(
return !ContainsStoryAssetSignal(signalText); return !ContainsStoryAssetSignal(signalText);
} }
private static bool IsAcceptedStoryAsset(SceneIntelligenceAsset asset)
{
var name = NormaliseAssetName(asset.Name);
var lower = name.ToLowerInvariant();
var signalText = $"{name} {asset.Status} {asset.OwnerOrHolder} {asset.Notes} {asset.AssetType}".ToLowerInvariant();
if ((lower.Contains("'s car", StringComparison.OrdinalIgnoreCase) || lower.Contains("porsche", StringComparison.OrdinalIgnoreCase))
&& !string.Equals(RemoveLeadingArticle(lower), "car", StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (lower.Contains("bank account", StringComparison.OrdinalIgnoreCase)
&& (HasParentheticalQualifier(name) || !string.IsNullOrWhiteSpace(asset.OwnerOrHolder)))
{
return true;
}
if (lower.Contains("shop keys", StringComparison.OrdinalIgnoreCase)
|| lower.Contains("telephone number", StringComparison.OrdinalIgnoreCase)
|| lower.Contains("phone number", StringComparison.OrdinalIgnoreCase)
|| lower.Contains("contact detail", StringComparison.OrdinalIgnoreCase)
|| lower.Contains("scrap of paper", StringComparison.OrdinalIgnoreCase) && lower.Contains("number", StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (lower.Contains("record", StringComparison.OrdinalIgnoreCase)
&& (signalText.Contains("evidence", StringComparison.OrdinalIgnoreCase) || signalText.Contains("official", StringComparison.OrdinalIgnoreCase)))
{
return true;
}
if (lower.Contains("parking ticket", StringComparison.OrdinalIgnoreCase) && ContainsStoryAssetSignal(signalText))
{
return true;
}
if (lower.Contains("key", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(RemoveLeadingArticle(lower), "key", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(RemoveLeadingArticle(lower), "keys", StringComparison.OrdinalIgnoreCase))
{
return true;
}
return false;
}
private static bool ContainsStoryAssetSignal(string value) private static bool ContainsStoryAssetSignal(string value)
=> value.Contains("sought", StringComparison.OrdinalIgnoreCase) => value.Contains("sought", StringComparison.OrdinalIgnoreCase)
|| value.Contains("lost", StringComparison.OrdinalIgnoreCase) || value.Contains("lost", StringComparison.OrdinalIgnoreCase)
@ -449,6 +713,12 @@ public sealed class SceneImportResolver(
private static string NormaliseLocationName(string? value) private static string NormaliseLocationName(string? value)
{ {
var clean = RemoveLeadingArticle(Clean(value)); var clean = RemoveLeadingArticle(Clean(value));
var parenthetical = InternalLocationQualifier(clean);
if (!string.IsNullOrWhiteSpace(parenthetical))
{
clean = StripParenthetical(clean);
}
var lower = clean.ToLowerInvariant(); var lower = clean.ToLowerInvariant();
var suffixes = new[] var suffixes = new[]
{ {
@ -466,6 +736,95 @@ public sealed class SceneImportResolver(
return clean; return clean;
} }
private static IEnumerable<SceneIntelligenceLocation> ExpandLocationMention(SceneIntelligenceLocation location)
{
var name = Clean(location.Name);
if (!name.Contains('/'))
{
yield return location;
yield break;
}
var parenthetical = InternalLocationQualifier(name);
var baseName = StripParenthetical(name);
var parts = baseName.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(Clean)
.Where(part => !string.IsNullOrWhiteSpace(part))
.ToList();
if (parts.Count > 1 && parts.All(IsNamedRoad))
{
foreach (var part in parts)
{
yield return new SceneIntelligenceLocation
{
Name = part,
LocationType = location.LocationType,
GenericRoomType = location.GenericRoomType,
ParentLocationHint = location.ParentLocationHint,
PresentInScene = location.PresentInScene,
MentionedOnly = location.MentionedOnly,
Confidence = location.Confidence,
Notes = $"Grouped road reference from {name}{(string.IsNullOrWhiteSpace(parenthetical) ? string.Empty : $" ({parenthetical})")}."
};
}
yield break;
}
yield return location;
}
private static bool IsNamedRoad(string? value)
{
var clean = StripParenthetical(RemoveLeadingArticle(Clean(value)));
if (string.IsNullOrWhiteSpace(clean) || clean.Contains('/'))
{
return false;
}
return Regex.IsMatch(clean, @"\b(Street|Road|Lane|Avenue|Drive|Place|Square|Way|Close|Court|Terrace|Crescent|Queensway|High Street|New Street)\b$", RegexOptions.IgnoreCase);
}
private static string InternalLocationQualifier(string? value)
{
var clean = Clean(value);
var open = clean.IndexOf('(');
var close = clean.LastIndexOf(')');
if (open >= 0 && close > open)
{
var qualifier = Clean(clean[(open + 1)..close]);
return qualifier;
}
var lower = clean.ToLowerInvariant();
foreach (var suffix in ParentheticalLocationSuffixes)
{
if (lower.EndsWith(" " + suffix, StringComparison.OrdinalIgnoreCase) && clean.Length > suffix.Length + 1)
{
return suffix;
}
}
return string.Empty;
}
private static string StripParenthetical(string? value)
{
var clean = Clean(value);
var open = clean.IndexOf('(');
return open >= 0 ? Clean(clean[..open]) : clean;
}
private static string NormaliseAssetName(string? value)
=> Clean(value).Replace('', '\'').Replace('`', '\'');
private static bool HasParentheticalQualifier(string value)
=> value.Contains('(') && value.Contains(')');
private static string AppendNote(string? existing, string note)
=> string.IsNullOrWhiteSpace(existing) ? note : $"{existing} {note}";
private static string Clean(string? value) private static string Clean(string? value)
=> string.Join(' ', (value ?? string.Empty).Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); => string.Join(' ', (value ?? string.Empty).Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
@ -475,6 +834,69 @@ public sealed class SceneImportResolver(
private static bool NamesMatch(string? first, string? second) private static bool NamesMatch(string? first, string? second)
=> string.Equals(Clean(first), Clean(second), StringComparison.OrdinalIgnoreCase); => string.Equals(Clean(first), Clean(second), StringComparison.OrdinalIgnoreCase);
private static int? SceneNumber(SceneIntelligenceScene scene)
=> scene.SceneReference?.SceneNumber.HasValue == true ? Convert.ToInt32(scene.SceneReference.SceneNumber.Value) : null;
private static bool IsRelationshipEligibleCharacter(StoryImportEntityResolution resolution)
=> resolution.Status is StoryImportResolutionStatuses.Matched
or StoryImportResolutionStatuses.NewCharacter
or StoryImportResolutionStatuses.PossibleDuplicateAliasCandidate
or StoryImportResolutionStatuses.PossibleMatch;
private static bool IsKnowledgeEligibleCharacter(StoryImportEntityResolution resolution)
=> resolution.Status is StoryImportResolutionStatuses.Matched
or StoryImportResolutionStatuses.NewCharacter
or StoryImportResolutionStatuses.PossibleDuplicateAliasCandidate
or StoryImportResolutionStatuses.PossibleMatch;
private static string RelationshipDisplayName(StoryImportEntityResolution resolution)
=> resolution.Status == StoryImportResolutionStatuses.Matched && !string.IsNullOrWhiteSpace(resolution.MatchedName)
? resolution.MatchedName!
: resolution.InputName;
private static Dictionary<string, string> BuildResolvedCharacterNameMap(
IReadOnlyList<StoryImportEntityResolution> characterResolutions,
Func<StoryImportEntityResolution, bool> eligible)
{
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var resolution in characterResolutions.Where(eligible))
{
AddCharacterMapEntry(map, resolution, RelationshipDisplayName(resolution));
}
return map;
}
private static Dictionary<string, StoryImportEntityResolution> BuildResolvedCharacterStatusMap(IReadOnlyList<StoryImportEntityResolution> characterResolutions)
{
var map = new Dictionary<string, StoryImportEntityResolution>(StringComparer.OrdinalIgnoreCase);
foreach (var resolution in characterResolutions)
{
AddCharacterMapEntry(map, resolution, resolution);
}
return map;
}
private static void AddCharacterMapEntry<T>(Dictionary<string, T> map, StoryImportEntityResolution resolution, T value)
{
var inputName = Clean(resolution.InputName);
if (!string.IsNullOrWhiteSpace(inputName))
{
map.TryAdd(inputName, value);
}
if (resolution.Status != StoryImportResolutionStatuses.PossibleDuplicateAliasCandidate || !inputName.Contains('/'))
{
return;
}
foreach (var part in inputName.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
map.TryAdd(Clean(part), value);
}
}
private static string RelationshipKey(string characterA, string characterB, string? signal) private static string RelationshipKey(string characterA, string characterB, string? signal)
{ {
var names = new[] { Clean(characterA), Clean(characterB) }.Order(StringComparer.OrdinalIgnoreCase).ToArray(); var names = new[] { Clean(characterA), Clean(characterB) }.Order(StringComparer.OrdinalIgnoreCase).ToArray();
@ -486,7 +908,9 @@ public sealed class SceneImportResolver(
private sealed record EntityIndexRow(int EntityID, string EntityName, string MatchValue, string MatchReason); private sealed record EntityIndexRow(int EntityID, string EntityName, string MatchValue, string MatchReason);
private sealed record CharacterMention(string? Name, decimal? Confidence, string? Notes); private sealed record CharacterMention(string? Name, decimal? Confidence, string? Notes, int? SceneNumber, string? Context);
private sealed record CharacterMentionGroup(string Name, decimal? Confidence, string Notes, IReadOnlyList<int> SceneNumbers, string Context);
private sealed class EntityIndex(IReadOnlyList<EntityIndexRow> rows) private sealed class EntityIndex(IReadOnlyList<EntityIndexRow> rows)
{ {
@ -513,5 +937,36 @@ public sealed class SceneImportResolver(
return matches; return matches;
} }
public IReadOnlyList<StoryImportResolutionCandidate> FindParentCandidates()
{
var parentMatches = rows
.Where(row =>
{
var value = RemoveLeadingArticle(Clean(row.MatchValue)).ToLowerInvariant();
var name = Clean(row.EntityName).ToLowerInvariant();
return ParentReferences.Contains(value)
|| value.Contains("mother", StringComparison.OrdinalIgnoreCase)
|| value.Contains("mum", StringComparison.OrdinalIgnoreCase)
|| value.Contains("mom", StringComparison.OrdinalIgnoreCase)
|| name.Contains("mother", StringComparison.OrdinalIgnoreCase)
|| name.Contains("mum", StringComparison.OrdinalIgnoreCase)
|| name.Contains("mom", 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 parentMatches;
}
} }
} }

View File

@ -269,6 +269,17 @@ else
</ul> </ul>
} }
@if (preview.UnresolvedKnowledge.Count > 0)
{
<h4>Knowledge involving unresolved references</h4>
<ul>
@foreach (var item in preview.UnresolvedKnowledge)
{
<li>@item.Action: @item.Character - @item.KnowledgeItem (@item.OldState -> @item.NewState, @DisplayConfidence(item.ConfidencePercent))</li>
}
</ul>
}
<h3>Timeline Clues</h3> <h3>Timeline Clues</h3>
<p>@preview.Timeline.Count.ToString("N0") timeline clue@(preview.Timeline.Count == 1 ? string.Empty : "s") detected.</p> <p>@preview.Timeline.Count.ToString("N0") timeline clue@(preview.Timeline.Count == 1 ? string.Empty : "s") detected.</p>
@if (preview.Timeline.Count > 0) @if (preview.Timeline.Count > 0)
@ -416,6 +427,8 @@ else
StoryImportResolutionStatuses.Matched => "✓", StoryImportResolutionStatuses.Matched => "✓",
StoryImportResolutionStatuses.Ambiguous => "⚠", StoryImportResolutionStatuses.Ambiguous => "⚠",
StoryImportResolutionStatuses.PossibleDuplicate => "⚠", StoryImportResolutionStatuses.PossibleDuplicate => "⚠",
StoryImportResolutionStatuses.PossibleDuplicateAliasCandidate => "⚠",
StoryImportResolutionStatuses.PossibleMatch => "?",
StoryImportResolutionStatuses.NewCharacter or StoryImportResolutionStatuses.NewLocation or StoryImportResolutionStatuses.NewAsset => "+", StoryImportResolutionStatuses.NewCharacter or StoryImportResolutionStatuses.NewLocation or StoryImportResolutionStatuses.NewAsset => "+",
_ => "•" _ => "•"
}; };
@ -424,7 +437,8 @@ else
=> status switch => status switch
{ {
StoryImportResolutionStatuses.NewCharacter or StoryImportResolutionStatuses.NewLocation or StoryImportResolutionStatuses.NewAsset => "New", StoryImportResolutionStatuses.NewCharacter or StoryImportResolutionStatuses.NewLocation or StoryImportResolutionStatuses.NewAsset => "New",
StoryImportResolutionStatuses.GenericReference => "Generic", StoryImportResolutionStatuses.PossibleDuplicate or StoryImportResolutionStatuses.PossibleDuplicateAliasCandidate => "Possible Duplicate / Alias Candidate",
StoryImportResolutionStatuses.GenericReference or StoryImportResolutionStatuses.Ignored => "Generic / Ignored",
StoryImportResolutionStatuses.UnresolvedPersonReference => "Unresolved", StoryImportResolutionStatuses.UnresolvedPersonReference => "Unresolved",
_ => status _ => status
}; };
@ -434,10 +448,10 @@ else
{ {
"Matched" => 0, "Matched" => 0,
"New" => 1, "New" => 1,
"Possible Duplicate" => 2, "Possible Match" => 2,
"Ambiguous" => 3, "Possible Duplicate / Alias Candidate" => 3,
"Ignored" => 4, "Ambiguous" => 4,
"Generic" => 5, "Generic / Ignored" => 5,
"Unresolved" => 6, "Unresolved" => 6,
_ => 99 _ => 99
}; };