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),
|
||||
("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()
|
||||
{
|
||||
|
||||
@ -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<string> ThrowawayLocationFragments = CreateSet(
|
||||
"door", "porch", "exterior", "tree", "pavement");
|
||||
|
||||
private static readonly HashSet<string> 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<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 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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user