From a54da80ea2cc665b59a01ba7e34870a90017687d Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Mon, 13 Jul 2026 21:36:52 +0000 Subject: [PATCH] Phase 21N - Fix Entity Evidence Corruption --- PlotLine.Tests/Program.cs | 10 ++ ...ryIntelligenceIllustrationCompatibility.cs | 97 ++++++++++++++++++- ...IntelligenceIllustrationMatchingService.cs | 30 +++++- ...ntelligenceVisualisationSnapshotService.cs | 2 +- ...telligenceExperiencePrototypeViewModels.cs | 1 + ...IntelligenceIllustrationDiagnostics.cshtml | 6 +- 6 files changed, 136 insertions(+), 10 deletions(-) diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 355e25b..6562e8d 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -568,6 +568,8 @@ static void IllustrationAssignmentRepairEnforcesExplicitEvidenceGroupsAndStrictS var lad = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("tubby lad", ["tubby lad"], []); var girl = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("girl", ["schoolgirl"], []); var kevin = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Kevin", ["Kevin", "female instructor (Kevin's)"], ["female instructor", "Kevin's examiner"]); + var colin = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Colin", ["Colin mother waits nearby", "female instructor"], []); + var david = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("David", ["David the woman speaks first", "female examiner"], []); Assert(femaleInstructor.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"female instructor should be Feminine, got {femaleInstructor.Presentation}."); Assert(maleExaminers.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"male examiners should be Masculine, got {maleExaminers.Presentation}."); @@ -576,6 +578,10 @@ static void IllustrationAssignmentRepairEnforcesExplicitEvidenceGroupsAndStrictS Assert(girl.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"girl should be Feminine, got {girl.Presentation}."); Assert(girl.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Child, $"schoolgirl should infer Child, got {girl.AgeBand}."); Assert(kevin.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"Kevin should not inherit feminine presentation from his instructor, got {kevin.Presentation}."); + Assert(colin.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"Colin should not inherit feminine presentation from neighbouring evidence, got {colin.Presentation}."); + Assert(colin.Warnings.Any(warning => warning.Contains("corrected Colin", StringComparison.OrdinalIgnoreCase)), "Colin correction should be visible in diagnostics warnings."); + Assert(david.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"David should not inherit feminine presentation from neighbouring evidence, got {david.Presentation}."); + Assert(david.Warnings.Any(warning => warning.Contains("corrected David", StringComparison.OrdinalIgnoreCase)), "David correction should be visible in diagnostics warnings."); Assert(StoryIntelligenceIllustrationCompatibility.IsGroupEntity("social workers"), "Social workers should be treated as a group entity."); Assert(StoryIntelligenceIllustrationCompatibility.IsGroupEntity("younger male examiners"), "Plural examiners should be treated as a group entity."); Assert(StoryIntelligenceIllustrationCompatibility.IsGroupEntity("uniformed people"), "Uniformed people should be treated as a group entity."); @@ -589,6 +595,10 @@ static void IllustrationAssignmentRepairEnforcesExplicitEvidenceGroupsAndStrictS Assert(StoryIntelligenceIllustrationCompatibility.AssetType("supercharger") == "CarPart", "Supercharger should not classify as full car."); Assert(StoryIntelligenceIllustrationCompatibility.AssetType("biscuit tin") == "Tin", "Biscuit tin should not classify as Photograph."); Assert(StoryIntelligenceIllustrationCompatibility.AssetType("parcel") == "Parcel", "Parcel should not classify as Letter."); + Assert(StoryIntelligenceIllustrationCompatibility.AssetType("pass certificate") == "Document", "Pass certificate should classify as Document."); + Assert(StoryIntelligenceIllustrationCompatibility.AssetType("driving licence") == "Document", "Driving licence should classify as Document."); + Assert(StoryIntelligenceIllustrationCompatibility.AssetType("thin cotton blouse") == "Clothing", "Blouse should classify as Clothing."); + Assert(StoryIntelligenceIllustrationCompatibility.AssetType("two pairs of sunglasses") == "Clothing", "Sunglasses should classify as Clothing."); var beth = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Beth", ["Beth is fifteen."], []); var adult = Metadata("char-family-relative", "MatureAdult", "Feminine"); diff --git a/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs b/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs index 0a9dcdb..6bf791a 100644 --- a/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs +++ b/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs @@ -67,6 +67,15 @@ public static class StoryIntelligenceIllustrationCompatibility namePresentation.Value, Presentations.Unknown); var presentationConfidence = new[] { titlePresentation.Confidence, entityPresentation.Confidence, relationshipPresentation.Confidence, pronounPresentation.Confidence, namePresentation.Confidence }.Max(); + var warnings = new List(); + var guardedPresentation = ApplyPresentationGuard( + name, + scopedText, + presentation, + presentationConfidence, + warnings); + presentation = guardedPresentation.Value; + presentationConfidence = guardedPresentation.Confidence; var explicitAge = ExplicitAgeBand(scopedText); var relationshipAge = RelationshipAgeBand(name, scopedText); var namedAge = NameAgeBand(name); @@ -89,7 +98,8 @@ public static class StoryIntelligenceIllustrationCompatibility presentationConfidence, relationshipPresentation.Confidence, namePresentation.Confidence, - pronounPresentation.Confidence); + pronounPresentation.Confidence, + warnings); } public static IllustrationCharacterMetadata CharacterMetadataFrom(IllustrationLibraryItem item) @@ -259,13 +269,14 @@ public static class StoryIntelligenceIllustrationCompatibility 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, "driving licence", "driver licence", "provisional licence", "license", "licence", "pass certificate", "certificate", "paperwork")) return "Document"; if (ContainsAny(text, "ring", "necklace", "jewellery", "jewelry")) return "Jewellery"; if (ContainsAny(text, "gun", "pistol", "rifle", "weapon")) return "Weapon"; if (ContainsAny(padded, " ford fiesta ", " fiesta ", " motor car ", " car ", " vehicle ")) return "Car"; if (ContainsAny(text, "supercharger", "engine part")) return "CarPart"; if (ContainsAny(text, "biscuit tin", "tin")) return "Tin"; if (ContainsAny(text, "parcel", "package")) return "Parcel"; + if (ContainsAny(text, "blouse", "skirt", "jumper", "jumpers", "sunglasses", "stockings")) return "Clothing"; if (text.Contains("rucksack", StringComparison.OrdinalIgnoreCase)) return "Rucksack"; if (text.Contains("suitcase", StringComparison.OrdinalIgnoreCase)) return "Suitcase"; if (text.Contains("note", StringComparison.OrdinalIgnoreCase)) return "Note"; @@ -306,6 +317,9 @@ public static class StoryIntelligenceIllustrationCompatibility || clean is "crowd" or "uniformed people" or "police" or "social services"; } + public static string PresentationGuardWarning(string name, string attemptedPresentation, string correctedPresentation) + => $"WARNING: Entity evidence guard corrected {name} presentation from {attemptedPresentation} to {correctedPresentation}; title/name hard evidence overrode leaked or unrelated evidence."; + private static EvidenceSignal ExplicitAgeBand(string text) { if (ContainsAny(text, "15", "fifteen")) return new(AgeBands.YoungTeen, 0.98m); @@ -369,6 +383,73 @@ public static class StoryIntelligenceIllustrationCompatibility return EvidenceSignal.Unknown; } + private static EvidenceSignal ApplyPresentationGuard(string name, string scopedText, string presentation, decimal confidence, ICollection warnings) + { + var nameSignal = NameOrTitlePresentation(name); + if (nameSignal.Value == Presentations.Unknown) + { + return new(presentation, confidence); + } + + if (presentation == Presentations.Unknown || presentation == nameSignal.Value) + { + return new(nameSignal.Value, Math.Max(confidence, nameSignal.Confidence)); + } + + if (HasExplicitContradictoryPresentation(name, scopedText, presentation)) + { + warnings.Add($"WARNING: {name} has explicit contradictory presentation evidence: {presentation} conflicts with {nameSignal.Value} name/title evidence."); + return new(presentation, confidence); + } + + warnings.Add(PresentationGuardWarning(name, presentation, nameSignal.Value)); + return new(nameSignal.Value, Math.Max(confidence, nameSignal.Confidence)); + } + + private static EvidenceSignal NameOrTitlePresentation(string name) + { + var clean = CleanWords(name); + if (clean.Length == 0) + { + return EvidenceSignal.Unknown; + } + + if (StartsWithAny(clean, "mr", "father", "dad", "uncle", "brother", "son", "boy", "man", "male") + || clean is "graham" or "george" or "john" or "david" or "simon" or "kevin" or "colin") + { + return new(Presentations.Masculine, 0.98m); + } + + if (StartsWithAny(clean, "mrs", "miss", "ms", "mother", "mum", "mom", "aunt", "sister", "daughter", "girl", "woman", "female") + || clean is "beth" or "maggie" or "rosie" or "grace" or "annie" or "helen" or "elen") + { + return new(Presentations.Feminine, 0.98m); + } + + return EvidenceSignal.Unknown; + } + + private static bool HasExplicitContradictoryPresentation(string name, string scopedText, string attemptedPresentation) + { + var clean = CleanWords(name); + if (clean.Length == 0 || string.IsNullOrWhiteSpace(scopedText)) + { + return false; + } + + var text = CleanWords(scopedText); + var contradictoryTerms = attemptedPresentation == Presentations.Feminine + ? new[] { "female", "woman", "girl" } + : attemptedPresentation == Presentations.Masculine + ? new[] { "male", "man", "boy" } + : []; + + return contradictoryTerms.Any(term => + text.Contains($"{clean} is {term}", StringComparison.OrdinalIgnoreCase) + || text.Contains($"{clean} was {term}", StringComparison.OrdinalIgnoreCase) + || text.Contains($"{clean} as {term}", StringComparison.OrdinalIgnoreCase)); + } + private static string HairColour(string text) => NormalizeHairColour(text); private static string SkinTone(string text) => NormalizeSkinTone(text); private static string HairLength(string text) => NormalizeHairLength(text); @@ -411,6 +492,15 @@ public static class StoryIntelligenceIllustrationCompatibility private static bool ContainsAny(string text, params string[] needles) => needles.Any(needle => text.Contains(needle, StringComparison.OrdinalIgnoreCase)); + private static bool StartsWithAny(string text, params string[] prefixes) + => prefixes.Any(prefix => text.Equals(prefix, StringComparison.OrdinalIgnoreCase) || text.StartsWith(prefix + " ", StringComparison.OrdinalIgnoreCase)); + + private static string CleanWords(string value) + { + var chars = value.Trim().ToLowerInvariant().Select(ch => char.IsLetterOrDigit(ch) ? ch : ' ').ToArray(); + return string.Join(' ', new string(chars).Split(' ', StringSplitOptions.RemoveEmptyEntries)); + } + private static string EntityAttributeText(string text, string entityName) { var cleanName = (entityName ?? string.Empty).Trim().ToLowerInvariant(); @@ -529,7 +619,8 @@ public sealed record CharacterEvidenceProfile( decimal PresentationConfidence, decimal RelationshipConfidence, decimal NameConfidence, - decimal PronounConfidence) + decimal PronounConfidence, + IReadOnlyList Warnings) { public bool HasStrongPresentationEvidence => PresentationConfidence >= 0.8m; } diff --git a/PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs b/PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs index 7d18bd9..30be30e 100644 --- a/PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs +++ b/PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs @@ -106,7 +106,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService( { var demandStatus = await RecordDemandAsync(projectId, observation, allScores); var fallbackCandidateDiagnostics = JsonSerializer.Serialize(allScores.Take(6).Select(MatchDiagnostics.From), JsonOptions); - var fallbackEvidenceJson = JsonSerializer.Serialize(observation.Evidence, JsonOptions); + var fallbackEvidenceJson = EvidenceTraceJson(observation, existing); await assignments.UpsertAssignmentAsync(new StoryIntelligenceCharacterIllustrationAssignmentSave( projectId, observation.CharacterKey, @@ -132,7 +132,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService( ? $"Assigned {chosen.Candidate.Item.StableCode} from structured character evidence." : $"Retained {chosen.Candidate.Item.StableCode}; existing assignment passed hard revalidation with score {chosen.Score:N0}."); var candidateDiagnostics = JsonSerializer.Serialize(allScores.Take(6).Select(MatchDiagnostics.From), JsonOptions); - var evidenceJson = JsonSerializer.Serialize(observation.Evidence, JsonOptions); + var evidenceJson = EvidenceTraceJson(observation, existing); var saved = await assignments.UpsertAssignmentAsync(new StoryIntelligenceCharacterIllustrationAssignmentSave( projectId, @@ -240,6 +240,22 @@ public sealed class StoryIntelligenceIllustrationMatchingService( private static string DemandKey(CharacterEvidence evidence) => string.Join('|', "character", evidence.AgeBand, evidence.Presentation, evidence.HairColour, evidence.SkinTone).ToLowerInvariant(); + private static string EvidenceTraceJson(CharacterObservation observation, StoryIntelligenceCharacterIllustrationAssignment? existing) + => JsonSerializer.Serialize(new + { + character = observation.Name, + identityKey = observation.CharacterKey, + canonicalIdentity = observation.CharacterKey, + rawExtractedEvidence = observation.IdentityEvidence, + sceneEvidence = observation.TextEvidence, + relationshipEvidence = observation.RelationshipEvidence, + mergedEvidence = observation.TextEvidence.Concat(observation.RelationshipEvidence).ToArray(), + previousStoredEvidence = existing?.EvidenceJson, + finalMatcherEvidence = observation.Evidence, + evidenceSource = "Story Intelligence visualisation character state", + canonicalMergeReason = "Character state ID from canonical visualisation identity" + }, JsonOptions); + private static IllustrationGenerationSpecification DemandSpecification(StoryIntelligenceIllustrationDemand demand) { var age = NormaliseUnknown(demand.AgeBand, "YoungAdult"); @@ -482,6 +498,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService( PreviousEvidence = existing?.EvidenceJson, CurrentEvidence = evidenceJson, DemandKey = DemandKey(observation.Evidence), + EvidenceWarnings = string.Join(" ", observation.Evidence.Warnings), DemandStatus = "Satisfied", ResolvedPresentation = observation.Evidence.Presentation, ResolvedAgeBand = observation.Evidence.AgeBand, @@ -520,8 +537,9 @@ public sealed class StoryIntelligenceIllustrationMatchingService( MatchingScore = allScores.FirstOrDefault()?.Score, RejectedCandidates = diagnostics, ReasonChosen = "No suitable unused illustration matched structured character evidence.", - CurrentEvidence = JsonSerializer.Serialize(observation.Evidence, JsonOptions), + CurrentEvidence = EvidenceTraceJson(observation, null), DemandKey = DemandKey(observation.Evidence), + EvidenceWarnings = string.Join(" ", observation.Evidence.Warnings), DemandStatus = demandStatus, ResolvedPresentation = observation.Evidence.Presentation, ResolvedAgeBand = observation.Evidence.AgeBand, @@ -584,7 +602,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService( FallbackImageUrl = character.ImagePath, FallbackUsed = true, ReasonChosen = "Collective group entity; reusable group fallback used without unique portrait assignment or demand.", - CurrentEvidence = JsonSerializer.Serialize(observation.Evidence, JsonOptions), + CurrentEvidence = EvidenceTraceJson(observation, null), DemandStatus = "Not required for group entity", ResolvedPresentation = "Unknown", ResolvedAgeBand = "Unknown", @@ -627,10 +645,11 @@ public sealed class StoryIntelligenceIllustrationMatchingService( decimal RelationshipConfidence, decimal NameConfidence, decimal PronounConfidence, + IReadOnlyList Warnings, CharacterEvidenceProfile Profile) { public bool HasStrongPresentationEvidence => RelationshipConfidence >= 0.8m || NameConfidence >= 0.75m || PronounConfidence >= 0.8m; - public static CharacterEvidence Unknown { get; } = From(new CharacterEvidenceProfile("Unknown", "Unknown", "Unknown", "Unknown", "Unknown", "Unknown", "Unknown", 0, 0, 0, 0, 0)); + public static CharacterEvidence Unknown { get; } = From(new CharacterEvidenceProfile("Unknown", "Unknown", "Unknown", "Unknown", "Unknown", "Unknown", "Unknown", 0, 0, 0, 0, 0, [])); public static CharacterEvidence From(CharacterEvidenceProfile profile) => new( profile.AgeBand, @@ -643,6 +662,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService( profile.RelationshipConfidence, profile.NameConfidence, profile.PronounConfidence, + profile.Warnings, profile); } diff --git a/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs b/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs index 73426f8..b2e3c9f 100644 --- a/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs +++ b/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs @@ -1683,7 +1683,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( => new() { Id = "location-pending", - LibraryCode = "loc-domestic-desk", + LibraryCode = string.Empty, Name = "Location not yet identified", Label = "Awaiting scene location", ImagePath = $"{FallbackRoot}/location-flat.svg" diff --git a/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs b/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs index 2b16007..12bf41a 100644 --- a/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs +++ b/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs @@ -155,6 +155,7 @@ public sealed class StoryIntelligenceExperienceImageResolution public string? PreviousEvidence { get; init; } public string? CurrentEvidence { get; init; } public string? DemandKey { get; init; } + public string? EvidenceWarnings { get; init; } public string? DemandStatus { get; init; } public string? ResolvedPresentation { get; init; } public string? ResolvedAgeBand { get; init; } diff --git a/PlotLine/Views/Development/StoryIntelligenceIllustrationDiagnostics.cshtml b/PlotLine/Views/Development/StoryIntelligenceIllustrationDiagnostics.cshtml index 49892a0..1785685 100644 --- a/PlotLine/Views/Development/StoryIntelligenceIllustrationDiagnostics.cshtml +++ b/PlotLine/Views/Development/StoryIntelligenceIllustrationDiagnostics.cshtml @@ -39,7 +39,11 @@ @row.Scene.SceneNumber @row.Character.Name
@row.Character.Id @(row.Character.ImageResolution.StableCode ?? row.Character.LibraryCode)
@row.Character.ImageResolution.Status - Age @Display(row.Character.ImageResolution.ResolvedAgeBand)
Presentation @Display(row.Character.ImageResolution.ResolvedPresentation) + + Age @Display(row.Character.ImageResolution.ResolvedAgeBand)
+ Presentation @Display(row.Character.ImageResolution.ResolvedPresentation)
+ @Display(row.Character.ImageResolution.EvidenceWarnings) + @Revalidation(row.Character.ImageResolution) @Display(row.Character.ImageResolution.ReasonChosen)
@Display(row.Character.ImageResolution.IllustrationAllocationStatus)