Phase 20AO – Refine Location Intelligence Before Moving to Assets
This commit is contained in:
parent
4b6719e242
commit
55cded0b40
@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user