Phase 21L - Repair Story Intelligence Evidence Extraction

This commit is contained in:
Nick Beckley 2026-07-13 20:40:13 +00:00
parent 2d2055f14a
commit 948ae2e54d
4 changed files with 198 additions and 29 deletions

View File

@ -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"], []);

View File

@ -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);

View File

@ -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)

View File

@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" role="img" aria-label="Unmatched story asset">
<defs>
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1">
<stop offset="0" stop-color="#15253d"/>
<stop offset="1" stop-color="#091521"/>
</linearGradient>
<linearGradient id="edge" x1="0" x2="1" y1="0" y2="1">
<stop offset="0" stop-color="#8fb9df"/>
<stop offset="1" stop-color="#d9a85f"/>
</linearGradient>
</defs>
<rect width="240" height="240" rx="120" fill="url(#bg)"/>
<circle cx="120" cy="120" r="82" fill="none" stroke="url(#edge)" stroke-width="6" opacity=".85"/>
<path d="M84 86h72a10 10 0 0 1 10 10v48a10 10 0 0 1-10 10H84a10 10 0 0 1-10-10V96a10 10 0 0 1 10-10Z" fill="#d8e5f1" opacity=".18"/>
<path d="M96 108h48M96 126h34" stroke="#d8e5f1" stroke-width="8" stroke-linecap="round" opacity=".72"/>
<circle cx="120" cy="176" r="8" fill="#d9a85f"/>
</svg>

After

Width:  |  Height:  |  Size: 931 B