From 55cded0b4023d2ea6ca4298b632f9bf71171ef49 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Thu, 9 Jul 2026 20:38:06 +0100 Subject: [PATCH] =?UTF-8?q?Phase=2020AO=20=E2=80=93=20Refine=20Location=20?= =?UTF-8?q?Intelligence=20Before=20Moving=20to=20Assets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PlotLine.Tests/Program.cs | 56 +++++++- .../StoryIntelligenceLocationImportService.cs | 126 +++++++++++++++--- 2 files changed, 163 insertions(+), 19 deletions(-) diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index cc282c3..f9b3cf1 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -12,7 +12,10 @@ var tests = new (string Name, Action Test)[] ("Chapter Structure confidence 0. repairs and deserialises", ChapterStructureConfidenceRepairs), ("Shared parser reports raw chapter output on unrecoverable JSON", SharedParserReportsRawOutput), ("Character filtering rejects generic groups", CharacterFilteringRejectsGenericGroups), - ("Character filtering preserves titled names", CharacterFilteringPreservesTitledNames) + ("Character filtering preserves titled names", CharacterFilteringPreservesTitledNames), + ("Location filtering rejects merged phrases", LocationFilteringRejectsMergedPhrases), + ("Location filtering preserves story locations", LocationFilteringPreservesStoryLocations), + ("Location canonical keys merge trivial variants", LocationCanonicalKeysMergeTrivialVariants) }; foreach (var test in tests) @@ -144,6 +147,57 @@ static bool InvokePrivateCharacterFilter(string methodName, string name) return method!.Invoke(null, [name]) is true; } +static void LocationFilteringRejectsMergedPhrases() +{ + Assert(IsMergedLocationPhrase("shop and staff room"), "Merged shop/staff room phrase should be rejected."); + Assert(IsMergedLocationPhrase("house and garden"), "Merged house/garden phrase should be rejected."); + Assert(IsMergedLocationPhrase("road and bridge"), "Merged road/bridge phrase should be rejected."); + Assert(IsMergedLocationPhrase("kitchen and hallway"), "Merged room phrase should be rejected."); + Assert(IsMergedLocationPhrase("front and rear garden"), "Merged garden phrase should be rejected."); + Assert(!IsMergedLocationPhrase("Rose and Crown"), "Named pub should not be treated as a merged location."); +} + +static void LocationFilteringPreservesStoryLocations() +{ + Assert(IsStoryLocation("The Doweries"), "The Doweries should be preserved."); + Assert(IsStoryLocation("Ashdown Trust"), "Ashdown Trust should be preserved."); + Assert(IsStoryLocation("St Luke's Church"), "St Luke's Church should be preserved."); + Assert(IsStoryLocation("Mrs Patterson's House"), "Mrs Patterson's House should be preserved."); + Assert(IsStoryLocation("Bristol Road"), "Named roads should be preserved."); + Assert(!IsStoryLocation("Kitchen"), "Standalone generic rooms should not be story locations."); +} + +static void LocationCanonicalKeysMergeTrivialVariants() +{ + Assert(LocationCanonicalKey("Kitchen") == LocationCanonicalKey("The Kitchen"), "The Kitchen should fold into Kitchen."); + Assert(LocationCanonicalKey("Interview room") == LocationCanonicalKey("Interview Room"), "Case-only room variants should merge."); + Assert(LocationCanonicalKey("Ashdown Trust (reception)") == LocationCanonicalKey("Ashdown Trust"), "Parenthetical variants should merge."); +} + +static bool IsMergedLocationPhrase(string name) + => InvokePrivateLocationFilter("IsMergedLocationPhrase", name); + +static bool IsStoryLocation(string name) + => InvokePrivateLocationFilter("IsStoryLocation", name); + +static string LocationCanonicalKey(string name) +{ + var method = typeof(StoryIntelligenceLocationImportService).GetMethod( + "CanonicalLocationKey", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + Assert(method is not null, "CanonicalLocationKey was not found."); + return (string)method!.Invoke(null, [name])!; +} + +static bool InvokePrivateLocationFilter(string methodName, string name) +{ + var method = typeof(StoryIntelligenceLocationImportService).GetMethod( + methodName, + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + Assert(method is not null, $"{methodName} was not found."); + return method!.Invoke(null, [name]) is true; +} + static JsonSerializerOptions JsonOptions() => new() { diff --git a/PlotLine/Services/StoryIntelligenceLocationImportService.cs b/PlotLine/Services/StoryIntelligenceLocationImportService.cs index 56c397e..1ee34dc 100644 --- a/PlotLine/Services/StoryIntelligenceLocationImportService.cs +++ b/PlotLine/Services/StoryIntelligenceLocationImportService.cs @@ -30,14 +30,28 @@ public sealed class StoryIntelligenceLocationImportService( "pavement", "car", "car park", "parking lot", "stairs", "stairwell", "landing", "kitchen", "bathroom", "hallway", "corridor", "lobby", "reception", "desk", "pew", "altar", "outside", "inside", "nearby", "somewhere", "home", "house", "flat", "streets", "main road", "dual carriageway", - "flyover", "motorway roundabout", "under the tree", "under-flyover / link road", "sitting room", "ring road"); + "flyover", "motorway roundabout", "under the tree", "under-flyover / link road", "sitting room", "ring road", + "entrance", "office", "bedroom", "garage", "seating area", "waiting room", "interview room", "garden"); private static readonly HashSet ThrowawayLocationFragments = CreateSet( "door", "porch", "exterior", "tree", "pavement"); private static readonly HashSet InternalLocationWords = CreateSet( "room", "kitchen", "bathroom", "hallway", "corridor", "lobby", "reception", "stairwell", "stairs", - "landing", "doorway", "pew", "altar", "floor"); + "landing", "doorway", "pew", "altar", "floor", "entrance", "office", "bedroom", "garage", + "seating area", "waiting room", "interview room", "garden", "staff room", "lift"); + + private static readonly HashSet RepeatableGenericSettings = CreateSet( + "kitchen", "bathroom", "hallway", "corridor", "lobby", "reception", "office", "bedroom", "garage", + "seating area", "waiting room", "interview room", "staff room"); + + private static readonly HashSet StoryLocationSignals = CreateSet( + "trust", "church", "school", "university", "police", "headquarters", "hq", "house", "flat", "pub", + "cafe", "shop", "factory", "centre", "center", "hospital", "station", "hotel", "estate"); + + private static readonly HashSet ConjunctionLocationWords = CreateSet( + "shop", "staff room", "house", "garden", "road", "bridge", "kitchen", "hallway", "front garden", + "rear garden", "room", "office", "garage", "bathroom", "bedroom", "corridor", "street", "car park"); private static readonly Regex AddressPattern = new(@"^\d+\s+\p{L}", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex NamedRoadPattern = new(@"\b(street|road|lane|avenue|drive|way|close|crescent|square|place)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); @@ -310,23 +324,23 @@ public sealed class StoryIntelligenceLocationImportService( { var setting = parsed.Setting; var settingName = FirstConfigured(setting?.LocationName, setting?.GenericRoomType); - AddLocation(groups, existingIndex, importedScene, parsed, settingName, setting?.LocationType, setting?.GenericRoomType, setting?.ParentLocationHint, true, false, setting?.Confidence, "Scene setting"); + AddLocation(groups, existingIndex, importedScene, parsed, settingName, setting?.LocationType, setting?.GenericRoomType, setting?.ParentLocationHint, true, false, true, setting?.Confidence, "Scene setting"); foreach (var location in parsed.Locations ?? []) { - AddLocation(groups, existingIndex, importedScene, parsed, location.Name, location.LocationType, location.GenericRoomType, location.ParentLocationHint, location.PresentInScene == true, location.MentionedOnly == true, location.Confidence, location.Notes); + AddLocation(groups, existingIndex, importedScene, parsed, location.Name, location.LocationType, location.GenericRoomType, location.ParentLocationHint, location.PresentInScene == true, location.MentionedOnly == true, false, location.Confidence, location.Notes); } foreach (var observation in parsed.Observations ?? []) { if (IsLocationEntity(observation.SubjectEntityType)) { - AddLocation(groups, existingIndex, importedScene, parsed, observation.SubjectName, null, null, null, IsPresentPredicate(observation.Predicate), !IsPresentPredicate(observation.Predicate), observation.Confidence, observation.Description); + AddLocation(groups, existingIndex, importedScene, parsed, observation.SubjectName, null, null, null, IsPresentPredicate(observation.Predicate), !IsPresentPredicate(observation.Predicate), false, observation.Confidence, observation.Description); } if (IsLocationEntity(observation.ObjectEntityType)) { - AddLocation(groups, existingIndex, importedScene, parsed, observation.ObjectName, null, null, null, IsPresentPredicate(observation.Predicate), !IsPresentPredicate(observation.Predicate), observation.Confidence, observation.Description); + AddLocation(groups, existingIndex, importedScene, parsed, observation.ObjectName, null, null, null, IsPresentPredicate(observation.Predicate), !IsPresentPredicate(observation.Predicate), false, observation.Confidence, observation.Description); } } } @@ -342,6 +356,7 @@ public sealed class StoryIntelligenceLocationImportService( string? parentLocationHint, bool presentInScene, bool mentionedOnly, + bool isSceneSetting, decimal? confidence, string? notes) { @@ -377,6 +392,7 @@ public sealed class StoryIntelligenceLocationImportService( importedScene.SceneTitle, presentInScene && !mentionedOnly, mentionedOnly, + isSceneSetting, notes, confidence, importedScene.PrimaryLocationID.HasValue && candidate.ExistingLocationID == importedScene.PrimaryLocationID)); @@ -485,6 +501,11 @@ public sealed class StoryIntelligenceLocationImportService( private static bool IsVisibleLocationCandidate(LocationCandidate candidate) { + if (IsMergedLocationPhrase(candidate.DisplayName)) + { + return false; + } + if (IsGenericComposite(candidate.DisplayName)) { return false; @@ -495,23 +516,33 @@ public sealed class StoryIntelligenceLocationImportService( return false; } - if (IsNamedLocation(candidate.DisplayName)) + if (IsStoryLocation(candidate.DisplayName)) { return true; } - if (!string.IsNullOrWhiteSpace(candidate.ParentLocationHint)) + var presentSceneCount = candidate.Appearances + .Where(appearance => appearance.PresentInScene) + .Select(appearance => appearance.SceneID) + .Distinct() + .Count(); + var settingSceneCount = candidate.Appearances + .Where(appearance => appearance.IsSceneSetting) + .Select(appearance => appearance.SceneID) + .Distinct() + .Count(); + + if (HasUsefulParentHint(candidate.ParentLocationHint)) { - return candidate.Appearances.Count(appearance => appearance.PresentInScene) > 0 - && (InternalLocationWords.Contains(Normalise(candidate.DisplayName)) || !IsGenericLocation(candidate.DisplayName)); + return presentSceneCount > 0; } if (IsGenericLocation(candidate.DisplayName)) { - return false; + return settingSceneCount >= 2 && RepeatableGenericSettings.Contains(Normalise(candidate.DisplayName)); } - return true; + return IsNamedLocation(candidate.DisplayName); } private static bool IsLocationNameCandidate(string? name) @@ -538,8 +569,7 @@ public sealed class StoryIntelligenceLocationImportService( return false; } - return value.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .All(part => GenericLocations.Contains(part) || InternalLocationWords.Contains(part) || NamedRoadPattern.IsMatch(part) == false); + return true; } private static bool ContainsThrowawayFragment(string name) @@ -548,9 +578,58 @@ public sealed class StoryIntelligenceLocationImportService( return ThrowawayLocationFragments.Any(fragment => value.Contains(fragment, StringComparison.OrdinalIgnoreCase)); } + private static bool IsMergedLocationPhrase(string name) + { + var value = Normalise(StripParenthetical(name)); + if (!value.Contains(" and ", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (value is "rose and crown") + { + return false; + } + + var parts = value.Split(" and ", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return parts.Length > 1 && parts.All(part => ConjunctionLocationWords.Contains(part) || ContainsConjunctionLocationWord(part)); + } + + private static bool ContainsConjunctionLocationWord(string value) + => ConjunctionLocationWords.Any(word => value.Contains(word, StringComparison.OrdinalIgnoreCase)); + + private static bool HasUsefulParentHint(string? parentHint) + { + var clean = Clean(parentHint); + return !string.IsNullOrWhiteSpace(clean) + && !IsMergedLocationPhrase(clean) + && !IsGenericLocation(clean) + && !ContainsThrowawayFragment(clean); + } + + private static bool IsStoryLocation(string name) + { + var clean = StripParenthetical(name); + var normalised = Normalise(clean); + if (IsGenericLocation(clean) || IsGenericComposite(clean)) + { + return false; + } + + if (AddressPattern.IsMatch(clean) || IsNamedRoad(clean)) + { + return true; + } + + return clean.Any(char.IsUpper) + && (StoryLocationSignals.Any(signal => normalised.Contains(signal, StringComparison.OrdinalIgnoreCase)) + || clean.Contains('\'') + || clean.Contains('’')); + } + private static bool IsNamedLocation(string name) { - var withoutParentheses = Regex.Replace(name, @"\s*\([^)]*\)\s*$", string.Empty); + var withoutParentheses = StripParenthetical(name); return AddressPattern.IsMatch(withoutParentheses) || IsNamedRoad(name) || withoutParentheses.Any(char.IsUpper) @@ -560,7 +639,7 @@ public sealed class StoryIntelligenceLocationImportService( private static bool IsNamedRoad(string name) { - var withoutParentheses = Regex.Replace(name, @"\s*\([^)]*\)\s*$", string.Empty); + var withoutParentheses = StripParenthetical(name); return !name.Contains('/') && withoutParentheses.Any(char.IsUpper) && NamedRoadPattern.IsMatch(withoutParentheses) @@ -671,8 +750,18 @@ public sealed class StoryIntelligenceLocationImportService( }; private static string CanonicalLocationKey(string name) - => Normalise(CleanLocationName(name) - .Replace("the ", string.Empty, StringComparison.OrdinalIgnoreCase)); + { + var clean = StripParenthetical(CleanLocationName(name)); + if (clean.StartsWith("the ", StringComparison.OrdinalIgnoreCase)) + { + clean = clean[4..]; + } + + return Normalise(clean); + } + + private static string StripParenthetical(string? value) + => Regex.Replace(Clean(value), @"\s*\([^)]*\)\s*$", string.Empty); private static string CleanLocationName(string? value) { @@ -734,6 +823,7 @@ public sealed class StoryIntelligenceLocationImportService( string SceneTitle, bool PresentInScene, bool MentionedOnly, + bool IsSceneSetting, string? Notes, decimal? Confidence, bool AlreadyLinked);