PlotDirector/PlotLine/Services/SceneImportResolver.cs

1025 lines
47 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Text.Json;
using System.Text.RegularExpressions;
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", "elderly cashier");
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(
"car park", "house", "kitchen", "stairwell", "stair", "doorway", "corridor", "room",
"bedroom", "bathroom", "office", "reception", "sixth floor", "floor", "car", "hall", "table", "chair",
"altar", "building lobby", "church", "garage", "ground floor lobby", "hallway", "kitchen/dining area",
"lobby", "multistorey car park", "pavement", "rear pew", "stairwell/landing", "street", "street corner",
"front door", "parked car", "back street");
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", "telephone", "phone", "keys", "key");
private static readonly HashSet<string> StoryAssetNames = CreateSet(
"passport", "letter", "photograph", "photo", "bank book", "bank account",
"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)
{
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, characterResolutions, includeResolved: true).ToList();
var unresolvedKnowledgePreview = ResolveKnowledge(scenes, characterResolutions, includeResolved: false).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,
UnresolvedKnowledge = unresolvedKnowledgePreview,
Timeline = timelinePreview,
Warnings = warnings
};
}
private IEnumerable<StoryImportEntityResolution> ResolveCharacters(IEnumerable<SceneIntelligenceScene> scenes, EntityIndex index)
{
var mentions = scenes
.SelectMany(scene => (scene.Characters ?? []).Select(character => new CharacterMention(
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))
.GroupBy(item => Clean(item.Name), StringComparer.OrdinalIgnoreCase)
.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);
if (IgnoredPovNames.Contains(name))
{
resolutions.Add(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))
{
resolutions.Add(LogResolution(new StoryImportEntityResolution
{
InputName = name,
EntityType = "Character",
Status = StoryImportResolutionStatuses.UnresolvedPersonReference,
Confidence = item.Confidence,
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;
}
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)
{
foreach (var group in scenes
.SelectMany(scene => (scene.Locations ?? []).SelectMany(ExpandLocationMention))
.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();
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)
{
var notes = new List<string>();
if (variants.Count > 1 || !string.Equals(group.Key, name, StringComparison.OrdinalIgnoreCase))
{
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);
}
}
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 => NormaliseAssetName(asset.Name), StringComparer.OrdinalIgnoreCase)
.Select(group => group.First()))
{
var name = NormaliseAssetName(item.Name);
if (IsRejectedAsset(item))
{
logger.LogInformation("Rejected generic asset: {InputName}. Reason={Reason}", name, "Generic object without owner/name/significance.");
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;
}
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));
}
}
private static IEnumerable<StoryImportRelationshipPreview> ResolveRelationships(
IEnumerable<SceneIntelligenceScene> scenes,
IReadOnlyList<CharacterRelationship> existingRelationships,
IReadOnlyList<StoryImportEntityResolution> characterResolutions)
{
var resolvedCharacters = BuildResolvedCharacterNameMap(characterResolutions, IsRelationshipEligibleCharacter);
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,
IReadOnlyList<StoryImportEntityResolution> characterResolutions,
bool includeResolved)
{
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!,
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 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)
{
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)
{
var name = Clean(location.Name);
var normalised = NormaliseLocationName(name);
return !IsNamedRoad(name)
&& !IsNamedPlace(name)
&& (GenericLocations.Contains(name.ToLowerInvariant())
|| IsGenericCompoundLocation(name)
|| IsCarLocationReference(name)
|| string.Equals(normalised, name, StringComparison.OrdinalIgnoreCase) && GenericLocations.Contains(RemovePossessiveOwner(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..])
|| IsCompoundGenericPerson(clean)
|| clean.Contains(" group", 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 combined = $"{firstName} {secondName} {first.Notes} {first.Context} {second.Notes} {second.Context}";
var isSusieDebbie = NamesMatch(firstName, "Susie") && NamesMatch(secondName, "Debbie")
|| NamesMatch(firstName, "Debbie") && NamesMatch(secondName, "Susie");
var hasSusieDebbieEvidence = isSusieDebbie
&& (combined.Contains("I use Susie", StringComparison.OrdinalIgnoreCase)
|| Regex.IsMatch(combined, @"\bDebbie\s*,?\s+or\s+Susie\b|\bSusie\s*,?\s+or\s+Debbie\b", RegexOptions.IgnoreCase)
|| Regex.IsMatch(combined, @"\b(Debbie|Susie)\b.{0,80}\b(alias|real name|also known as|called herself|uses the name|used the name|she said her name was)\b.{0,80}\b(Debbie|Susie)\b", RegexOptions.IgnoreCase));
var hasExplicitNameLink = Regex.IsMatch(combined, $@"\b{Regex.Escape(firstName)}\b.{{0,80}}\b(alias|real name|also known as|called herself|uses the name|used the name|she said her name was)\b.{{0,80}}\b{Regex.Escape(secondName)}\b", RegexOptions.IgnoreCase)
|| Regex.IsMatch(combined, $@"\b{Regex.Escape(secondName)}\b.{{0,80}}\b(alias|real name|also known as|called herself|uses the name|used the name|she said her name was)\b.{{0,80}}\b{Regex.Escape(firstName)}\b", RegexOptions.IgnoreCase);
return hasSusieDebbieEvidence || hasExplicitNameLink;
}
private static bool IsRejectedAsset(SceneIntelligenceAsset asset)
{
var name = NormaliseAssetName(asset.Name);
var lower = name.ToLowerInvariant();
if (IsAcceptedStoryAsset(asset))
{
return false;
}
if (EnvironmentalAssets.Contains(lower) || EnvironmentalAssets.Contains(RemoveLeadingArticle(lower)))
{
return true;
}
if (StoryAssetNames.Contains(RemoveLeadingArticle(lower)))
{
return false;
}
var signalText = $"{asset.Status} {asset.OwnerOrHolder} {asset.Notes} {asset.AssetType}".ToLowerInvariant();
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)
=> 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 parenthetical = InternalLocationQualifier(clean);
if (!string.IsNullOrWhiteSpace(parenthetical))
{
clean = StripParenthetical(clean);
}
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 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 bool IsNamedPlace(string? value)
{
var clean = StripParenthetical(RemoveLeadingArticle(Clean(value)));
if (string.IsNullOrWhiteSpace(clean))
{
return false;
}
return Regex.IsMatch(clean, @"^\d+\s+\S+", RegexOptions.IgnoreCase)
|| clean.Contains("Ashdown Trust", StringComparison.OrdinalIgnoreCase)
|| clean.Contains("Sleepy Joe", StringComparison.OrdinalIgnoreCase)
|| Regex.IsMatch(clean, @"\bSt\.?\s+\S+|\bSaint\s+\S+", RegexOptions.IgnoreCase);
}
private static bool IsGenericCompoundLocation(string? value)
{
var clean = StripParenthetical(RemoveLeadingArticle(Clean(value))).Replace('', '\'').ToLowerInvariant();
if (!clean.Contains('/'))
{
return false;
}
var parts = clean.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(RemoveLeadingArticle)
.ToList();
return parts.Count > 1 && parts.All(part => GenericLocations.Contains(part));
}
private static bool IsCarLocationReference(string? value)
{
var clean = StripParenthetical(RemoveLeadingArticle(Clean(value))).ToLowerInvariant();
return clean == "car"
|| clean.EndsWith(" car", StringComparison.OrdinalIgnoreCase)
|| clean.Contains("'s car", StringComparison.OrdinalIgnoreCase)
|| clean.Contains("parked car", StringComparison.OrdinalIgnoreCase);
}
private static string RemovePossessiveOwner(string? value)
{
var clean = StripParenthetical(RemoveLeadingArticle(Clean(value))).Replace('', '\'');
var possessive = clean.IndexOf("'s ", StringComparison.OrdinalIgnoreCase);
return possessive >= 0 ? clean[(possessive + 3)..].Trim() : clean;
}
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)
=> 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 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)
{
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, 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)
{
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;
}
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;
}
}
}