Phase 20AO – Refine Location Intelligence Before Moving to Assets

This commit is contained in:
Nick Beckley 2026-07-09 20:38:06 +01:00
parent 4b6719e242
commit 55cded0b40
2 changed files with 163 additions and 19 deletions

View File

@ -12,7 +12,10 @@ var tests = new (string Name, Action Test)[]
("Chapter Structure confidence 0. repairs and deserialises", ChapterStructureConfidenceRepairs), ("Chapter Structure confidence 0. repairs and deserialises", ChapterStructureConfidenceRepairs),
("Shared parser reports raw chapter output on unrecoverable JSON", SharedParserReportsRawOutput), ("Shared parser reports raw chapter output on unrecoverable JSON", SharedParserReportsRawOutput),
("Character filtering rejects generic groups", CharacterFilteringRejectsGenericGroups), ("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) foreach (var test in tests)
@ -144,6 +147,57 @@ static bool InvokePrivateCharacterFilter(string methodName, string name)
return method!.Invoke(null, [name]) is true; 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() static JsonSerializerOptions JsonOptions()
=> new() => new()
{ {

View File

@ -30,14 +30,28 @@ public sealed class StoryIntelligenceLocationImportService(
"pavement", "car", "car park", "parking lot", "stairs", "stairwell", "landing", "kitchen", "pavement", "car", "car park", "parking lot", "stairs", "stairwell", "landing", "kitchen",
"bathroom", "hallway", "corridor", "lobby", "reception", "desk", "pew", "altar", "outside", "bathroom", "hallway", "corridor", "lobby", "reception", "desk", "pew", "altar", "outside",
"inside", "nearby", "somewhere", "home", "house", "flat", "streets", "main road", "dual carriageway", "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<string> ThrowawayLocationFragments = CreateSet( private static readonly HashSet<string> ThrowawayLocationFragments = CreateSet(
"door", "porch", "exterior", "tree", "pavement"); "door", "porch", "exterior", "tree", "pavement");
private static readonly HashSet<string> InternalLocationWords = CreateSet( private static readonly HashSet<string> InternalLocationWords = CreateSet(
"room", "kitchen", "bathroom", "hallway", "corridor", "lobby", "reception", "stairwell", "stairs", "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<string> RepeatableGenericSettings = CreateSet(
"kitchen", "bathroom", "hallway", "corridor", "lobby", "reception", "office", "bedroom", "garage",
"seating area", "waiting room", "interview room", "staff room");
private static readonly HashSet<string> 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<string> 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 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); 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 setting = parsed.Setting;
var settingName = FirstConfigured(setting?.LocationName, setting?.GenericRoomType); 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 ?? []) 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 ?? []) foreach (var observation in parsed.Observations ?? [])
{ {
if (IsLocationEntity(observation.SubjectEntityType)) 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)) 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, string? parentLocationHint,
bool presentInScene, bool presentInScene,
bool mentionedOnly, bool mentionedOnly,
bool isSceneSetting,
decimal? confidence, decimal? confidence,
string? notes) string? notes)
{ {
@ -377,6 +392,7 @@ public sealed class StoryIntelligenceLocationImportService(
importedScene.SceneTitle, importedScene.SceneTitle,
presentInScene && !mentionedOnly, presentInScene && !mentionedOnly,
mentionedOnly, mentionedOnly,
isSceneSetting,
notes, notes,
confidence, confidence,
importedScene.PrimaryLocationID.HasValue && candidate.ExistingLocationID == importedScene.PrimaryLocationID)); importedScene.PrimaryLocationID.HasValue && candidate.ExistingLocationID == importedScene.PrimaryLocationID));
@ -485,6 +501,11 @@ public sealed class StoryIntelligenceLocationImportService(
private static bool IsVisibleLocationCandidate(LocationCandidate candidate) private static bool IsVisibleLocationCandidate(LocationCandidate candidate)
{ {
if (IsMergedLocationPhrase(candidate.DisplayName))
{
return false;
}
if (IsGenericComposite(candidate.DisplayName)) if (IsGenericComposite(candidate.DisplayName))
{ {
return false; return false;
@ -495,23 +516,33 @@ public sealed class StoryIntelligenceLocationImportService(
return false; return false;
} }
if (IsNamedLocation(candidate.DisplayName)) if (IsStoryLocation(candidate.DisplayName))
{ {
return true; 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 return presentSceneCount > 0;
&& (InternalLocationWords.Contains(Normalise(candidate.DisplayName)) || !IsGenericLocation(candidate.DisplayName));
} }
if (IsGenericLocation(candidate.DisplayName)) 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) private static bool IsLocationNameCandidate(string? name)
@ -538,8 +569,7 @@ public sealed class StoryIntelligenceLocationImportService(
return false; return false;
} }
return value.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) return true;
.All(part => GenericLocations.Contains(part) || InternalLocationWords.Contains(part) || NamedRoadPattern.IsMatch(part) == false);
} }
private static bool ContainsThrowawayFragment(string name) private static bool ContainsThrowawayFragment(string name)
@ -548,9 +578,58 @@ public sealed class StoryIntelligenceLocationImportService(
return ThrowawayLocationFragments.Any(fragment => value.Contains(fragment, StringComparison.OrdinalIgnoreCase)); 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) private static bool IsNamedLocation(string name)
{ {
var withoutParentheses = Regex.Replace(name, @"\s*\([^)]*\)\s*$", string.Empty); var withoutParentheses = StripParenthetical(name);
return AddressPattern.IsMatch(withoutParentheses) return AddressPattern.IsMatch(withoutParentheses)
|| IsNamedRoad(name) || IsNamedRoad(name)
|| withoutParentheses.Any(char.IsUpper) || withoutParentheses.Any(char.IsUpper)
@ -560,7 +639,7 @@ public sealed class StoryIntelligenceLocationImportService(
private static bool IsNamedRoad(string name) private static bool IsNamedRoad(string name)
{ {
var withoutParentheses = Regex.Replace(name, @"\s*\([^)]*\)\s*$", string.Empty); var withoutParentheses = StripParenthetical(name);
return !name.Contains('/') return !name.Contains('/')
&& withoutParentheses.Any(char.IsUpper) && withoutParentheses.Any(char.IsUpper)
&& NamedRoadPattern.IsMatch(withoutParentheses) && NamedRoadPattern.IsMatch(withoutParentheses)
@ -671,8 +750,18 @@ public sealed class StoryIntelligenceLocationImportService(
}; };
private static string CanonicalLocationKey(string name) 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) private static string CleanLocationName(string? value)
{ {
@ -734,6 +823,7 @@ public sealed class StoryIntelligenceLocationImportService(
string SceneTitle, string SceneTitle,
bool PresentInScene, bool PresentInScene,
bool MentionedOnly, bool MentionedOnly,
bool IsSceneSetting,
string? Notes, string? Notes,
decimal? Confidence, decimal? Confidence,
bool AlreadyLinked); bool AlreadyLinked);