Generalise Story Intelligence entity consolidation

This commit is contained in:
Nick Beckley 2026-08-29 12:45:04 +00:00
parent b7f24954e1
commit ae0552af90
3 changed files with 138 additions and 56 deletions

View File

@ -44,6 +44,8 @@ var tests = new (string Name, Action Test)[]
("Asset canonical keys merge trivial variants", AssetCanonicalKeysMergeTrivialVariants),
("Asset canonical keys merge contextual vehicle variants", AssetCanonicalKeysMergeContextualVehicleVariants),
("Asset canonical keys keep unrelated owned cars distinct", AssetCanonicalKeysKeepUnrelatedOwnedCarsDistinct),
("Asset candidate keys attach unique unowned variants", AssetCandidateKeysAttachUniqueUnownedVariants),
("Asset filtering rejects relationship facts", AssetFilteringRejectsRelationshipFacts),
("Asset contextual names resolve POV ownership", AssetContextualNamesResolvePovOwnership),
("Asset structural phrases trim neighbouring nouns", AssetStructuralPhrasesTrimNeighbouringNouns),
("Asset significance is generic and not fixture-specific", AssetSignificanceIsGenericAndNotFixtureSpecific),
@ -1906,8 +1908,8 @@ static void CharacterFilteringPreservesTitledNames()
Assert(!IsGenericGroupReference("Reverend Potter"), "Reverend Potter should not be treated as generic.");
Assert(IsNamedOrTitledPerson("Reverend Potter"), "Reverend Potter should be recognised as a titled person.");
Assert(IsNamedOrTitledPerson("Detective Sergeant Summerhill"), "Detective Sergeant Summerhill should be recognised as a titled person.");
Assert(IsNamedOrTitledPerson("Mrs Patterson"), "Mrs Patterson should be recognised as a titled person.");
Assert(IsNamedOrTitledPerson("Aunt Elen"), "Aunt Elen should be recognised as a titled person.");
Assert(IsNamedOrTitledPerson("Mrs Alder"), "Mrs Alder should be recognised as a titled person.");
Assert(IsNamedOrTitledPerson("Aunt Mara"), "Aunt Mara should be recognised as a titled person.");
}
static bool IsGenericGroupReference(string name)
@ -1932,15 +1934,15 @@ static void LocationFilteringRejectsMergedPhrases()
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.");
Assert(!IsMergedLocationPhrase("Fox and Lantern"), "Named pub should not be treated as a merged location.");
}
static void LocationFilteringPreservesStoryLocations()
{
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("Harbour Trust"), "Harbour Trust should be preserved.");
Assert(IsStoryLocation("St Anselm's Church"), "St Anselm's Church should be preserved.");
Assert(IsStoryLocation("Mrs Alder's House"), "Mrs Alder's House should be preserved.");
Assert(IsStoryLocation("Northbridge Road"), "Named roads should be preserved.");
Assert(!IsStoryLocation("Kitchen"), "Standalone generic rooms should not be story locations.");
}
@ -1948,23 +1950,23 @@ 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.");
Assert(LocationCanonicalKey("Harbour Trust (reception)") == LocationCanonicalKey("Harbour Trust"), "Parenthetical variants should merge.");
}
static void LocationContextualNamesUsePovAndStructuralSetting()
{
var bethScene = new Scene { StructuralSetting = "Beth's bedroom", POVCharacterName = "Beth" };
var maggieScene = new Scene { StructuralSetting = "Maggie's bedroom", POVCharacterName = "Maggie" };
var noraScene = new Scene { StructuralSetting = "Nora's bedroom", POVCharacterName = "Nora" };
var imaniScene = new Scene { StructuralSetting = "Imani's bedroom", POVCharacterName = "Imani" };
Assert(ResolveContextualLocationName("my bedroom", "bedroom", bethScene) == "Beth's bedroom", "Beth POV plus Beth structural setting should resolve my bedroom to Beth's bedroom.");
Assert(ResolveContextualLocationName("bedroom", "bedroom", maggieScene) == "Maggie's bedroom", "Maggie structural setting should resolve generic bedroom to Maggie's bedroom.");
Assert(LocationCanonicalKey("Beth's bedroom") != LocationCanonicalKey("Maggie's bedroom"), "Owned bedrooms for different POV characters must remain distinct.");
Assert(ResolveContextualLocationName("Narrator's home", null, bethScene) == "Beth's home", "Narrator-owned locations should resolve through canonical POV.");
Assert(ResolveContextualLocationName("my bedroom", "bedroom", noraScene) == "Nora's bedroom", "Nora POV plus Nora structural setting should resolve my bedroom to Nora's bedroom.");
Assert(ResolveContextualLocationName("bedroom", "bedroom", imaniScene) == "Imani's bedroom", "Imani structural setting should resolve generic bedroom to Imani's bedroom.");
Assert(LocationCanonicalKey("Nora's bedroom") != LocationCanonicalKey("Imani's bedroom"), "Owned bedrooms for different POV characters must remain distinct.");
Assert(ResolveContextualLocationName("Narrator's home", null, noraScene) == "Nora's home", "Narrator-owned locations should resolve through canonical POV.");
}
static void LocationCanonicalKeysMergePunctuationAndSafePlurals()
{
Assert(LocationCanonicalKey("Mrs Patterson's house") == LocationCanonicalKey("Mrs. Patterson's house"), "Title punctuation variants should merge.");
Assert(LocationCanonicalKey("Mrs Alder's house") == LocationCanonicalKey("Mrs. Alder's house"), "Title punctuation variants should merge.");
Assert(LocationCanonicalKey("garage") == LocationCanonicalKey("garages"), "Safe singular/plural location variants should merge.");
Assert(!IsStoryLocation("road"), "Incidental generic road should not be a strong story location.");
}
@ -1975,18 +1977,20 @@ static void LocationCandidateConsolidationHandlesGenericReviewVariants()
Assert(LocationCanonicalKey("The Crown Inn") == LocationCanonicalKey("Crown Inn"), "Leading article variants should merge.");
Assert(LocationCanonicalKey("inside The Crown Inn") == LocationCanonicalKey("at the Crown Inn"), "Context wrappers should not create separate candidates.");
Assert(LocationCanonicalKey("The Crown Inn interior, early evening") == LocationCanonicalKey("Crown Inn"), "Temporal and broad interior suffixes should fold into the named place.");
Assert(LocationCanonicalKey("Mrs Patterson's house") == LocationCanonicalKey("Mrs. Patterson's house"), "Mrs/Mrs. punctuation should not split a house candidate.");
Assert(LocationCanonicalKey("Mrs Alder's house") == LocationCanonicalKey("Mrs. Alder's house"), "Mrs/Mrs. punctuation should not split a house candidate.");
Assert(LocationCanonicalKey("Dr Hale's house") == LocationCanonicalKey("Dr. Hale's house"), "Unrelated title punctuation fixture should also merge.");
Assert(LocationCanonicalKey("Nora's house") != LocationCanonicalKey("Imani's house"), "Distinct owned houses must not merge.");
Assert(LocationCanonicalKey("The Crown Inn") != LocationCanonicalKey("The Crown Inn cellar"), "Meaningful sub-locations should remain separate.");
Assert(LocationCanonicalKey("The Meridian Hotel") != LocationCanonicalKey("The Meridian Hotel kitchen"), "Unrelated named-place sub-location fixture should remain separate.");
Assert(LocationCanonicalKey("The Meridian Hotel car park and waiting area") == LocationCanonicalKey("Meridian Hotel car park"), "Low-value waiting-area suffixes should fold into the specific parent setting.");
Assert(LocationCanonicalKey("inside Meridian Hotel car park") == LocationCanonicalKey("Meridian Hotel car park"), "Context prefixes should not split specific repeatable settings.");
Assert(IsGenericLocation("the tunnel"), "Tunnel should be recognised as a generic setting label.");
Assert(RepeatableGenericSettingKeys().Contains(LocationCanonicalKey("the tunnel")), "Repeated active tunnel settings should be allowed to survive review.");
Assert(!IsStoryLocation("a stretch of road"), "One-off incidental road prose should not be treated as a strong named location.");
Assert(IsGenericComposite("Nora's house and Imani's house"), "Multi-location owned-place conjunctions should not become one fake location.");
Assert(!IsMergedLocationPhrase("Rose and Crown"), "Generic named compounds should not need a production special-case exception.");
Assert(!IsMergedLocationPhrase("Fox and Lantern"), "Generic named compounds should not need a production special-case exception.");
}
static void LocationReviewUxSupportsLargeCandidateSets()
@ -2066,12 +2070,12 @@ static void AssetFilteringRejectsGenericObjects()
static void AssetFilteringPreservesStoryAssets()
{
Assert(IsAssetNameCandidate("TR6"), "TR6 should be treated as a story asset.");
Assert(IsAssetNameCandidate("ZX6R"), "ZX6R should be treated as a story asset.");
Assert(IsAssetNameCandidate("red notebook"), "Red notebook should be treated as a story asset.");
Assert(IsAssetNameCandidate("passport"), "Passport should be treated as a story asset.");
Assert(IsAssetNameCandidate("car keys"), "Car keys should be treated as a story asset.");
Assert(IsAssetNameCandidate("driving licence"), "Driving licence should be treated as a story asset.");
Assert(IsAssetNameCandidate("Memory Tin"), "Structurally significant Memory Tin-style objects should be treated as story assets.");
Assert(IsAssetNameCandidate("brass astrolabe"), "Structurally significant arbitrary objects should be treated as story assets.");
}
static void AssetCanonicalKeysMergeTrivialVariants()
@ -2083,37 +2087,59 @@ static void AssetCanonicalKeysMergeTrivialVariants()
static void AssetCanonicalKeysMergeContextualVehicleVariants()
{
Assert(AssetCanonicalKey("Triumph TR6") == AssetCanonicalKey("TR6"), "Triumph TR6 and TR6 should merge when the model identifies the specific vehicle.");
Assert(AssetCanonicalKey("Kawasaki ZX6R") == AssetCanonicalKey("ZX6R"), "Manufacturer/model-code variants should merge without hard-coded manuscript vehicle names.");
Assert(AssetCanonicalKey("Mazda MX5") == AssetCanonicalKey("MX5"), "A second arbitrary manufacturer/model-code fixture should merge the same way.");
}
static void AssetCanonicalKeysKeepUnrelatedOwnedCarsDistinct()
{
Assert(AssetCanonicalKey("Beth's car") != AssetCanonicalKey("Maggie's car"), "Owned cars should remain separate when context identifies different owners.");
Assert(AssetCanonicalKey("Nora's car") != AssetCanonicalKey("Imani's car"), "Generic owned vehicles should remain separate for unrelated books too.");
}
static void AssetCandidateKeysAttachUniqueUnownedVariants()
{
var existing = new[] { AssetCanonicalKey("Nora's pass certificate") };
Assert(FindContextualAssetKey(existing, AssetCanonicalKey("pass certificate")) == existing[0], "A unique unowned asset variant should attach to its owned contextual candidate.");
var ambiguous = new[]
{
AssetCanonicalKey("Nora's pass certificate"),
AssetCanonicalKey("Imani's pass certificate")
};
Assert(FindContextualAssetKey(ambiguous, AssetCanonicalKey("pass certificate")) is null, "Unowned asset variants must not merge when multiple owners match.");
}
static void AssetFilteringRejectsRelationshipFacts()
{
Assert(!IsAssetNameCandidate("Nora's sister Mara"), "Possessive relationship facts should not become assets.");
Assert(!IsAssetNameCandidate("Nora's mother dead in the bath"), "Event/family relationship phrases should not become assets.");
Assert(!IsAssetNameCandidate("Nora's parents in bed but Ellis"), "Relationship scene facts should not become assets.");
Assert(!IsAssetNameCandidate("Nora's number before leaving"), "Information fragments should not become assets.");
Assert(IsAssetNameCandidate("Nora's photograph"), "Actual owned asset nouns should survive relationship filtering.");
Assert(IsAssetNameCandidate("Nora's address book"), "Actual owned object phrases should survive relationship filtering.");
}
static void AssetContextualNamesResolvePovOwnership()
{
var scene = new Scene { POVCharacterName = "Beth" };
Assert(ResolveContextualAssetName("my memory tin", null, scene) == "Beth's memory tin", "POV ownership should resolve my memory tin to Beth's memory tin.");
Assert(ResolveContextualAssetName("Memory Tin", "Beth", scene) == "Beth's Memory Tin", "Explicit owner evidence should prefix owned significant assets.");
Assert(IsAssetNameCandidate("Beth's Memory Tin"), "Owned Memory Tin-style significant objects should be retained.");
var scene = new Scene { POVCharacterName = "Nora" };
Assert(ResolveContextualAssetName("my brass astrolabe", null, scene) == "Nora's brass astrolabe", "POV ownership should resolve my brass astrolabe to Nora's brass astrolabe.");
Assert(ResolveContextualAssetName("brass astrolabe", "Nora", scene) == "Nora's brass astrolabe", "Explicit owner evidence should prefix owned significant assets.");
Assert(IsAssetNameCandidate("Nora's brass astrolabe"), "Owned arbitrary significant objects should be retained.");
var unrelatedScene = new Scene { POVCharacterName = "Nora" };
Assert(ResolveContextualAssetName("my brass astrolabe", null, unrelatedScene) == "Nora's brass astrolabe", "POV ownership should resolve arbitrary significant owned objects.");
var unrelatedScene = new Scene { POVCharacterName = "Imani" };
Assert(ResolveContextualAssetName("my brass astrolabe", null, unrelatedScene) == "Imani's brass astrolabe", "POV ownership should resolve arbitrary significant owned objects.");
}
static void AssetStructuralPhrasesTrimNeighbouringNouns()
{
Assert(CleanStructuralAssetPhrase("memory tin and photographs") == "memory tin", "Structural asset extraction should not bundle neighbouring nouns after and.");
Assert(CleanStructuralAssetPhrase("brass astrolabe and photographs") == "brass astrolabe", "Structural asset extraction should not bundle neighbouring nouns after and.");
Assert(CleanStructuralAssetPhrase("brass astrolabe containing coordinates") == "brass astrolabe", "Structural asset extraction should trim explanatory tails for arbitrary objects.");
}
static void AssetSignificanceIsGenericAndNotFixtureSpecific()
{
Assert(IsDistinctiveAssetName("memory tin"), "Memory Tin should survive because it is a distinctive structural object phrase.");
Assert(IsDistinctiveAssetName("brass astrolabe"), "A second arbitrary significant object should use the same distinctive-name mechanism.");
Assert(IsDistinctiveAssetName("brass astrolabe"), "Arbitrary significant object phrases should survive because they are distinctive.");
Assert(IsDistinctiveAssetName("obsidian compass"), "A second arbitrary significant object should use the same distinctive-name mechanism.");
Assert(!IsDistinctiveAssetName("cup"), "Single incidental props should not become significant merely by being named.");
var service = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceAssetImportService.cs"));
@ -2250,6 +2276,15 @@ static string AssetCanonicalKey(string name)
return (string)method!.Invoke(null, [name])!;
}
static string? FindContextualAssetKey(IEnumerable<string> existingKeys, string candidateKey)
{
var method = typeof(StoryIntelligenceAssetImportService).GetMethod(
"FindContextualAssetKey",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "FindContextualAssetKey was not found.");
return (string?)method!.Invoke(null, [existingKeys, candidateKey]);
}
static bool InvokePrivateAssetFilter(string methodName, string name)
{
var method = typeof(StoryIntelligenceAssetImportService).GetMethod(

View File

@ -37,7 +37,7 @@ public sealed class StoryIntelligenceAssetImportService(
"passport", "photograph", "photo", "key", "keys", "car keys", "letter", "envelope", "note",
"notebook", "knife", "gun", "suitcase", "rucksack", "wedding ring",
"ring", "necklace", "cassette tape", "camera", "map", "diary", "watch", "driving licence",
"driving license", "licence", "license", "document", "folder", "wallet", "bank book", "scrap of paper",
"driving license", "licence", "license", "document", "folder", "wallet", "bank book", "address book", "scrap of paper",
"keepsake box", "keepsake tin");
private static readonly HashSet<string> StorySignals = CreateSet(
@ -48,6 +48,11 @@ public sealed class StoryIntelligenceAssetImportService(
private static readonly HashSet<string> AdjectivesToFold = CreateSet(
"red", "old", "new", "small", "large", "little", "anonymous", "folded", "sealed", "missing");
private static readonly HashSet<string> RelationshipWords = CreateSet(
"mother", "father", "mum", "mom", "dad", "sister", "brother", "parent", "parents", "daughter", "son",
"child", "children", "aunt", "uncle", "wife", "husband", "partner", "friend", "neighbour", "neighbor",
"teacher", "student", "examiner", "doctor", "nurse");
public async Task<StoryIntelligenceAssetReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
{
var data = await BuildCandidateDataAsync(batch);
@ -686,7 +691,9 @@ public sealed class StoryIntelligenceAssetImportService(
}
var normalised = Normalise(clean);
return !GenericObjects.Contains(normalised) && !GenericObjects.Contains(RemoveLeadingArticle(normalised));
return !GenericObjects.Contains(normalised)
&& !GenericObjects.Contains(RemoveLeadingArticle(normalised))
&& !LooksLikePersonOrRelationshipFact(clean);
}
private static bool IsAcceptedStoryAssetName(string? name)
@ -768,7 +775,9 @@ public sealed class StoryIntelligenceAssetImportService(
private static string? FindContextualAssetKey(IEnumerable<string> existingKeys, string candidateKey)
{
if (candidateKey.Contains(' '))
if (string.IsNullOrWhiteSpace(candidateKey)
|| HasPossessiveOwner(candidateKey)
|| LooksLikePersonOrRelationshipFact(candidateKey))
{
return null;
}
@ -830,6 +839,10 @@ public sealed class StoryIntelligenceAssetImportService(
|| IsAcceptedStoryAssetName(name)
|| IsDistinctiveAssetName(name);
private static bool HasPossessiveOwner(string value)
=> value.Contains("'s ", StringComparison.OrdinalIgnoreCase)
|| value.Contains("’s ", StringComparison.OrdinalIgnoreCase);
private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed, string assetName)
{
var summary = Clean(FirstConfigured(scene.StructuralSummary, scene.Summary, parsed.Summary?.Short));
@ -921,7 +934,7 @@ public sealed class StoryIntelligenceAssetImportService(
private static bool IsDistinctiveAssetName(string? value)
{
var clean = RemoveLeadingArticle(Normalise(CleanAssetName(value)));
if (string.IsNullOrWhiteSpace(clean) || GenericObjects.Contains(clean))
if (string.IsNullOrWhiteSpace(clean) || GenericObjects.Contains(clean) || LooksLikePersonOrRelationshipFact(clean))
{
return false;
}
@ -942,6 +955,39 @@ public sealed class StoryIntelligenceAssetImportService(
&& words.Any(word => !AdjectivesToFold.Contains(word) && !GenericObjects.Contains(word));
}
private static bool LooksLikePersonOrRelationshipFact(string? value)
{
var clean = RemoveLeadingArticle(Normalise(CleanAssetName(value)));
if (string.IsNullOrWhiteSpace(clean) || IsAcceptedStoryAssetName(clean))
{
return false;
}
if (clean.Contains(" dead in ", StringComparison.OrdinalIgnoreCase)
|| clean.Contains(" in bed", StringComparison.OrdinalIgnoreCase)
|| clean.Contains(" before leaving", StringComparison.OrdinalIgnoreCase)
|| clean.Contains(" written name", StringComparison.OrdinalIgnoreCase))
{
return true;
}
var subject = StripPossessiveOwner(clean);
var words = subject.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return words.Length is >= 1 and <= 4
&& words.Any(word => RelationshipWords.Contains(word))
&& !words.Any(word => AcceptedAssetNames.Contains(word) || StorySignals.Contains(word));
}
private static string StripPossessiveOwner(string value)
{
var straightIndex = value.IndexOf("'s ", StringComparison.OrdinalIgnoreCase);
var curlyIndex = value.IndexOf("’s ", StringComparison.OrdinalIgnoreCase);
var index = straightIndex >= 0
? straightIndex
: curlyIndex;
return index >= 0 ? value[(index + 3)..] : value;
}
private static string? ExtractDistinctiveModelCode(string? value)
{
var clean = Normalise(value);

View File

@ -413,7 +413,7 @@ public sealed class StoryIntelligenceLocationImportService(
return;
}
var key = identity.Key;
var key = ResolveCandidateKey(groups, identity.Key, identity.DisplayName);
if (!groups.TryGetValue(key, out var candidate))
{
var match = identity.ExistingMatch;
@ -802,27 +802,6 @@ public sealed class StoryIntelligenceLocationImportService(
|| value.Contains("occursAt", StringComparison.OrdinalIgnoreCase);
}
private static string ResolveCandidateKey(
IReadOnlyDictionary<string, LocationCandidate> groups,
LocationIndex existingIndex,
string name)
{
var existingMatch = existingIndex.Find(name);
if (existingMatch is not null)
{
var existingKey = CanonicalLocationKey(existingMatch.LocationName);
if (groups.ContainsKey(existingKey))
{
return existingKey;
}
return existingKey;
}
var canonical = CanonicalLocationKey(name);
return groups.ContainsKey(canonical) ? canonical : canonical;
}
private static void AddResolvedName(Dictionary<string, int> map, string name, int locationId)
{
var clean = Clean(name);
@ -957,6 +936,27 @@ public sealed class StoryIntelligenceLocationImportService(
return Regex.Replace(clean, @"\s+and\s*$", string.Empty, RegexOptions.IgnoreCase).Trim();
}
private static string ResolveCandidateKey(
IReadOnlyDictionary<string, LocationCandidate> groups,
string canonicalKey,
string displayName)
{
if (groups.ContainsKey(canonicalKey))
{
return canonicalKey;
}
if (!IsGenericLocation(displayName))
{
return canonicalKey;
}
var contextual = groups.Keys
.Where(key => key.EndsWith($" {canonicalKey}", StringComparison.OrdinalIgnoreCase))
.ToList();
return contextual.Count == 1 ? contextual[0] : canonicalKey;
}
private static string StripLowValueContextSuffix(string value)
{
var clean = Clean(value);
@ -973,6 +973,7 @@ public sealed class StoryIntelligenceLocationImportService(
@"\s+dance\s+floor\s+and\s+bar\s+area\b.*$",
@"\s+and\s+driveway\b.*$",
@"\s+and\s+walk\s+to\b.*$",
@"\s+and\s+waiting\s+area\b.*$",
@"\s+en\s+route\b.*$",
@"\s+and\s+outside\b.*$",
@"\s+and\s+inside\b.*$"