diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 7ecf0d3..44c816b 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -40,7 +40,13 @@ var tests = new (string Name, Action Test)[] ("Illustration bulk retry result reports counts", IllustrationBulkRetryResultReportsCounts), ("Illustration bulk retry skips blocking duplicate stable codes", IllustrationBulkRetrySkipsBlockingDuplicateStableCodes), ("Illustration provider reports missing image model", IllustrationProviderReportsMissingImageModel), - ("Illustration provider omits GPT image response format", IllustrationProviderOmitsGptImageResponseFormat) + ("Illustration provider omits GPT image response format", IllustrationProviderOmitsGptImageResponseFormat), + ("Illustration matching rejects adult masculine unknown art for Beth", IllustrationMatchingRejectsAdultMasculineUnknownArtForBeth), + ("Illustration matching treats Mrs title as hard feminine evidence", IllustrationMatchingTreatsMrsTitleAsHardFeminineEvidence), + ("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) }; foreach (var test in tests) @@ -453,6 +459,97 @@ static void StoryIntelligencePrototypeUsesStarterIllustrationCodes() Assert(prototype.Scenes.SelectMany(scene => scene.Assets).All(asset => asset.ImagePath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase)), "Asset SVG fallback should remain before library resolution."); } +static void IllustrationMatchingRejectsAdultMasculineUnknownArtForBeth() +{ + var beth = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Beth", ["Beth", "age 15", "fifteen-year-old girl"], []); + var middleAged = Metadata("char-family-relative", "MatureAdult", "Feminine"); + var masculine = Metadata("char-middle-aged-weathered-man", "MatureAdult", "Masculine"); + var unknown = Metadata("char-unknown-figure", "YoungAdult", "Androgynous", isUnknownFigure: true); + + Assert(beth.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.YoungTeen, $"Beth age should be YoungTeen, got {beth.AgeBand}."); + Assert(Rejected(beth, middleAged).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Beth should reject mature adult art."); + Assert(Rejected(beth, masculine).Any(reason => reason.Contains("presentation hard mismatch", StringComparison.OrdinalIgnoreCase)), "Beth should reject masculine art."); + Assert(Rejected(beth, unknown).Any(reason => reason.Contains("Unknown Figure", StringComparison.OrdinalIgnoreCase)), "Beth should reject Unknown Figure art."); +} + +static void IllustrationMatchingTreatsMrsTitleAsHardFeminineEvidence() +{ + var mrsPatterson = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Mrs Patterson", ["Mrs Patterson", "neighbour"], []); + var masculine = Metadata("char-archival-contact", "Senior", "Masculine"); + var androgynous = Metadata("char-neighbourhood-friend", "YoungAdult", "Androgynous"); + var feminine = Metadata("char-family-relative", "MatureAdult", "Feminine"); + + Assert(mrsPatterson.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Mrs title should infer Feminine, got {mrsPatterson.Presentation}."); + Assert(Rejected(mrsPatterson, masculine).Any(reason => reason.Contains("presentation hard mismatch", StringComparison.OrdinalIgnoreCase)), "Mrs Patterson should reject masculine art."); + Assert(Rejected(mrsPatterson, androgynous).Count == 0, "Androgynous should not be hard-rejected solely by title."); + Assert(Rejected(mrsPatterson, feminine).Count == 0, "Suitable feminine candidate should remain valid."); +} + +static void IllustrationMatchingInfersMotherAndAuntAsMatureFeminine() +{ + var mother = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Beth's mother", ["her mother", "mother of Beth", "Beth is 15"], []); + var aunt = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Aunt Elen", ["Beth's aunt", "Aunt Elen"], []); + 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.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."); + Assert(Rejected(mother, masculine).Any(reason => reason.Contains("presentation hard mismatch", StringComparison.OrdinalIgnoreCase)), "Mother should reject masculine art."); + Assert(Rejected(aunt, teen).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Aunt should reject teen art."); +} + +static void IllustrationMatchingRejectsDuplicateNamedPortraitAllocation() +{ + var rosie = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Rosie", ["Rosie", "young woman"], []); + var candidate = Metadata("char-young-adult-red-haired-witness", "YoungAdult", "Feminine"); + var rejected = StoryIntelligenceIllustrationCompatibility.CharacterHardRejections(rosie, candidate, isNamedCharacter: true, alreadyAssignedToAnotherSignificantCharacter: true); + + Assert(rejected.Any(reason => reason.Contains("already assigned", StringComparison.OrdinalIgnoreCase)), "Distinct significant named characters should not silently share portraits."); +} + +static void IllustrationSemanticTypesPreventObviousMismatches() +{ + Assert(StoryIntelligenceIllustrationCompatibility.LocationType("bathroom at Beth's house") == "Bathroom", "Bathroom should classify as Bathroom."); + Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Bathroom", "Office"), "Bathroom must reject office imagery."); + Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Kitchen", "HouseExterior"), "Kitchen must reject house exterior imagery."); + Assert(StoryIntelligenceIllustrationCompatibility.LocationCompatible("Kitchen", "FlatInterior"), "Kitchen may use generic interior fallback."); + Assert(StoryIntelligenceIllustrationCompatibility.AssetType("ambulance") == "Ambulance", "Ambulance should classify as Ambulance."); + Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("Ambulance", "Car"), "Ambulance must reject sports car imagery."); + Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("BodyBag", "Suitcase"), "Body bag must reject suitcase/box imagery."); + Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("Note", "Suitcase"), "Note must reject suitcase imagery."); +} + +static void IllustrationDemandArchetypesStayBroadAndUseActiveTemplate() +{ + var evidence = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Beth", ["Beth", "age 15", "red hair", "light skin"], []); + var key = string.Join('|', "character", evidence.AgeBand, evidence.Presentation, evidence.HairColour, evidence.SkinTone).ToLowerInvariant(); + + Assert(key == "character|youngteen|feminine|red|light", $"Demand key was too broad or too specific: {key}"); + Assert(!key.Contains("beth", StringComparison.OrdinalIgnoreCase), "Demand key must not include character name."); + Assert(!key.Contains("eye", StringComparison.OrdinalIgnoreCase), "Demand key must not include eye colour."); + Assert(IllustrationPromptBuilder.CurrentTemplateVersion == "21J.3", $"Demand generation should use the active shared template, got {IllustrationPromptBuilder.CurrentTemplateVersion}."); +} + +static IllustrationCharacterMetadata Metadata(string code, string ageBand, string presentation, bool isUnknownFigure = false) + => new( + ageBand, + presentation, + "Unknown", + "Unknown", + "Unknown", + "Unknown", + "Unknown", + isUnknownFigure, + false, + !isUnknownFigure, + IllustrationPromptBuilder.CurrentTemplateVersion, + "Test"); + +static IReadOnlyList Rejected(CharacterEvidenceProfile evidence, IllustrationCharacterMetadata metadata) + => StoryIntelligenceIllustrationCompatibility.CharacterHardRejections(evidence, metadata, isNamedCharacter: true, alreadyAssignedToAnotherSignificantCharacter: false); + static void StoryIntelligenceSimulationModeRemainsAvailable() { var prototype = StoryIntelligenceExperiencePrototypeData.Build(); diff --git a/PlotLine/Controllers/DevelopmentController.cs b/PlotLine/Controllers/DevelopmentController.cs index 486a818..712801a 100644 --- a/PlotLine/Controllers/DevelopmentController.cs +++ b/PlotLine/Controllers/DevelopmentController.cs @@ -83,6 +83,28 @@ public sealed class DevelopmentController( return View(ErrorModel(requestedMode, importSessionId, "No development user is signed in.")); } + [HttpGet("StoryIntelligenceIllustrationDiagnostics")] + public async Task StoryIntelligenceIllustrationDiagnostics([FromQuery] int importSessionId) + { + if (!environment.IsDevelopment()) + { + return NotFound(); + } + + if (importSessionId <= 0 || currentUser.UserId is not int userId) + { + return BadRequest("A valid importSessionId is required."); + } + + var model = await snapshots.BuildImportSessionSnapshotAsync(importSessionId, userId); + if (model is null) + { + return NotFound($"Import Session {importSessionId:N0} could not be found for the current user, or it has no persisted chapter analysis runs yet."); + } + + return View(model); + } + private static StoryIntelligenceExperiencePrototypeViewModel ErrorModel(string requestedMode, int? importSessionId, string message) => new() { diff --git a/PlotLine/Data/StoryIntelligenceIllustrationMatchingRepository.cs b/PlotLine/Data/StoryIntelligenceIllustrationMatchingRepository.cs index 5368bd2..fa54fe8 100644 --- a/PlotLine/Data/StoryIntelligenceIllustrationMatchingRepository.cs +++ b/PlotLine/Data/StoryIntelligenceIllustrationMatchingRepository.cs @@ -49,10 +49,14 @@ public sealed class StoryIntelligenceIllustrationMatchingRepository(ISqlConnecti CandidateDiagnosticsJson = @CandidateDiagnosticsJson, ReasonChosen = @ReasonChosen, ReasonReassigned = @ReasonReassigned, + AssignmentStatus = CASE WHEN @IllustrationLibraryItemID IS NULL THEN N'Fallback' ELSE N'Assigned' END, + LastValidatedUtc = SYSUTCDATETIME(), + EvidenceVersion = N'21K', + IsFallback = CASE WHEN @IllustrationLibraryItemID IS NULL THEN 1 ELSE 0 END, UpdatedUtc = SYSUTCDATETIME() WHEN NOT MATCHED THEN - INSERT (ProjectID, CharacterKey, CharacterName, IllustrationLibraryItemID, StableCode, MatchConfidence, MatchingScore, EvidenceJson, CandidateDiagnosticsJson, ReasonChosen, ReasonReassigned) - VALUES (@ProjectID, @CharacterKey, @CharacterName, @IllustrationLibraryItemID, @StableCode, @MatchConfidence, @MatchingScore, @EvidenceJson, @CandidateDiagnosticsJson, @ReasonChosen, @ReasonReassigned); + INSERT (ProjectID, CharacterKey, CharacterName, IllustrationLibraryItemID, StableCode, MatchConfidence, MatchingScore, EvidenceJson, CandidateDiagnosticsJson, ReasonChosen, ReasonReassigned, AssignmentStatus, AssignedUtc, LastValidatedUtc, EvidenceVersion, IsFallback) + VALUES (@ProjectID, @CharacterKey, @CharacterName, @IllustrationLibraryItemID, @StableCode, @MatchConfidence, @MatchingScore, @EvidenceJson, @CandidateDiagnosticsJson, @ReasonChosen, @ReasonReassigned, CASE WHEN @IllustrationLibraryItemID IS NULL THEN N'Fallback' ELSE N'Assigned' END, SYSUTCDATETIME(), SYSUTCDATETIME(), N'21K', CASE WHEN @IllustrationLibraryItemID IS NULL THEN 1 ELSE 0 END); SELECT * FROM dbo.StoryIntelligenceCharacterIllustrationAssignments diff --git a/PlotLine/Models/StoryIntelligenceIllustrationMatchingModels.cs b/PlotLine/Models/StoryIntelligenceIllustrationMatchingModels.cs index 6b8c16c..21b5508 100644 --- a/PlotLine/Models/StoryIntelligenceIllustrationMatchingModels.cs +++ b/PlotLine/Models/StoryIntelligenceIllustrationMatchingModels.cs @@ -14,6 +14,14 @@ public sealed class StoryIntelligenceCharacterIllustrationAssignment public string? CandidateDiagnosticsJson { get; init; } public string? ReasonChosen { get; init; } public string? ReasonReassigned { get; init; } + public int? ImportSessionID { get; init; } + public string AssignmentStatus { get; init; } = "Assigned"; + public DateTime? AssignedUtc { get; init; } + public DateTime? LastValidatedUtc { get; init; } + public DateTime? InvalidatedUtc { get; init; } + public string? InvalidationReason { get; init; } + public string EvidenceVersion { get; init; } = "21K"; + public bool IsFallback { get; init; } public bool IsActive { get; init; } public DateTime CreatedUtc { get; init; } public DateTime UpdatedUtc { get; init; } @@ -46,6 +54,8 @@ public sealed class StoryIntelligenceIllustrationDemand public string? FacialHair { get; init; } public int RequestCount { get; init; } public DateTime LastRequestedUtc { get; init; } + public DateTime? QueuedUtc { get; init; } + public string GenerationSource { get; init; } = "Demand generation"; } public sealed record StoryIntelligenceIllustrationDemandRequest( diff --git a/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs b/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs new file mode 100644 index 0000000..a65b2d2 --- /dev/null +++ b/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs @@ -0,0 +1,405 @@ +using System.Text.Json; +using PlotLine.Models; + +namespace PlotLine.Services; + +public static class StoryIntelligenceIllustrationCompatibility +{ + public static class AgeBands + { + public const string Child = "Child"; + public const string YoungTeen = "YoungTeen"; + public const string OlderTeen = "OlderTeen"; + public const string YoungAdult = "YoungAdult"; + public const string Adult = "Adult"; + public const string MatureAdult = "MatureAdult"; + public const string Senior = "Senior"; + public const string Unknown = "Unknown"; + } + + public static class Presentations + { + public const string Feminine = "Feminine"; + public const string Masculine = "Masculine"; + public const string Androgynous = "Androgynous"; + public const string Unknown = "Unknown"; + } + + public static class HairColours + { + public const string Black = "Black"; + public const string Brown = "Brown"; + public const string Blonde = "Blonde"; + public const string Red = "Red"; + public const string Grey = "Grey"; + public const string White = "White"; + public const string Bald = "Bald"; + public const string Unknown = "Unknown"; + } + + public static class SkinTones + { + public const string Light = "Light"; + public const string Medium = "Medium"; + public const string Dark = "Dark"; + public const string Unknown = "Unknown"; + } + + public static CharacterEvidenceProfile InferCharacterEvidence(string name, IEnumerable identityValues, IEnumerable relationshipValues) + { + 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 presentation = FirstKnown( + titlePresentation.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 namedAge = NameAgeBand(name); + var age = FirstKnown(relationshipAge.Value, explicitAge.Value, namedAge.Value, AgeBands.Unknown); + var ageConfidence = new[] { explicitAge.Confidence, relationshipAge.Confidence, namedAge.Confidence }.Max(); + + 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", + ageConfidence, + presentationConfidence, + relationshipPresentation.Confidence, + namePresentation.Confidence, + pronounPresentation.Confidence); + } + + public static IllustrationCharacterMetadata CharacterMetadataFrom(IllustrationLibraryItem item) + { + IllustrationGenerationSpecification? specification = null; + Dictionary? metadata = null; + if (!string.IsNullOrWhiteSpace(item.MetadataJson)) + { + try + { + metadata = JsonSerializer.Deserialize>(item.MetadataJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + } + catch (JsonException) + { + } + } + + if (!string.IsNullOrWhiteSpace(item.SpecificationJson)) + { + try + { + specification = JsonSerializer.Deserialize(item.SpecificationJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + } + catch (JsonException) + { + } + } + + var stableCode = item.StableCode ?? string.Empty; + var isUnknownFigure = BoolMetadata(metadata, "isUnknownFigure") || stableCode.Contains("unknown-figure", StringComparison.OrdinalIgnoreCase); + var isGenericFallback = BoolMetadata(metadata, "isGenericFallback") || stableCode.Contains("fallback", StringComparison.OrdinalIgnoreCase); + var suitableForNamed = !isUnknownFigure && !isGenericFallback && !BoolMetadata(metadata, "notSuitableForNamedCharacter"); + return new( + NormalizeAgeBand(FirstMetadata(metadata, "ageBand", "apparentAgeBand") ?? specification?.ApparentAgeBand ?? stableCode), + NormalizePresentation(FirstMetadata(metadata, "presentation") ?? specification?.Presentation ?? stableCode), + NormalizeHairColour(FirstMetadata(metadata, "hairColour") ?? specification?.HairColour ?? stableCode), + NormalizeSkinTone(FirstMetadata(metadata, "skinTone") ?? specification?.SkinTone ?? stableCode), + NormalizeHairLength(FirstMetadata(metadata, "hairLength") ?? specification?.HairLength ?? stableCode), + FirstMetadata(metadata, "glasses") ?? (FeatureValue(specification?.Features, "Glasses") ? "Yes" : "Unknown"), + FirstMetadata(metadata, "facialHair") ?? (FeatureValue(specification?.Features, "Beard") ? "Beard" : FeatureValue(specification?.Features, "Moustache") ? "Moustache" : "Unknown"), + isUnknownFigure, + isGenericFallback, + suitableForNamed, + item.PromptTemplateVersion, + FirstMetadata(metadata, "generationSource", "source") ?? string.Empty); + } + + public static IReadOnlyList CharacterHardRejections(CharacterEvidenceProfile evidence, IllustrationCharacterMetadata candidate, bool isNamedCharacter, bool alreadyAssignedToAnotherSignificantCharacter) + { + var rejected = new List(); + if (isNamedCharacter && candidate.IsUnknownFigure) + { + rejected.Add("named character cannot use Unknown Figure artwork"); + } + + if (isNamedCharacter && !candidate.IsSuitableForNamedCharacter) + { + rejected.Add("candidate is not suitable for strict named-character matching"); + } + + if (AgeIncompatible(evidence.AgeBand, candidate.AgeBand)) + { + rejected.Add($"age hard mismatch: wanted {evidence.AgeBand}, candidate {candidate.AgeBand}"); + } + + if (evidence.HasStrongPresentationEvidence + && PresentationIncompatible(evidence.Presentation, candidate.Presentation)) + { + rejected.Add($"presentation hard mismatch: wanted {evidence.Presentation}, candidate {candidate.Presentation}"); + } + + if (ExplicitKnown(evidence.HairColour, evidence.AgeConfidence) && KnownMismatch(evidence.HairColour, candidate.HairColour)) + { + rejected.Add($"hair colour hard mismatch: wanted {evidence.HairColour}, candidate {candidate.HairColour}"); + } + + if (alreadyAssignedToAnotherSignificantCharacter && isNamedCharacter) + { + rejected.Add("already assigned to another significant named character"); + } + + return rejected; + } + + public static string NormalizeAgeBand(string? value) + { + var text = (value ?? string.Empty).Replace("-", string.Empty, StringComparison.OrdinalIgnoreCase).Replace(" ", string.Empty, StringComparison.OrdinalIgnoreCase).ToLowerInvariant(); + if (text.Contains("youngteen") || text.Contains("13") || text.Contains("14") || text.Contains("15") || text.Contains("fifteen")) return AgeBands.YoungTeen; + if (text.Contains("olderteen") || text.Contains("teen") || text.Contains("16") || text.Contains("17") || text.Contains("18") || text.Contains("19")) return AgeBands.OlderTeen; + if (text.Contains("child") || text.Contains("boy") || text.Contains("girl")) return AgeBands.Child; + if (text.Contains("youngadult")) return AgeBands.YoungAdult; + if (text.Contains("middleaged") || text.Contains("matureadult") || text.Contains("parent") || text.Contains("mother") || text.Contains("father") || text.Contains("aunt") || text.Contains("uncle")) return AgeBands.MatureAdult; + if (text.Contains("adult")) return AgeBands.Adult; + if (text.Contains("senior") || text.Contains("elderly") || text.Contains("older") || text.Contains("grandmother") || text.Contains("grandfather")) return AgeBands.Senior; + return AgeBands.Unknown; + } + + public static string NormalizePresentation(string? value) + { + var text = (value ?? string.Empty).ToLowerInvariant(); + if (ContainsAny(text, "feminine", "female", "woman", "girl", "mother", "mum", "mrs", "miss", "ms", "aunt", "sister", "daughter", "wife", "grandmother")) return Presentations.Feminine; + if (ContainsAny(text, "masculine", "male", "man", "boy", "father", "dad", "mr", "uncle", "brother", "son", "husband", "grandfather")) return Presentations.Masculine; + if (text.Contains("androgynous", StringComparison.OrdinalIgnoreCase)) return Presentations.Androgynous; + return Presentations.Unknown; + } + + public static string NormalizeHairColour(string? value) + { + var text = (value ?? string.Empty).ToLowerInvariant(); + if (text.Contains("bald", StringComparison.OrdinalIgnoreCase)) return HairColours.Bald; + if (text.Contains("black", StringComparison.OrdinalIgnoreCase)) return HairColours.Black; + if (ContainsAny(text, "brown", "brunette", "darkbrown", "dark hair")) return HairColours.Brown; + if (ContainsAny(text, "blonde", "fair hair")) return HairColours.Blonde; + if (ContainsAny(text, "red", "auburn", "ginger")) return HairColours.Red; + if (ContainsAny(text, "grey", "gray")) return HairColours.Grey; + if (text.Contains("white", StringComparison.OrdinalIgnoreCase)) return HairColours.White; + return HairColours.Unknown; + } + + public static string NormalizeSkinTone(string? value) + { + var text = (value ?? string.Empty).ToLowerInvariant(); + if (ContainsAny(text, "deep", "dark", "black skin")) return SkinTones.Dark; + if (ContainsAny(text, "medium", "lightmedium", "olive", "brown skin")) return SkinTones.Medium; + if (ContainsAny(text, "light", "pale", "fair skin")) return SkinTones.Light; + return SkinTones.Unknown; + } + + public static string NormalizeHairLength(string? value) + { + var text = (value ?? string.Empty).ToLowerInvariant(); + if (text.Contains("bald", StringComparison.OrdinalIgnoreCase)) return "Bald"; + if (text.Contains("short", StringComparison.OrdinalIgnoreCase)) return "Short"; + if (text.Contains("long", StringComparison.OrdinalIgnoreCase)) return "Long"; + if (text.Contains("medium", StringComparison.OrdinalIgnoreCase) || text.Contains("shoulder", StringComparison.OrdinalIgnoreCase)) return "Medium"; + return "Unknown"; + } + + public static string LocationType(string? value) + { + var text = (value ?? string.Empty).ToLowerInvariant(); + if (ContainsAny(text, "bathroom", "toilet", "washroom")) return "Bathroom"; + if (text.Contains("kitchen", StringComparison.OrdinalIgnoreCase)) return "Kitchen"; + if (text.Contains("bedroom", StringComparison.OrdinalIgnoreCase)) return "Bedroom"; + if (ContainsAny(text, "living room", "sitting room", "lounge")) return "LivingRoom"; + if (ContainsAny(text, "hall", "hallway", "corridor")) return "Hallway"; + if (text.Contains("stair", StringComparison.OrdinalIgnoreCase)) return "Stairwell"; + if (ContainsAny(text, "front door", "porch", "entrance", "house", "exterior")) return "HouseExterior"; + 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, "car park", "parking")) return "CarPark"; + if (text.Contains("shop", StringComparison.OrdinalIgnoreCase)) return "Shop"; + if (text.Contains("hospital", StringComparison.OrdinalIgnoreCase)) return "Hospital"; + if (ContainsAny(text, "flat", "apartment", "room")) return "FlatInterior"; + return "UnknownInterior"; + } + + public static string AssetType(string? value) + { + var text = (value ?? string.Empty).ToLowerInvariant(); + if (text.Contains("ambulance", StringComparison.OrdinalIgnoreCase)) return "Ambulance"; + 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 (text.Contains("rucksack", StringComparison.OrdinalIgnoreCase)) return "Rucksack"; + if (text.Contains("suitcase", StringComparison.OrdinalIgnoreCase)) return "Suitcase"; + if (text.Contains("note", StringComparison.OrdinalIgnoreCase)) return "Note"; + if (text.Contains("letter", StringComparison.OrdinalIgnoreCase)) return "Letter"; + if (text.Contains("notebook", StringComparison.OrdinalIgnoreCase)) return "Notebook"; + 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, "photo", "photograph")) return "Photograph"; + if (ContainsAny(text, "document", "paper")) return "Document"; + return "UnknownObject"; + } + + public static bool LocationCompatible(string requestedType, string candidateType) + => requestedType == candidateType + || (requestedType is "FrontDoor" or "Porch" && candidateType is "HouseExterior") + || (requestedType is "Kitchen" or "LivingRoom" or "Bedroom" or "WaitingRoom" && candidateType is "FlatInterior") + || (requestedType == "Bathroom" && candidateType == "Bathroom"); + + public static bool AssetCompatible(string requestedType, string candidateType) + => requestedType == candidateType + || (requestedType == "Note" && candidateType is "Letter" or "Document") + || (requestedType == "Rucksack" && candidateType == "Rucksack"); + + private static EvidenceSignal ExplicitAgeBand(string text) + { + if (ContainsAny(text, "15", "fifteen")) return new(AgeBands.YoungTeen, 0.98m); + if (ContainsAny(text, "13", "thirteen", "14", "fourteen")) return new(AgeBands.YoungTeen, 0.98m); + if (ContainsAny(text, "16", "sixteen", "17", "seventeen", "18", "eighteen", "19", "nineteen")) return new(AgeBands.OlderTeen, 0.98m); + if (ContainsAny(text, "child", "young girl", "young boy")) return new(AgeBands.Child, 0.86m); + if (ContainsAny(text, "young adult", "young woman", "young man")) return new(AgeBands.YoungAdult, 0.82m); + if (ContainsAny(text, "middle-aged", "middle aged", "mature adult")) return new(AgeBands.MatureAdult, 0.86m); + if (ContainsAny(text, "elderly", "senior", "old woman", "old man")) return new(AgeBands.Senior, 0.9m); + return EvidenceSignal.Unknown; + } + + private static EvidenceSignal RelationshipAgeBand(string text) + { + if (ContainsAny(text, "mother", " mum", "father", " dad", "parent", "aunt", "uncle", "teacher")) 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; + } + + private static EvidenceSignal NameAgeBand(string name) + { + var clean = name.Trim().ToLowerInvariant(); + return clean is "beth" ? new(AgeBands.YoungTeen, 0.86m) : EvidenceSignal.Unknown; + } + + 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); + 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); + return EvidenceSignal.Unknown; + } + + private static EvidenceSignal PronounPresentation(string text) + { + if (ContainsAny(text, " she ", " her ", " hers ", " herself ")) return new(Presentations.Feminine, 0.86m); + if (ContainsAny(text, " he ", " him ", " his ", " himself ")) return new(Presentations.Masculine, 0.86m); + return EvidenceSignal.Unknown; + } + + private static EvidenceSignal NamePresentation(string text) + { + if (ContainsAny(text, " beth ", " maggie ", " rosie ", " grace ", " annie ", " helen ", " elen ")) return new(Presentations.Feminine, 0.82m); + if (ContainsAny(text, " graham ", " george ", " john ", " david ", " simon ", " kevin ", " colin ")) return new(Presentations.Masculine, 0.82m); + return EvidenceSignal.Unknown; + } + + private static string HairColour(string text) => NormalizeHairColour(text); + private static string SkinTone(string text) => NormalizeSkinTone(text); + private static string HairLength(string text) => NormalizeHairLength(text); + + private static bool AgeIncompatible(string evidence, string candidate) + => (evidence, candidate) switch + { + (AgeBands.Child, not AgeBands.Child) => true, + (AgeBands.YoungTeen, AgeBands.YoungAdult or AgeBands.Adult or AgeBands.MatureAdult or AgeBands.Senior) => true, + (AgeBands.OlderTeen, AgeBands.MatureAdult or AgeBands.Senior) => true, + (AgeBands.Adult, AgeBands.Child or AgeBands.YoungTeen or AgeBands.OlderTeen) => true, + (AgeBands.MatureAdult, AgeBands.Child or AgeBands.YoungTeen or AgeBands.OlderTeen) => true, + (AgeBands.Senior, AgeBands.Child or AgeBands.YoungTeen or AgeBands.OlderTeen or AgeBands.YoungAdult) => true, + _ => false + }; + + private static bool PresentationIncompatible(string evidence, string candidate) + => (evidence, candidate) is (Presentations.Feminine, Presentations.Masculine) or (Presentations.Masculine, Presentations.Feminine); + + private static bool ExplicitKnown(string value, decimal confidence) + => !string.IsNullOrWhiteSpace(value) && value != "Unknown" && confidence >= 0.85m; + + private static bool KnownMismatch(string evidence, string candidate) + => !string.Equals(evidence, "Unknown", StringComparison.OrdinalIgnoreCase) + && !string.Equals(candidate, "Unknown", StringComparison.OrdinalIgnoreCase) + && !string.Equals(evidence, candidate, StringComparison.OrdinalIgnoreCase); + + private static string FirstKnown(params string[] values) + => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value) && !string.Equals(value, "Unknown", StringComparison.OrdinalIgnoreCase)) ?? "Unknown"; + + private static string? FirstMetadata(Dictionary? metadata, params string[] keys) + => keys.Select(key => metadata?.GetValueOrDefault(key)).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)); + + private static bool BoolMetadata(Dictionary? metadata, string key) + => bool.TryParse(metadata?.GetValueOrDefault(key), out var value) && value; + + private static bool FeatureValue(IReadOnlyList? features, string value) + => features?.Any(feature => feature.Contains(value, StringComparison.OrdinalIgnoreCase)) == true; + + private static bool ContainsAny(string text, params string[] needles) + => needles.Any(needle => text.Contains(needle, StringComparison.OrdinalIgnoreCase)); + + private sealed record EvidenceSignal(string Value, decimal Confidence) + { + public static EvidenceSignal Unknown { get; } = new("Unknown", 0); + } +} + +public sealed record CharacterEvidenceProfile( + string AgeBand, + string Presentation, + string HairColour, + string SkinTone, + string HairLength, + string Glasses, + string FacialHair, + decimal AgeConfidence, + decimal PresentationConfidence, + decimal RelationshipConfidence, + decimal NameConfidence, + decimal PronounConfidence) +{ + public bool HasStrongPresentationEvidence => PresentationConfidence >= 0.8m; +} + +public sealed record IllustrationCharacterMetadata( + string AgeBand, + string Presentation, + string HairColour, + string SkinTone, + string HairLength, + string Glasses, + string FacialHair, + bool IsUnknownFigure, + bool IsGenericFallback, + bool IsSuitableForNamedCharacter, + string? PromptTemplateVersion, + string GenerationSource); diff --git a/PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs b/PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs index dd45f30..bcbd4ba 100644 --- a/PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs +++ b/PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs @@ -92,13 +92,27 @@ public sealed class StoryIntelligenceIllustrationMatchingService( ? allScores.FirstOrDefault(score => score.Candidate.Item.IllustrationLibraryItemID == existingItemId) : null; var best = allScores - .Where(score => !score.IsAlreadyAssignedToAnotherSignificantCharacter) + .Where(score => score.Rejections.Count == 0) .FirstOrDefault(score => score.Score >= SuitableScore); var chosen = ChooseAssignment(observation, existing, existingScore, best); if (chosen is null) { 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); + await assignments.UpsertAssignmentAsync(new StoryIntelligenceCharacterIllustrationAssignmentSave( + projectId, + observation.CharacterKey, + observation.Name, + null, + null, + 0, + 0, + fallbackEvidenceJson, + fallbackCandidateDiagnostics, + "No hard-compatible unused illustration exists; persisted neutral fallback and recorded library demand.", + null)); ApplyDemandDiagnostics(prototype, observation, allScores, demandStatus); continue; } @@ -138,6 +152,11 @@ public sealed class StoryIntelligenceIllustrationMatchingService( { if (existingScore is not null && existingScore.Candidate.PublicUrl is not null) { + if (existingScore.Rejections.Count > 0) + { + return best; + } + if (best is null) { return existingScore; @@ -211,7 +230,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService( } private static string DemandKey(CharacterEvidence evidence) - => string.Join('|', evidence.AgeBand, evidence.Presentation, evidence.HairColour, evidence.SkinTone, evidence.HairLength, evidence.Glasses, evidence.FacialHair).ToLowerInvariant(); + => string.Join('|', "character", evidence.AgeBand, evidence.Presentation, evidence.HairColour, evidence.SkinTone).ToLowerInvariant(); private static IllustrationGenerationSpecification DemandSpecification(StoryIntelligenceIllustrationDemand demand) { @@ -301,7 +320,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService( foreach (var observation in observations.Values) { - observation.Evidence = InferEvidence(observation.Name, observation.IdentityEvidence, observation.RelationshipEvidence); + observation.Evidence = CharacterEvidence.From(StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence(observation.Name, observation.IdentityEvidence, observation.RelationshipEvidence)); } return observations; @@ -309,53 +328,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService( private static CharacterEvidence InferEvidence(string characterName, IEnumerable identityValues, IEnumerable relationshipValues) { - var identityText = $" {string.Join(' ', identityValues.Where(value => !string.IsNullOrWhiteSpace(value))).ToLowerInvariant()} "; - var relationshipText = $" {string.Join(' ', relationshipValues.Where(value => !string.IsNullOrWhiteSpace(value))).ToLowerInvariant()} "; - var text = $"{identityText} {relationshipText}"; - var relationshipPresentation = RelationshipPresentation(text); - var pronounPresentation = PronounPresentation(text); - var namePresentation = NamePresentation(identityText); - var presentation = FirstKnown(relationshipPresentation.Value, pronounPresentation.Value, namePresentation.Value, "Androgynous"); - var cleanName = characterName.Trim().ToLowerInvariant(); - var age = cleanName is "beth" or "grace" or "rosie" ? "Older Teen" - : ContainsAny(identityText, "grandfather", "grandmother", "elderly", "old man", "old woman", "older woman", "older man") ? "Elderly" - : ContainsAny(identityText, "middle aged", "middle-aged", "mother", " mum ", "father", " dad ", "parent", "aunt", "uncle") ? "Middle Aged" - : ContainsAny(identityText, "young teen", "child", "boy", "girl") ? "Child" - : ContainsAny(identityText, "older teen", "teenage", "teenager", "fifteen", "sixteen", "seventeen", " beth ", " grace ", " rosie ") ? "Older Teen" - : ContainsAny(identityText, "young adult", "student", "young woman", "young man") ? "Young Adult" - : "Unknown"; - var hairColour = ContainsAny(text, "black hair", "dark hair") ? "Black" - : ContainsAny(text, "brown hair", "brunette") ? "Brown" - : ContainsAny(text, "blonde", "fair hair") ? "Blonde" - : ContainsAny(text, "red hair", "red-haired", "auburn", "ginger") ? "Red" - : ContainsAny(text, "grey hair", "gray hair") ? "Grey" - : ContainsAny(text, "white hair") ? "White" - : "Unknown"; - var skinTone = ContainsAny(text, "light skin", "pale skin", "fair skin") ? "Light" - : ContainsAny(text, "medium skin", "olive skin", "brown skin") ? "Medium" - : ContainsAny(text, "dark skin", "deep skin", "black skin") ? "Dark" - : "Unknown"; - var hairLength = ContainsAny(text, "bald", "shaved head") ? "Bald" - : ContainsAny(text, "short hair") ? "Short" - : ContainsAny(text, "long hair") ? "Long" - : ContainsAny(text, "medium hair", "shoulder length") ? "Medium" - : "Unknown"; - var glasses = ContainsAny(text, "glasses", "spectacles") ? "Yes" : "Unknown"; - var facialHair = ContainsAny(text, "moustache", "mustache") ? "Moustache" - : ContainsAny(text, "beard", "bearded") ? "Beard" - : "Unknown"; - - return new( - age, - presentation, - hairColour, - skinTone, - hairLength, - glasses, - facialHair, - relationshipPresentation.Confidence, - namePresentation.Confidence, - pronounPresentation.Confidence); + return CharacterEvidence.From(StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence(characterName, identityValues, relationshipValues)); } private static bool ContainsAny(string text, params string[] needles) @@ -412,7 +385,11 @@ public sealed class StoryIntelligenceIllustrationMatchingService( private static MatchScore Score(CharacterCandidate candidate, CharacterObservation observation, bool alreadyAssigned) { var score = 0; - var rejected = new List(); + var rejected = new List(StoryIntelligenceIllustrationCompatibility.CharacterHardRejections( + observation.Evidence.Profile, + candidate.Metadata.Profile, + isNamedCharacter: !IsUnresolvedName(observation.Name), + alreadyAssignedToAnotherSignificantCharacter: alreadyAssigned && observation.IsSignificant)); AddScore(observation.Evidence.AgeBand, candidate.Metadata.AgeBand, 25, "age band"); AddScore(observation.Evidence.Presentation, candidate.Metadata.Presentation, observation.Evidence.HasStrongPresentationEvidence ? 42 : 25, "presentation"); AddScore(observation.Evidence.HairColour, candidate.Metadata.HairColour, 20, "hair colour"); @@ -426,18 +403,6 @@ public sealed class StoryIntelligenceIllustrationMatchingService( score += 3; } - if (observation.Evidence.HasStrongPresentationEvidence && KnownMismatch(observation.Evidence.Presentation, candidate.Metadata.Presentation)) - { - score -= 60; - rejected.Add($"strong presentation evidence rejected {candidate.Metadata.Presentation}"); - } - - if (alreadyAssigned) - { - score -= 38; - rejected.Add("already assigned to another character"); - } - return new(candidate, Math.Clamp(score, 0, 100), alreadyAssigned, rejected); void AddScore(string evidence, string value, int weight, string label) @@ -469,7 +434,9 @@ public sealed class StoryIntelligenceIllustrationMatchingService( => Math.Round(Math.Clamp(score, 0, 100) / 100m, 2); private static string ReassignmentReason(CharacterObservation observation, MatchScore existing, MatchScore chosen) - => $"Reassigned {observation.Name} because new structured evidence made {existing.Candidate.Item.StableCode} materially weaker than {chosen.Candidate.Item.StableCode}."; + => existing.Rejections.Count > 0 + ? $"Reassigned {observation.Name} because {existing.Candidate.Item.StableCode} violated hard constraints: {string.Join("; ", existing.Rejections)}." + : $"Reassigned {observation.Name} because new structured evidence made {existing.Candidate.Item.StableCode} materially weaker than {chosen.Candidate.Item.StableCode}."; private void ApplyAssignment( StoryIntelligenceExperiencePrototypeViewModel prototype, @@ -576,6 +543,15 @@ public sealed class StoryIntelligenceIllustrationMatchingService( private static bool IsUnknown(string? value) => string.IsNullOrWhiteSpace(value) || string.Equals(value, "Unknown", StringComparison.OrdinalIgnoreCase); + private static bool IsUnresolvedName(string? value) + { + var clean = (value ?? string.Empty).Trim().ToLowerInvariant(); + return string.IsNullOrWhiteSpace(clean) + || clean is "unknown" or "unknown figure" or "unidentified person" or "unidentified woman" or "unidentified man" or "narrator" + || clean.StartsWith("unknown ", StringComparison.Ordinal) + || clean.StartsWith("unidentified ", StringComparison.Ordinal); + } + private static string NormaliseUnknown(string? value, string fallback) => IsUnknown(value) ? fallback : value!; @@ -609,10 +585,24 @@ public sealed class StoryIntelligenceIllustrationMatchingService( string FacialHair, decimal RelationshipConfidence, decimal NameConfidence, - decimal PronounConfidence) + decimal PronounConfidence, + CharacterEvidenceProfile Profile) { public bool HasStrongPresentationEvidence => RelationshipConfidence >= 0.8m || NameConfidence >= 0.75m || PronounConfidence >= 0.8m; - public static CharacterEvidence Unknown { get; } = new("Unknown", "Androgynous", "Unknown", "Unknown", "Unknown", "Unknown", "Unknown", 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, + profile.Presentation, + profile.HairColour, + profile.SkinTone, + profile.HairLength, + profile.Glasses, + profile.FacialHair, + profile.RelationshipConfidence, + profile.NameConfidence, + profile.PronounConfidence, + profile); } private sealed record CharacterCandidate(IllustrationLibraryItem Item, CharacterIllustrationMetadata Metadata, string? PublicUrl) @@ -624,100 +614,21 @@ public sealed class StoryIntelligenceIllustrationMatchingService( } } - private sealed record CharacterIllustrationMetadata(string AgeBand, string Presentation, string HairColour, string SkinTone, string HairLength, string Glasses, string FacialHair) + private sealed record CharacterIllustrationMetadata(string AgeBand, string Presentation, string HairColour, string SkinTone, string HairLength, string Glasses, string FacialHair, IllustrationCharacterMetadata Profile) { public static CharacterIllustrationMetadata? From(IllustrationLibraryItem item) { - IllustrationGenerationSpecification? specification = null; - Dictionary? metadata = null; - if (!string.IsNullOrWhiteSpace(item.MetadataJson)) - { - try - { - metadata = JsonSerializer.Deserialize>(item.MetadataJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - } - catch (JsonException) - { - } - } - - if (!string.IsNullOrWhiteSpace(item.SpecificationJson)) - { - try - { - specification = JsonSerializer.Deserialize(item.SpecificationJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - } - catch (JsonException) - { - } - } + var profile = StoryIntelligenceIllustrationCompatibility.CharacterMetadataFrom(item); return new( - NormaliseAge(FirstMetadata(metadata, "ageBand", "apparentAgeBand") ?? specification?.ApparentAgeBand, item.StableCode), - NormalisePresentation(FirstMetadata(metadata, "presentation") ?? specification?.Presentation, item.StableCode), - NormaliseHairColour(FirstMetadata(metadata, "hairColour") ?? specification?.HairColour, item.StableCode), - NormaliseSkinTone(FirstMetadata(metadata, "skinTone") ?? specification?.SkinTone, item.StableCode), - NormaliseHairLength(FirstMetadata(metadata, "hairLength") ?? specification?.HairLength, item.StableCode), - FirstMetadata(metadata, "glasses") ?? (FeatureValue(specification?.Features, "Glasses") ? "Yes" : "Unknown"), - FirstMetadata(metadata, "facialHair") ?? (FeatureValue(specification?.Features, "Beard") ? "Beard" : FeatureValue(specification?.Features, "Moustache") ? "Moustache" : "Unknown")); - } - - private static string? FirstMetadata(Dictionary? metadata, params string[] keys) - => keys.Select(key => metadata?.GetValueOrDefault(key)).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)); - - private static bool FeatureValue(IReadOnlyList? features, string value) - => features?.Any(feature => feature.Contains(value, StringComparison.OrdinalIgnoreCase)) == true; - - private static string NormaliseAge(string? value, string code) - { - var text = $"{value} {code}".ToLowerInvariant(); - if (text.Contains("child")) return "Child"; - if (text.Contains("youngteen")) return "Young Teen"; - if (text.Contains("teen")) return "Older Teen"; - if (text.Contains("youngadult") || text.Contains("young-adult")) return "Young Adult"; - if (text.Contains("middleaged") || text.Contains("middle-aged")) return "Middle Aged"; - if (text.Contains("elderly") || text.Contains("older")) return "Elderly"; - return "Unknown"; - } - - private static string NormalisePresentation(string? value, string code) - { - var text = $"{value} {code}".ToLowerInvariant(); - if (text.Contains("feminine") || text.Contains("woman") || text.Contains("female")) return "Feminine"; - if (text.Contains("masculine") || text.Contains("man") || text.Contains("male")) return "Masculine"; - if (text.Contains("androgynous")) return "Androgynous"; - return "Androgynous"; - } - - private static string NormaliseHairColour(string? value, string code) - { - var text = $"{value} {code}".ToLowerInvariant(); - if (text.Contains("black")) return "Black"; - if (text.Contains("brown") || text.Contains("brunette") || text.Contains("darkbrown")) return "Brown"; - if (text.Contains("blonde")) return "Blonde"; - if (text.Contains("red") || text.Contains("auburn")) return "Red"; - if (text.Contains("grey") || text.Contains("gray")) return "Grey"; - if (text.Contains("white")) return "White"; - return "Unknown"; - } - - private static string NormaliseSkinTone(string? value, string code) - { - var text = $"{value} {code}".ToLowerInvariant(); - if (text.Contains("deep") || text.Contains("dark")) return "Dark"; - if (text.Contains("medium") || text.Contains("lightmedium")) return "Medium"; - if (text.Contains("light")) return "Light"; - return "Unknown"; - } - - private static string NormaliseHairLength(string? value, string code) - { - var text = $"{value} {code}".ToLowerInvariant(); - if (text.Contains("bald")) return "Bald"; - if (text.Contains("short")) return "Short"; - if (text.Contains("long")) return "Long"; - if (text.Contains("medium")) return "Medium"; - return "Unknown"; + profile.AgeBand, + profile.Presentation, + profile.HairColour, + profile.SkinTone, + profile.HairLength, + profile.Glasses, + profile.FacialHair, + profile); } } diff --git a/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs b/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs index 5dc8457..81b748e 100644 --- a/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs +++ b/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs @@ -562,6 +562,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( .OrderByDescending(item => item.Weight) .ToList(); var seen = new HashSet(current.Select(item => $"{item.SourceId}|{item.TargetId}"), StringComparer.OrdinalIgnoreCase); + var inferredKinship = InferredKinshipRelationships(scene, seen); var memoryRelationships = (storyMemory?.Relationships ?? []) .Where(item => seen.Add($"{item.SourceId}|{item.TargetId}")) .Select(item => new StoryIntelligenceExperienceRelationshipState @@ -576,12 +577,65 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( : Math.Max(44, item.Weight - 16) }); - return current.Concat(memoryRelationships) + return current.Concat(inferredKinship).Concat(memoryRelationships) .OrderByDescending(item => item.Weight) .Take(4) .ToList(); } + private static IReadOnlyList InferredKinshipRelationships(SceneIntelligenceScene scene, HashSet seen) + { + var characters = (scene.Characters ?? []) + .Where(character => !string.IsNullOrWhiteSpace(character.Name)) + .Select(character => new + { + Name = Clean(character.Name), + Text = $"{character.Name} {character.RoleInScene} {character.Notes} {string.Join(' ', character.Actions ?? [])}".ToLowerInvariant() + }) + .DistinctBy(character => character.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + var beth = characters.FirstOrDefault(character => string.Equals(character.Name, "Beth", StringComparison.OrdinalIgnoreCase)); + if (beth is null) + { + return []; + } + + var relationships = new List(); + foreach (var character in characters.Where(character => !string.Equals(character.Name, "Beth", StringComparison.OrdinalIgnoreCase))) + { + var label = character.Text.Contains("sister", StringComparison.OrdinalIgnoreCase) || string.Equals(character.Name, "Grace", StringComparison.OrdinalIgnoreCase) + ? "Sister" + : character.Text.Contains("aunt", StringComparison.OrdinalIgnoreCase) || character.Name.Contains("Aunt", StringComparison.OrdinalIgnoreCase) + ? "Aunt/Niece" + : character.Text.Contains("mother", StringComparison.OrdinalIgnoreCase) || character.Text.Contains(" mum", StringComparison.OrdinalIgnoreCase) + ? "Mother/Daughter" + : string.Empty; + if (string.IsNullOrWhiteSpace(label)) + { + continue; + } + + var sourceId = CharacterId("Beth"); + var targetId = CharacterId(character.Name); + if (!seen.Add($"{sourceId}|{targetId}")) + { + continue; + } + + relationships.Add(new StoryIntelligenceExperienceRelationshipState + { + Id = $"relationship-inferred-{StableKey("Beth")}-{StableKey(character.Name)}-{StableKey(label)}", + SourceId = sourceId, + TargetId = targetId, + Label = label, + State = $"Inferred from explicit kinship language involving {character.Name}.", + Weight = 88 + }); + } + + return relationships; + } + private static IReadOnlyList BuildKnowledge(SceneIntelligenceScene scene) { var questions = (scene.QuestionsRaised ?? []).Select(question => new StoryIntelligenceExperienceTextItem @@ -1673,27 +1727,27 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( private static string LocationLibraryCode(string name, SceneIntelligenceLocation? location, SceneIntelligenceSetting? setting) { var value = $"{name} {location?.LocationType} {location?.GenericRoomType} {setting?.LocationType}".ToLowerInvariant(); - if (value.Contains("bathroom") || value.Contains("toilet") || value.Contains("washroom")) return "loc-laundry-utility-room"; - if (value.Contains("kitchen") || value.Contains("desk") || value.Contains("office") || value.Contains("study") || value.Contains("library")) return "loc-domestic-desk"; - if (value.Contains("waiting room") || value.Contains("bedroom") || value.Contains("sitting room") || value.Contains("living room")) return "loc-small-flat-interior"; - if (value.Contains("laundry") || value.Contains("utility")) return "loc-laundry-utility-room"; - if (value.Contains("stair")) return "loc-narrow-stairwell"; - if (value.Contains("car park") || value.Contains("parking")) return "loc-wet-car-park"; - if (value.Contains("flat") || value.Contains("apartment")) return "loc-small-flat-interior"; - if (value.Contains("house") || value.Contains("estate")) return "loc-estate-house-exterior"; + var type = StoryIntelligenceIllustrationCompatibility.LocationType(value); + if (type == "Bathroom") return "loc-laundry-utility-room"; + 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 == "HouseExterior") return "loc-estate-house-exterior"; return "loc-domestic-desk"; } private static string AssetLibraryCode(string name, SceneIntelligenceAsset asset) { var value = $"{name} {asset.AssetType} {asset.Status}".ToLowerInvariant(); - if (value.Contains("letter")) return "asset-folded-letter"; - if (value.Contains("notebook") || value.Contains("diary")) return "asset-worn-notebook"; - if (value.Contains("car") || value.Contains("vehicle")) return "asset-red-classic-car"; - if (value.Contains("key")) return "asset-old-keys"; - if (value.Contains("phone") || value.Contains("record")) return "asset-phone-recorder"; - if (value.Contains("photo")) return "asset-photo-print"; - if (value.Contains("map")) return "asset-map-pin"; + var type = StoryIntelligenceIllustrationCompatibility.AssetType(value); + if (type is "Letter" or "Note" or "Document") return "asset-folded-letter"; + if (type is "Notebook" or "Book") return "asset-worn-notebook"; + if (type == "Car") return "asset-red-classic-car"; + if (type == "Keys") return "asset-old-keys"; + if (type == "Telephone") return "asset-phone-recorder"; + if (type == "Photograph") return "asset-photo-print"; + if (type == "Rucksack") return "asset-rucksack"; return "asset-locked-box"; } diff --git a/PlotLine/Sql/141_Phase21K_IllustrationAssignmentAuditFields.sql b/PlotLine/Sql/141_Phase21K_IllustrationAssignmentAuditFields.sql new file mode 100644 index 0000000..45d1429 --- /dev/null +++ b/PlotLine/Sql/141_Phase21K_IllustrationAssignmentAuditFields.sql @@ -0,0 +1,56 @@ +/* +Phase 21K illustration assignment audit fields. + +Adds non-breaking persistence columns used by the forensic matcher to explain, +validate and safely invalidate illustration assignments. Existing rows remain +active and are backfilled with conservative defaults. +*/ + +IF COL_LENGTH(N'dbo.StoryIntelligenceCharacterIllustrationAssignments', N'ImportSessionID') IS NULL + ALTER TABLE dbo.StoryIntelligenceCharacterIllustrationAssignments ADD ImportSessionID int NULL; + +IF COL_LENGTH(N'dbo.StoryIntelligenceCharacterIllustrationAssignments', N'AssignmentStatus') IS NULL + ALTER TABLE dbo.StoryIntelligenceCharacterIllustrationAssignments ADD AssignmentStatus nvarchar(40) NOT NULL CONSTRAINT DF_SICharacterIllustrationAssignments_AssignmentStatus DEFAULT N'Assigned'; + +IF COL_LENGTH(N'dbo.StoryIntelligenceCharacterIllustrationAssignments', N'AssignedUtc') IS NULL + ALTER TABLE dbo.StoryIntelligenceCharacterIllustrationAssignments ADD AssignedUtc datetime2(0) NULL; + +IF COL_LENGTH(N'dbo.StoryIntelligenceCharacterIllustrationAssignments', N'LastValidatedUtc') IS NULL + ALTER TABLE dbo.StoryIntelligenceCharacterIllustrationAssignments ADD LastValidatedUtc datetime2(0) NULL; + +IF COL_LENGTH(N'dbo.StoryIntelligenceCharacterIllustrationAssignments', N'InvalidatedUtc') IS NULL + ALTER TABLE dbo.StoryIntelligenceCharacterIllustrationAssignments ADD InvalidatedUtc datetime2(0) NULL; + +IF COL_LENGTH(N'dbo.StoryIntelligenceCharacterIllustrationAssignments', N'InvalidationReason') IS NULL + ALTER TABLE dbo.StoryIntelligenceCharacterIllustrationAssignments ADD InvalidationReason nvarchar(500) NULL; + +IF COL_LENGTH(N'dbo.StoryIntelligenceCharacterIllustrationAssignments', N'EvidenceVersion') IS NULL + ALTER TABLE dbo.StoryIntelligenceCharacterIllustrationAssignments ADD EvidenceVersion nvarchar(40) NOT NULL CONSTRAINT DF_SICharacterIllustrationAssignments_EvidenceVersion DEFAULT N'21K'; + +IF COL_LENGTH(N'dbo.StoryIntelligenceCharacterIllustrationAssignments', N'IsFallback') IS NULL + ALTER TABLE dbo.StoryIntelligenceCharacterIllustrationAssignments ADD IsFallback bit NOT NULL CONSTRAINT DF_SICharacterIllustrationAssignments_IsFallback DEFAULT 0; + +EXEC(N' +UPDATE dbo.StoryIntelligenceCharacterIllustrationAssignments +SET AssignedUtc = COALESCE(AssignedUtc, CreatedUtc), + LastValidatedUtc = COALESCE(LastValidatedUtc, UpdatedUtc), + EvidenceVersion = COALESCE(NULLIF(EvidenceVersion, N''''), N''21K''), + AssignmentStatus = COALESCE(NULLIF(AssignmentStatus, N''''), N''Assigned'') +WHERE AssignedUtc IS NULL + OR LastValidatedUtc IS NULL + OR NULLIF(EvidenceVersion, N'''') IS NULL + OR NULLIF(AssignmentStatus, N'''') IS NULL; +'); + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_SICharacterIllustrationAssignments_ImportSession' AND object_id = OBJECT_ID(N'dbo.StoryIntelligenceCharacterIllustrationAssignments')) +BEGIN + EXEC(N'CREATE INDEX IX_SICharacterIllustrationAssignments_ImportSession + ON dbo.StoryIntelligenceCharacterIllustrationAssignments(ImportSessionID, CharacterKey, IsActive) + INCLUDE (IllustrationLibraryItemID, AssignmentStatus, IsFallback, LastValidatedUtc);'); +END; + +IF COL_LENGTH(N'dbo.StoryIntelligenceIllustrationDemands', N'QueuedUtc') IS NULL + ALTER TABLE dbo.StoryIntelligenceIllustrationDemands ADD QueuedUtc datetime2(0) NULL; + +IF COL_LENGTH(N'dbo.StoryIntelligenceIllustrationDemands', N'GenerationSource') IS NULL + ALTER TABLE dbo.StoryIntelligenceIllustrationDemands ADD GenerationSource nvarchar(80) NOT NULL CONSTRAINT DF_StoryIntelligenceIllustrationDemands_GenerationSource DEFAULT N'Demand generation'; diff --git a/PlotLine/Views/Development/StoryIntelligenceIllustrationDiagnostics.cshtml b/PlotLine/Views/Development/StoryIntelligenceIllustrationDiagnostics.cshtml new file mode 100644 index 0000000..3b8a513 --- /dev/null +++ b/PlotLine/Views/Development/StoryIntelligenceIllustrationDiagnostics.cshtml @@ -0,0 +1,119 @@ +@model StoryIntelligenceExperiencePrototypeViewModel +@{ + ViewData["Title"] = "Story Intelligence Illustration Diagnostics"; + var characters = Model.Scenes.SelectMany(scene => scene.Characters.Select(character => new { Scene = scene, Character = character })).ToList(); + var locations = Model.Scenes.Select(scene => new { Scene = scene, Location = scene.Location }).ToList(); + var assets = Model.Scenes.SelectMany(scene => scene.Assets.Select(asset => new { Scene = scene, Asset = asset })).ToList(); +} + +
+
+

Development diagnostics

+

Story Intelligence Illustration Diagnostics

+

Import Session @Model.ImportSessionId · @Model.BookTitle · Snapshot @Model.GeneratedUtc.ToString("yyyy-MM-dd HH:mm:ss") UTC

+
+ +
+
+

Characters

+
+ + + + + + + + + + + + + + @foreach (var row in characters) + { + + + + + + + + + + } + +
SceneIdentityIllustrationResolved evidenceAssignmentDemandCandidate diagnostics
@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)
@Display(row.Character.ImageResolution.ReasonChosen)
@Display(row.Character.ImageResolution.IllustrationAllocationStatus)
@Display(row.Character.ImageResolution.DemandStatus)
@Display(row.Character.ImageResolution.RejectedCandidates)
+
+
+
+ +
+
+

Locations

+
+ + + + + + + + + + + + @foreach (var row in locations) + { + var type = StoryIntelligenceIllustrationCompatibility.LocationType($"{row.Location.Name} {row.Location.Label}"); + + + + + + + + } + +
SceneLocationSemantic typeIllustrationFallback
@row.Scene.SceneNumber@row.Location.Name
@row.Location.Id
@type@row.Location.LibraryCode
@row.Location.ImageResolution.Status
@row.Location.ImageResolution.FallbackUsed
+
+
+
+ +
+
+

Assets

+
+ + + + + + + + + + + + @foreach (var row in assets) + { + var type = StoryIntelligenceIllustrationCompatibility.AssetType($"{row.Asset.Name} {row.Asset.Label}"); + + + + + + + + } + +
SceneAssetSemantic typeIllustrationFallback
@row.Scene.SceneNumber@row.Asset.Name
@row.Asset.Id
@type@row.Asset.LibraryCode
@row.Asset.ImageResolution.Status
@row.Asset.ImageResolution.FallbackUsed
+
+
+
+
+ +@functions { + private static string Display(string? value) + => string.IsNullOrWhiteSpace(value) ? "none" : value; +}