diff --git a/PlotLine/Services/SceneImportResolver.cs b/PlotLine/Services/SceneImportResolver.cs index dadf625..7dd2349 100644 --- a/PlotLine/Services/SceneImportResolver.cs +++ b/PlotLine/Services/SceneImportResolver.cs @@ -35,7 +35,10 @@ public sealed class SceneImportResolver( private static readonly HashSet GenericLocations = CreateSet( "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", + "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 EnvironmentalAssets = CreateSet( "pink towel", "mug of tea", "magazine", "fire door", "old oak door", "iron handle", "reception desk", @@ -585,8 +588,11 @@ public sealed class SceneImportResolver( var name = Clean(location.Name); var normalised = NormaliseLocationName(name); return !IsNamedRoad(name) - && GenericLocations.Contains(name.ToLowerInvariant()) - && string.Equals(normalised, name, StringComparison.OrdinalIgnoreCase); + && !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) @@ -615,17 +621,18 @@ public sealed class SceneImportResolver( 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); + 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)); - return sameOrAdjacentScene && hasAliasLanguage; + 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) @@ -786,6 +793,51 @@ public sealed class SceneImportResolver( 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);