From 948ae2e54d2ac72d3667c8365dfc07e8c136a0f2 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Mon, 13 Jul 2026 20:40:13 +0000 Subject: [PATCH] Phase 21L - Repair Story Intelligence Evidence Extraction --- PlotLine.Tests/Program.cs | 32 ++++- ...ryIntelligenceIllustrationCompatibility.cs | 134 +++++++++++++++--- ...ntelligenceVisualisationSnapshotService.cs | 44 +++++- .../prototype/asset-generic.svg | 17 +++ 4 files changed, 198 insertions(+), 29 deletions(-) create mode 100644 PlotLine/wwwroot/images/story-intelligence/prototype/asset-generic.svg diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 44c816b..f10d5bd 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -46,7 +46,8 @@ var tests = new (string Name, Action Test)[] ("Illustration matching infers mother and aunt as mature feminine", IllustrationMatchingInfersMotherAndAuntAsMatureFeminine), ("Illustration matching rejects duplicate named portrait allocation", IllustrationMatchingRejectsDuplicateNamedPortraitAllocation), ("Illustration semantic types prevent bathroom office and ambulance car mismatches", IllustrationSemanticTypesPreventObviousMismatches), - ("Illustration demand archetypes stay broad and use active template", IllustrationDemandArchetypesStayBroadAndUseActiveTemplate) + ("Illustration demand archetypes stay broad and use active template", IllustrationDemandArchetypesStayBroadAndUseActiveTemplate), + ("Story Intelligence evidence extraction prevents observed attribute leakage", StoryIntelligenceEvidenceExtractionPreventsObservedAttributeLeakage) }; foreach (var test in tests) @@ -492,7 +493,7 @@ static void IllustrationMatchingInfersMotherAndAuntAsMatureFeminine() var teen = Metadata("char-young-helper", "YoungTeen", "Androgynous"); var masculine = Metadata("char-middle-aged-weathered-man", "MatureAdult", "Masculine"); - Assert(mother.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.YoungTeen || mother.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.MatureAdult, $"Unexpected mother age inference {mother.AgeBand}."); + Assert(mother.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.MatureAdult, $"Mother should infer MatureAdult, got {mother.AgeBand}."); Assert(mother.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, "Mother should infer Feminine."); Assert(aunt.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.MatureAdult, $"Aunt should infer MatureAdult, got {aunt.AgeBand}."); Assert(aunt.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, "Aunt should infer Feminine."); @@ -521,6 +522,33 @@ static void IllustrationSemanticTypesPreventObviousMismatches() Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("Note", "Suitcase"), "Note must reject suitcase imagery."); } +static void StoryIntelligenceEvidenceExtractionPreventsObservedAttributeLeakage() +{ + var beth = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Beth", ["Beth is fifteen.", "Beth pushed red hair aside."], ["Beth's mother entered."]); + var maggie = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Maggie", ["Maggie is sixteen."], ["her mother spoke."]); + var socialWorker = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("social worker", ["the social worker arrived."], ["Beth pushed red hair aside."]); + var kevin = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Kevin", ["Kevin"], []); + var ladyExaminer = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("lady examiner", ["the lady examiner"], []); + var toddler = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("boy", ["the boy toddler"], []); + + Assert(beth.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.YoungTeen, $"Beth must not become MatureAdult, got {beth.AgeBand}."); + Assert(beth.HairColour == StoryIntelligenceIllustrationCompatibility.HairColours.Red, "Beth's own red-hair evidence should be retained."); + Assert(maggie.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.OlderTeen, $"Maggie at sixteen must not become MatureAdult, got {maggie.AgeBand}."); + Assert(socialWorker.HairColour == StoryIntelligenceIllustrationCompatibility.HairColours.Unknown, $"Social worker should not inherit Beth's red hair, got {socialWorker.HairColour}."); + Assert(kevin.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"Kevin should be Masculine, got {kevin.Presentation}."); + Assert(ladyExaminer.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Lady examiner should be Feminine, got {ladyExaminer.Presentation}."); + Assert(toddler.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Child, $"Toddler should be Child, got {toddler.AgeBand}."); + Assert(toddler.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"Boy toddler should be Masculine, got {toddler.Presentation}."); + + Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Road", "Cock Hill Lane must classify as Road, not interior."); + Assert(StoryIntelligenceIllustrationCompatibility.AssetType("money") == "Money", "Money must not classify as Car."); + Assert(StoryIntelligenceIllustrationCompatibility.AssetType("bottle") == "Bottle", "Bottle must not classify as LockedBox."); + Assert(StoryIntelligenceIllustrationCompatibility.AssetType("scarves") == "Clothing", "Scarves must not classify as Car."); + Assert(StoryIntelligenceIllustrationCompatibility.AssetType("driving licence") == "Document", "Driving licence should classify as Document."); + Assert(StoryIntelligenceIllustrationCompatibility.AssetType("ring") == "Jewellery", "Ring should classify as Jewellery."); + Assert(StoryIntelligenceIllustrationCompatibility.AssetType("gun") == "Weapon", "Gun should classify as Weapon."); +} + static void IllustrationDemandArchetypesStayBroadAndUseActiveTemplate() { var evidence = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Beth", ["Beth", "age 15", "red hair", "light skin"], []); diff --git a/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs b/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs index a65b2d2..a9d5aab 100644 --- a/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs +++ b/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs @@ -49,32 +49,41 @@ public static class StoryIntelligenceIllustrationCompatibility { var identityText = $" {string.Join(' ', identityValues.Where(value => !string.IsNullOrWhiteSpace(value))).ToLowerInvariant()} "; var relationshipText = $" {string.Join(' ', relationshipValues.Where(value => !string.IsNullOrWhiteSpace(value))).ToLowerInvariant()} "; - var allText = $"{identityText} {relationshipText}"; - var titlePresentation = TitlePresentation(identityText); - var relationshipPresentation = RelationshipPresentation(allText); - var pronounPresentation = PronounPresentation(allText); - var namePresentation = NamePresentation($" {name.ToLowerInvariant()} {identityText} "); + var nameText = $" {name.ToLowerInvariant()} "; + var entityText = $"{nameText} {identityText}"; + var scopedText = EntityScopedText(entityText, name); + var relationshipSafeText = $"{nameText} {relationshipText}"; + var titlePresentation = TitlePresentation(scopedText); + var relationshipPresentation = RelationshipPresentation(relationshipSafeText); + var entityPresentation = RelationshipPresentation(scopedText); + var pronounPresentation = PronounPresentation(scopedText); + var namePresentation = NamePresentation(entityText); var presentation = FirstKnown( titlePresentation.Value, + entityPresentation.Value, relationshipPresentation.Value, pronounPresentation.Value, namePresentation.Value, Presentations.Unknown); - var presentationConfidence = new[] { titlePresentation.Confidence, relationshipPresentation.Confidence, pronounPresentation.Confidence, namePresentation.Confidence }.Max(); - var explicitAge = ExplicitAgeBand(allText); - var relationshipAge = RelationshipAgeBand(allText); + var presentationConfidence = new[] { titlePresentation.Confidence, entityPresentation.Confidence, relationshipPresentation.Confidence, pronounPresentation.Confidence, namePresentation.Confidence }.Max(); + var explicitAge = ExplicitAgeBand(scopedText); + var relationshipAge = RelationshipAgeBand(name, scopedText); var namedAge = NameAgeBand(name); var age = FirstKnown(relationshipAge.Value, explicitAge.Value, namedAge.Value, AgeBands.Unknown); var ageConfidence = new[] { explicitAge.Confidence, relationshipAge.Confidence, namedAge.Confidence }.Max(); + var attributeText = EntityAttributeText(entityText, name); + var hairColour = HairColour(attributeText); + var skinTone = SkinTone(attributeText); + var hairLength = HairLength(attributeText); return new( age, presentation, - HairColour(allText), - SkinTone(allText), - HairLength(allText), - ContainsAny(allText, "glasses", "spectacles") ? "Yes" : "Unknown", - ContainsAny(allText, "moustache", "mustache") ? "Moustache" : ContainsAny(allText, "beard", "bearded") ? "Beard" : "Unknown", + hairColour, + skinTone, + hairLength, + ContainsAny(attributeText, "glasses", "spectacles") ? "Yes" : "Unknown", + ContainsAny(attributeText, "moustache", "mustache") ? "Moustache" : ContainsAny(attributeText, "beard", "bearded") ? "Beard" : "Unknown", ageConfidence, presentationConfidence, relationshipPresentation.Confidence, @@ -231,7 +240,7 @@ public static class StoryIntelligenceIllustrationCompatibility if (ContainsAny(text, "office", "desk", "study", "library")) return "Office"; if (text.Contains("school", StringComparison.OrdinalIgnoreCase)) return "School"; if (text.Contains("waiting room", StringComparison.OrdinalIgnoreCase)) return "WaitingRoom"; - if (text.Contains("street", StringComparison.OrdinalIgnoreCase)) return "Street"; + if (ContainsAny(text, "road", "street", "lane", "drive", "motorway", "bridge")) return "Road"; if (ContainsAny(text, "car park", "parking")) return "CarPark"; if (text.Contains("shop", StringComparison.OrdinalIgnoreCase)) return "Shop"; if (text.Contains("hospital", StringComparison.OrdinalIgnoreCase)) return "Hospital"; @@ -246,6 +255,11 @@ public static class StoryIntelligenceIllustrationCompatibility if (text.Contains("stretcher", StringComparison.OrdinalIgnoreCase)) return "Stretcher"; if (text.Contains("body bag", StringComparison.OrdinalIgnoreCase)) return "BodyBag"; if (text.Contains("bottle", StringComparison.OrdinalIgnoreCase)) return "Bottle"; + if (ContainsAny(text, "money", "cash", "coins", "banknote")) return "Money"; + if (ContainsAny(text, "scarf", "scarves")) return "Clothing"; + if (ContainsAny(text, "driving licence", "driver licence", "license", "licence")) return "Document"; + if (ContainsAny(text, "ring", "necklace", "jewellery", "jewelry")) return "Jewellery"; + if (ContainsAny(text, "gun", "pistol", "rifle", "weapon")) return "Weapon"; if (text.Contains("rucksack", StringComparison.OrdinalIgnoreCase)) return "Rucksack"; if (text.Contains("suitcase", StringComparison.OrdinalIgnoreCase)) return "Suitcase"; if (text.Contains("note", StringComparison.OrdinalIgnoreCase)) return "Note"; @@ -254,7 +268,7 @@ public static class StoryIntelligenceIllustrationCompatibility if (text.Contains("book", StringComparison.OrdinalIgnoreCase)) return "Book"; if (ContainsAny(text, "telephone", "phone", "recorder")) return "Telephone"; if (text.Contains("key", StringComparison.OrdinalIgnoreCase)) return "Keys"; - if (ContainsAny(text, "car", "vehicle")) return "Car"; + if (ContainsAny(text, "tr6", " car ", "vehicle")) return "Car"; if (ContainsAny(text, "photo", "photograph")) return "Photograph"; if (ContainsAny(text, "document", "paper")) return "Document"; return "UnknownObject"; @@ -283,9 +297,16 @@ public static class StoryIntelligenceIllustrationCompatibility return EvidenceSignal.Unknown; } - private static EvidenceSignal RelationshipAgeBand(string text) + private static EvidenceSignal RelationshipAgeBand(string name, string text) { - if (ContainsAny(text, "mother", " mum", "father", " dad", "parent", "aunt", "uncle", "teacher")) return new(AgeBands.MatureAdult, 0.74m); + if (ContainsAny(text, "toddler", "baby", "newborn")) return new(AgeBands.Child, 0.96m); + var identity = $"{name} {text}"; + if (ContainsAny(identity, "mother", " mum", "father", " dad", "parent", "aunt", "uncle", "teacher") + && !PossessiveOfOtherIdentity(name, text)) + { + return new(AgeBands.MatureAdult, 0.74m); + } + if (ContainsAny(text, "grandmother", "grandfather")) return new(AgeBands.Senior, 0.82m); if (ContainsAny(text, "daughter", "son", "sister", "brother")) return new(AgeBands.Unknown, 0.3m); return EvidenceSignal.Unknown; @@ -299,15 +320,15 @@ public static class StoryIntelligenceIllustrationCompatibility private static EvidenceSignal TitlePresentation(string text) { - if (ContainsAny(text, " mrs ", " miss ", " ms ")) return new(Presentations.Feminine, 0.96m); - if (ContainsAny(text, " mr ")) return new(Presentations.Masculine, 0.96m); + if (ContainsAny(text, " mrs ", " mrs. ", " miss ", " miss. ", " ms ", " ms. ", " lady ")) return new(Presentations.Feminine, 0.96m); + if (ContainsAny(text, " mr ", " mr. ", " sir ")) return new(Presentations.Masculine, 0.96m); return EvidenceSignal.Unknown; } private static EvidenceSignal RelationshipPresentation(string text) { - if (ContainsAny(text, "mother", " mum", "mummy", "aunt", "sister", "daughter", "wife", "grandmother", " niece", "woman", "girl")) return new(Presentations.Feminine, 0.92m); - if (ContainsAny(text, "father", " dad", "daddy", "uncle", "brother", "son", "husband", "grandfather", " nephew", "man", "boy", "lad")) return new(Presentations.Masculine, 0.92m); + if (ContainsAny(text, "mother", " mum", "mummy", "aunt", "sister", "daughter", "wife", "grandmother", " niece", "woman", "girl", "lady", "queen")) return new(Presentations.Feminine, 0.92m); + if (ContainsAny(text, "father", " dad", "daddy", "uncle", "brother", "son", "husband", "grandfather", " nephew", "man", "boy", "lad", "king")) return new(Presentations.Masculine, 0.92m); return EvidenceSignal.Unknown; } @@ -367,6 +388,77 @@ public static class StoryIntelligenceIllustrationCompatibility private static bool ContainsAny(string text, params string[] needles) => needles.Any(needle => text.Contains(needle, StringComparison.OrdinalIgnoreCase)); + private static string EntityAttributeText(string text, string entityName) + { + var cleanName = (entityName ?? string.Empty).Trim().ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(cleanName)) + { + return text; + } + + var sentences = text.Split(['.', '!', '?', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var scoped = sentences + .Where(sentence => sentence.Contains(cleanName, StringComparison.OrdinalIgnoreCase)) + .Where(sentence => AttributeNearEntity(sentence, cleanName)) + .ToList(); + return scoped.Count == 0 ? $" {cleanName} " : $" {string.Join(' ', scoped)} "; + } + + private static string EntityScopedText(string text, string entityName) + { + var cleanName = (entityName ?? string.Empty).Trim().ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(cleanName)) + { + return text; + } + + var sentences = text.Split(['.', '!', '?', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var scoped = sentences.Where(sentence => sentence.Contains(cleanName, StringComparison.OrdinalIgnoreCase)).ToList(); + return scoped.Count == 0 ? $" {cleanName} " : $" {string.Join(' ', scoped)} "; + } + + private static bool AttributeNearEntity(string sentence, string cleanName) + { + var lower = sentence.ToLowerInvariant(); + var entityIndex = lower.IndexOf(cleanName, StringComparison.Ordinal); + if (entityIndex < 0) + { + return false; + } + + var attributeIndex = new[] + { + lower.IndexOf("hair", StringComparison.Ordinal), + lower.IndexOf("skin", StringComparison.Ordinal), + lower.IndexOf("glasses", StringComparison.Ordinal), + lower.IndexOf("spectacles", StringComparison.Ordinal), + lower.IndexOf("beard", StringComparison.Ordinal), + lower.IndexOf("moustache", StringComparison.Ordinal), + lower.IndexOf("mustache", StringComparison.Ordinal) + } + .Where(index => index >= 0) + .DefaultIfEmpty(-1) + .Min(); + return attributeIndex >= 0 && Math.Abs(attributeIndex - entityIndex) <= 48; + } + + private static bool PossessiveOfOtherIdentity(string name, string text) + { + var cleanName = (name ?? string.Empty).Trim().ToLowerInvariant(); + if (cleanName.Contains("mother", StringComparison.Ordinal) + || cleanName.Contains("mum", StringComparison.Ordinal) + || cleanName.Contains("father", StringComparison.Ordinal) + || cleanName.Contains("dad", StringComparison.Ordinal) + || cleanName.Contains("aunt", StringComparison.Ordinal) + || cleanName.Contains("uncle", StringComparison.Ordinal) + || cleanName.Contains("teacher", StringComparison.Ordinal)) + { + return false; + } + + return ContainsAny(text, "'s mother", "'s mum", "'s father", "'s dad", "'s aunt", "'s uncle", " her mother", " his mother", " her mum", " his dad"); + } + private sealed record EvidenceSignal(string Value, decimal Confidence) { public static EvidenceSignal Unknown { get; } = new("Unknown", 0); diff --git a/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs b/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs index 81b748e..e5373c7 100644 --- a/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs +++ b/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs @@ -441,7 +441,11 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( } } - return states.OrderByDescending(character => character.Weight).ToList(); + return states + .OrderByDescending(character => character.Weight) + .GroupBy(character => character.Id, StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .ToList(); } private static string? ResolveNarratorName(SceneIntelligenceScene scene) @@ -1685,7 +1689,35 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( ImagePath = $"{FallbackRoot}/location-flat.svg" }; - private static string CharacterId(string? name) => $"character-result-{StableKey(name)}"; + private static string CharacterId(string? name) => $"character-result-{StableKey(CanonicalCharacterIdentity(name))}"; + + private static string CanonicalCharacterIdentity(string? name) + { + var clean = Clean(name).ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(clean)) + { + return "unknown"; + } + + if (clean is "mother" or "mum" or "mummy" or "her mother" or "the mother" or "my mother" or "my mum" + || clean.Contains("beth's mum", StringComparison.Ordinal) + || clean.Contains("beth's mother", StringComparison.Ordinal)) + { + return "beth mother"; + } + + if (clean is "aunt" or "her aunt" or "my aunt" or "aunt elen" || clean.Contains("beth's aunt", StringComparison.Ordinal)) + { + return "beth aunt"; + } + + if (IsGenericNarratorName(clean)) + { + return "narrator"; + } + + return clean; + } private static int CharacterWeight(SceneIntelligenceCharacter character) => character.MentionedOnly == true ? 46 : ConfidenceWeight(character.Confidence, 72); @@ -1732,9 +1764,9 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( if (type is "Kitchen" or "Bedroom" or "LivingRoom" or "WaitingRoom" or "FlatInterior" or "Hallway") return "loc-small-flat-interior"; if (type == "Office") return "loc-domestic-desk"; if (type == "Stairwell") return "loc-narrow-stairwell"; - if (type == "CarPark") return "loc-wet-car-park"; + if (type is "CarPark" or "Road") return "loc-wet-car-park"; if (type == "HouseExterior") return "loc-estate-house-exterior"; - return "loc-domestic-desk"; + return string.Empty; } private static string AssetLibraryCode(string name, SceneIntelligenceAsset asset) @@ -1748,7 +1780,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( if (type == "Telephone") return "asset-phone-recorder"; if (type == "Photograph") return "asset-photo-print"; if (type == "Rucksack") return "asset-rucksack"; - return "asset-locked-box"; + return string.Empty; } private static string CharacterFallback(string name) @@ -1786,7 +1818,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( "asset-red-classic-car" => $"{FallbackRoot}/asset-red-tr6.svg", "asset-old-keys" => $"{FallbackRoot}/asset-keys.svg", "asset-phone-recorder" => $"{FallbackRoot}/asset-phone.svg", - _ => $"{FallbackRoot}/asset-letter.svg" + _ => $"{FallbackRoot}/asset-generic.svg" }; private static string StableKey(string? value) diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/asset-generic.svg b/PlotLine/wwwroot/images/story-intelligence/prototype/asset-generic.svg new file mode 100644 index 0000000..bbd3e64 --- /dev/null +++ b/PlotLine/wwwroot/images/story-intelligence/prototype/asset-generic.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + +