diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 42a1c39..ede9316 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -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}."); diff --git a/PlotLine/Services/CharacterAppearanceExtractionService.cs b/PlotLine/Services/CharacterAppearanceExtractionService.cs index feb09d0..5085ade 100644 --- a/PlotLine/Services/CharacterAppearanceExtractionService.cs +++ b/PlotLine/Services/CharacterAppearanceExtractionService.cs @@ -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(); diff --git a/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs b/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs index 6823385..76300e3 100644 --- a/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs +++ b/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs @@ -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}"; diff --git a/PlotLine/Services/StoryMemoryServices.cs b/PlotLine/Services/StoryMemoryServices.cs index 38eaff2..6fdabac 100644 --- a/PlotLine/Services/StoryMemoryServices.cs +++ b/PlotLine/Services/StoryMemoryServices.cs @@ -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 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 BuildCrossSceneIdentityAliases(IReadOnlyList scenes) { var aliases = new Dictionary(StringComparer.OrdinalIgnoreCase); diff --git a/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css b/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css index f24057c..b47920f 100644 --- a/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css +++ b/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css @@ -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;