Tighten story intelligence evidence visuals

This commit is contained in:
Nick Beckley 2026-07-26 12:16:34 +00:00
parent d7fe153816
commit 72148565e2
5 changed files with 174 additions and 21 deletions

View File

@ -1044,6 +1044,7 @@ static void Phase21UExtractsMandatoryCharacterAppearanceFixtures()
Assert(facts.Any(fact => fact.CanonicalName == "Maggie" && fact.AttributeType == "HairLength" && fact.NormalisedValue == "Long"), "Maggie's long-hair evidence was not extracted.");
Assert(facts.Any(fact => fact.CanonicalName == "Beth" && fact.AttributeType == "HairColour" && fact.NormalisedValue == "Blonde"), "Beth's blonde evidence was not extracted.");
Assert(facts.Any(fact => fact.CanonicalName == "Rosie" && fact.AttributeType == "HairColour" && fact.NormalisedValue == "Blonde"), "Rosie's blonde evidence was not extracted.");
Assert(!CharacterAppearanceExtractionService.Extract("Beth went next door and mumbled, 'Mum.'", ["Beth", "Mum"]).Any(fact => fact.CanonicalName == "Beth" && fact.AttributeType == "AgeBand" && fact.NormalisedValue == StoryIntelligenceIllustrationCompatibility.AgeBands.MatureAdult), "Nearby Mum text must not age Beth into a mature adult.");
Assert(facts.Any(fact => fact.CanonicalName == "Zoe Davies" && fact.AttributeType == "Alias" && fact.NormalisedValue == "miss davies"), "Miss Davies should resolve as an alias of Zoe Davies.");
Assert(facts.Any(fact => fact.CanonicalName == "Zoe Davies" && fact.AttributeType == "Alias" && fact.NormalisedValue == "zoe"), "Zoe should resolve as an alias of Zoe Davies.");
@ -1074,8 +1075,22 @@ static void Phase21UEnforcesAliasAgeSemanticAndUiResetRules()
var redHairedMaggie = StoryMemoryService.ResolveCharacterEvidenceFromAttributes("Maggie", [
new StoryMemoryCharacterAttribute { AttributeType = "HairColour", NormalisedValue = StoryIntelligenceIllustrationCompatibility.HairColours.Red, Confidence = 0.95m, IsExplicit = true }
]);
var bethFromLeakyEvidence = StoryMemoryService.ResolveCharacterEvidenceFromAttributes("Beth", [
new StoryMemoryCharacterAttribute { AttributeType = "AgeBand", NormalisedValue = StoryIntelligenceIllustrationCompatibility.AgeBands.MatureAdult, Confidence = 0.9m, IsExplicit = true, EvidenceSummary = "Beth goes next door and mumbles 'Mum'" },
new StoryMemoryCharacterAttribute { AttributeType = "AgeBand", NormalisedValue = StoryIntelligenceIllustrationCompatibility.AgeBands.YoungAdult, Confidence = 0.9m, IsExplicit = true, EvidenceSummary = "No fifteen-year-old girl with a future." },
new StoryMemoryCharacterAttribute { AttributeType = "Presentation", NormalisedValue = StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, Confidence = 0.95m, IsExplicit = true, EvidenceSummary = "I checked my look in the mirror." }
]);
var mrsWithTeenContext = StoryMemoryService.ApplyImportContextToEvidence(
StoryMemoryService.ResolveCharacterEvidenceFromAttributes("Mrs Patterson", [
new StoryMemoryCharacterAttribute { AttributeType = "AgeBand", NormalisedValue = StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, Confidence = 0.88m, IsExplicit = true }
]),
"Mrs Patterson",
new StoryMemoryImportContext(StoryMemoryAppearancePreferences.PredominantlyLight, "OlderTeen", "1980s", "UK"));
var childCandidate = Metadata("char-child", "Child", "Feminine");
var darkRedCandidate = Metadata("char-red-dark", "OlderTeen", "Feminine", hairColour: "Red", skinTone: "Dark");
Assert(bethFromLeakyEvidence.AgeBand != StoryIntelligenceIllustrationCompatibility.AgeBands.MatureAdult, $"Leaked family-role evidence should not age Beth up, got {bethFromLeakyEvidence.AgeBand}.");
Assert(bethFromLeakyEvidence.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Name/title guard should repair leaked Beth presentation, got {bethFromLeakyEvidence.Presentation}.");
Assert(mrsWithTeenContext.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, $"Adult-role titles must not be overridden by primary age context, got {mrsWithTeenContext.AgeBand}.");
Assert(Rejected(mum, childCandidate).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Parent role should reject child portraits.");
Assert(Rejected(redHairedMaggie, darkRedCandidate).Any(reason => reason.Contains("skin tone hard mismatch", StringComparison.OrdinalIgnoreCase)), "Red-haired light-skin evidence must reject dark-skin generated portraits.");
Assert(examiner.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, $"Driving examiner should infer Adult, got {examiner.AgeBand}.");

View File

@ -67,6 +67,11 @@ public static class CharacterAppearanceExtractionService
foreach (Match match in AgeRegex.Matches(sourceText))
{
var rawName = match.Groups["name"].Value.Trim();
if (LooksLikeLeakedRelationshipAge(rawName, match.Groups["age"].Value))
{
continue;
}
var age = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence(rawName, [match.Value], []).AgeBand;
if (age != StoryIntelligenceIllustrationCompatibility.AgeBands.Unknown)
{
@ -161,6 +166,23 @@ public static class CharacterAppearanceExtractionService
private static string ShortFragment(string value)
=> Regex.Replace(value.Trim(), @"\s+", " ") is var clean && clean.Length > 160 ? clean[..160].TrimEnd() : clean;
private static bool LooksLikeLeakedRelationshipAge(string rawName, string rawAge)
{
var name = Normalise(rawName);
var age = Normalise(rawAge);
var relationshipAge = age is "mother" or "mum" or "father" or "dad" or "aunt" or "uncle";
if (!relationshipAge)
{
return false;
}
return name is not ("mum" or "mother" or "dad" or "father" or "aunt" or "uncle")
&& !name.StartsWith("mrs ", StringComparison.OrdinalIgnoreCase)
&& !name.StartsWith("miss ", StringComparison.OrdinalIgnoreCase)
&& !name.StartsWith("ms ", StringComparison.OrdinalIgnoreCase)
&& !name.StartsWith("mr ", StringComparison.OrdinalIgnoreCase);
}
private static string Normalise(string? value)
{
var chars = (value ?? string.Empty).Trim().ToLowerInvariant().Select(ch => char.IsLetterOrDigit(ch) ? ch : ' ').ToArray();

View File

@ -1618,14 +1618,19 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
var locations = await storyMemoryRepository.ListLocationsAsync(importSessionId);
var assets = await storyMemoryRepository.ListAssetsAsync(importSessionId);
var attributes = await storyMemoryRepository.ListCharacterAttributesAsync(importSessionId);
var importContext = await storyMemoryRepository.GetImportContextAsync(importSessionId);
var characterIds = characters.ToDictionary(item => item.StoryMemoryCharacterID, item => item.CanonicalIdentityKey);
var locationIds = locations.ToDictionary(item => item.StoryMemoryLocationID, item => item.CanonicalIdentityKey);
var assetIds = assets.ToDictionary(item => item.StoryMemoryAssetID, item => item.CanonicalIdentityKey);
var characterEvidence = characters.ToDictionary(
item => item.CanonicalIdentityKey,
item => StoryMemoryService.ResolveCharacterEvidenceFromAttributes(
item.DisplayName,
attributes.Where(attribute => attribute.StoryMemoryCharacterID == item.StoryMemoryCharacterID)),
item =>
{
var evidence = StoryMemoryService.ResolveCharacterEvidenceFromAttributes(
item.DisplayName,
attributes.Where(attribute => attribute.StoryMemoryCharacterID == item.StoryMemoryCharacterID));
return StoryMemoryService.ApplyImportContextToEvidence(evidence, item.DisplayName, importContext);
},
StringComparer.OrdinalIgnoreCase);
var catalogue = (await illustrationRepository.ListAsync(new IllustrationLibraryFilter()))
.ToDictionary(item => item.IllustrationLibraryItemID);
@ -1673,6 +1678,16 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
CharacterEvidenceProfile? evidence = null;
characterEvidence?.TryGetValue(entityKey, out evidence);
if (entityType == StoryMemoryEntityTypes.Character && evidence is not null)
{
var metadata = StoryIntelligenceIllustrationCompatibility.CharacterMetadataFrom(item);
var rejections = StoryIntelligenceIllustrationCompatibility.CharacterHardRejections(evidence, metadata, true, false);
if (rejections.Count > 0)
{
continue;
}
}
var currentEvidence = evidence is null
? null
: $"age={evidence.AgeBand}; presentation={evidence.Presentation}; hair={evidence.HairColour}; hairLength={evidence.HairLength}; skin={evidence.SkinTone}";

View File

@ -164,7 +164,7 @@ public sealed class StoryMemoryService(
CharacterEvidence(character, attributes.Where(item => item.StoryMemoryCharacterID == character.StoryMemoryCharacterID)),
character.CanonicalIdentityKey,
options.AppearancePreference);
evidence = ApplyPrimaryAgeContext(evidence, options.PrimaryAgeGroup);
evidence = ApplyPrimaryAgeContext(evidence, options.PrimaryAgeGroup, character.DisplayName);
var existing = assignments.FirstOrDefault(item => item.EntityType == StoryMemoryEntityTypes.Character && item.EntityID == character.StoryMemoryCharacterID);
var existingItem = existing?.IllustrationLibraryItemID is int existingId
? catalogue.FirstOrDefault(item => item.IllustrationLibraryItemID == existingId)
@ -545,7 +545,13 @@ public sealed class StoryMemoryService(
: evidence with { SkinTone = skinTone };
}
private static CharacterEvidenceProfile ApplyPrimaryAgeContext(CharacterEvidenceProfile evidence, string primaryAgeGroup)
public static CharacterEvidenceProfile ApplyImportContextToEvidence(CharacterEvidenceProfile evidence, string displayName, StoryMemoryImportContext context)
{
var withAppearance = ApplyAppearancePreference(evidence, Normalise(displayName), context.AppearancePreference);
return ApplyPrimaryAgeContext(withAppearance, context.PrimaryAgeGroup, displayName);
}
private static CharacterEvidenceProfile ApplyPrimaryAgeContext(CharacterEvidenceProfile evidence, string primaryAgeGroup, string displayName)
{
var contextAge = StoryIntelligenceIllustrationCompatibility.NormalizeAgeBand(primaryAgeGroup);
if (contextAge == StoryIntelligenceIllustrationCompatibility.AgeBands.Unknown)
@ -553,8 +559,9 @@ public sealed class StoryMemoryService(
return evidence;
}
return evidence.AgeBand is StoryIntelligenceIllustrationCompatibility.AgeBands.Unknown
or StoryIntelligenceIllustrationCompatibility.AgeBands.Adult
return string.Equals(evidence.AgeBand, StoryIntelligenceIllustrationCompatibility.AgeBands.Unknown, StringComparison.OrdinalIgnoreCase)
|| (string.Equals(evidence.AgeBand, StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, StringComparison.OrdinalIgnoreCase)
&& !NameImpliesAdultRole(displayName))
? evidence with { AgeBand = contextAge, AgeConfidence = Math.Max(evidence.AgeConfidence, 0.74m) }
: evidence;
}
@ -580,8 +587,8 @@ public sealed class StoryMemoryService(
displayName,
[displayName],
[]);
var age = PickAttribute(attributeList, "AgeBand", inferred.AgeBand, inferred.AgeConfidence);
var presentation = PickAttribute(attributeList, "Presentation", inferred.Presentation, inferred.PresentationConfidence);
var age = PickAgeAttribute(attributeList, displayName, inferred.AgeBand, inferred.AgeConfidence);
var presentation = GuardPresentation(displayName, PickAttribute(attributeList, "Presentation", inferred.Presentation, inferred.PresentationConfidence), inferred);
var hairColour = PickAttribute(attributeList, "HairColour", inferred.HairColour, 0.1m);
var skinTone = PickAttribute(attributeList, "SkinTone", inferred.SkinTone, 0.1m);
var hairLength = PickAttribute(attributeList, "HairLength", inferred.HairLength, 0.1m);
@ -624,6 +631,88 @@ public sealed class StoryMemoryService(
: (selected.NormalisedValue, selected.Confidence);
}
private static (string Value, decimal Confidence) PickAgeAttribute(
IReadOnlyList<StoryMemoryCharacterAttribute> attributes,
string displayName,
string fallback,
decimal fallbackConfidence)
{
var selected = attributes
.Where(item => string.Equals(item.AttributeType, "AgeBand", StringComparison.OrdinalIgnoreCase))
.Where(item => !LooksLikeLeakedFamilyAge(displayName, item))
.OrderByDescending(item => item.IsExplicit)
.ThenByDescending(item => item.Confidence)
.ThenByDescending(item => AgeSpecificity(item.NormalisedValue))
.ThenByDescending(item => item.SourceSceneResultID ?? 0)
.FirstOrDefault();
return selected is null
? (fallback, fallbackConfidence)
: (selected.NormalisedValue, selected.Confidence);
}
private static (string Value, decimal Confidence) GuardPresentation(
string displayName,
(string Value, decimal Confidence) selected,
CharacterEvidenceProfile inferred)
{
if (string.Equals(inferred.Presentation, StoryIntelligenceIllustrationCompatibility.Presentations.Unknown, StringComparison.OrdinalIgnoreCase)
|| inferred.PresentationConfidence < 0.9m
|| string.Equals(selected.Value, inferred.Presentation, StringComparison.OrdinalIgnoreCase))
{
return selected;
}
return (inferred.Presentation, Math.Max(selected.Confidence, inferred.PresentationConfidence));
}
private static bool LooksLikeLeakedFamilyAge(string displayName, StoryMemoryCharacterAttribute attribute)
{
if (!string.Equals(attribute.NormalisedValue, StoryIntelligenceIllustrationCompatibility.AgeBands.MatureAdult, StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (NameImpliesAdultRole(displayName))
{
return false;
}
var evidence = attribute.EvidenceSummary ?? string.Empty;
return ContainsAnyLocal(evidence, " mum", "mother", " dad", "father", "aunt", "uncle", "parent");
}
private static bool ContainsAnyLocal(string text, params string[] needles)
=> needles.Any(needle => text.Contains(needle, StringComparison.OrdinalIgnoreCase));
private static int AgeSpecificity(string? ageBand)
=> ageBand switch
{
StoryIntelligenceIllustrationCompatibility.AgeBands.Child => 5,
StoryIntelligenceIllustrationCompatibility.AgeBands.YoungTeen => 5,
StoryIntelligenceIllustrationCompatibility.AgeBands.OlderTeen => 5,
StoryIntelligenceIllustrationCompatibility.AgeBands.YoungAdult => 4,
StoryIntelligenceIllustrationCompatibility.AgeBands.MatureAdult => 4,
StoryIntelligenceIllustrationCompatibility.AgeBands.Senior => 4,
StoryIntelligenceIllustrationCompatibility.AgeBands.Adult => 1,
_ => 0
};
private static bool NameImpliesAdultRole(string? displayName)
{
var clean = Normalise(displayName);
return clean is "mum" or "mother" or "dad" or "father" or "aunt" or "uncle"
|| clean.Contains(" mum", StringComparison.Ordinal)
|| clean.Contains(" mother", StringComparison.Ordinal)
|| clean.Contains(" dad", StringComparison.Ordinal)
|| clean.Contains(" father", StringComparison.Ordinal)
|| clean.Contains(" aunt", StringComparison.Ordinal)
|| clean.Contains(" uncle", StringComparison.Ordinal)
|| clean.StartsWith("mrs ", StringComparison.Ordinal)
|| clean.StartsWith("mr ", StringComparison.Ordinal)
|| clean.StartsWith("dr ", StringComparison.Ordinal)
|| clean.StartsWith("professor ", StringComparison.Ordinal);
}
private static IReadOnlyDictionary<string, string> BuildCrossSceneIdentityAliases(IReadOnlyList<SceneResultWithParse> scenes)
{
var aliases = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);

View File

@ -898,25 +898,30 @@ input:focus-visible {
}
.story-character.is-evidence-card {
width: min(172px, calc(var(--node-size, 150px) + 44px));
height: auto;
width: var(--node-size, 150px);
height: var(--node-size, 150px);
}
.story-character.is-evidence-card .story-character__image {
display: none;
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
}
.story-character.is-evidence-card .story-character__text {
position: relative;
position: absolute;
inset: 0;
top: 0;
left: 0;
width: min(172px, calc(var(--node-size, 150px) + 44px));
min-height: 86px;
width: 100%;
min-height: 100%;
align-content: center;
justify-items: start;
gap: 5px;
border-color: rgba(169, 205, 240, 0.22);
border-radius: 12px;
padding: 10px 11px;
padding: clamp(8px, calc(var(--node-size, 150px) * 0.07), 12px);
background:
linear-gradient(180deg, rgba(16, 34, 58, 0.94), rgba(5, 13, 25, 0.9));
text-align: left;
@ -1066,19 +1071,26 @@ input:focus-visible {
}
.story-asset.is-evidence-card {
width: 132px;
width: var(--node-size, 78px);
height: var(--node-size, 78px);
}
.story-asset.is-evidence-card .story-asset__image {
display: none;
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
}
.story-asset.is-evidence-card .story-asset__text {
position: absolute;
inset: 0;
align-content: center;
justify-items: start;
width: 132px;
min-height: 64px;
width: 100%;
min-height: 100%;
border-color: rgba(215, 168, 93, 0.18);
padding: 8px 9px;
padding: 7px 8px;
background:
linear-gradient(180deg, rgba(34, 29, 18, 0.88), rgba(7, 13, 21, 0.84));
text-align: left;