diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 1da881a..dd49e61 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -54,6 +54,8 @@ var tests = new (string Name, Action Test)[] ("Illustration assignment repair enforces explicit evidence groups and strict subtypes", IllustrationAssignmentRepairEnforcesExplicitEvidenceGroupsAndStrictSubtypes), ("Phase 21O preserves adult defaults and real-mode continuity", Phase21OPreservesAdultDefaultsAndRealModeContinuity), ("Phase 21P uses one authoritative current visualisation scene", Phase21PUsesOneAuthoritativeCurrentVisualisationScene), + ("Phase 21Q repairs production visualisation regressions", Phase21QRepairsProductionVisualisationRegressions), + ("Phase 21Q view keeps panels bounded and real controls hidden", Phase21QViewKeepsPanelsBoundedAndRealControlsHidden), ("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions), ("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel), ("Live visualisation strips illustration diagnostics", LiveVisualisationStripsIllustrationDiagnostics), @@ -628,7 +630,7 @@ static void Phase21OPreservesAdultDefaultsAndRealModeContinuity() Assert(examiner.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, $"Driving examiner should infer Adult, got {examiner.AgeBand}."); Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Bristol Road") == "Road", "Road names should classify as Road."); Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Road", "Lane names should classify as Road."); - Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("Hatchback", "Car"), "Hatchbacks must not borrow generic car artwork."); + Assert(StoryIntelligenceIllustrationCompatibility.AssetCompatible("Hatchback", "Car"), "Hatchbacks may fall back to neutral generic car artwork."); Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("SportsCar", "Hatchback"), "Vehicle subtypes should remain strict."); var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "PlotLine", "Views", "Development", "StoryIntelligenceExperience.cshtml")); @@ -653,11 +655,11 @@ static void Phase21PUsesOneAuthoritativeCurrentVisualisationScene() Assert(view.Contains("Scenes = Model.Scenes", StringComparison.Ordinal), "Initial real page payload must include the same scene collection as polled snapshots."); } -static IllustrationCharacterMetadata Metadata(string code, string ageBand, string presentation, bool isUnknownFigure = false) +static IllustrationCharacterMetadata Metadata(string code, string ageBand, string presentation, bool isUnknownFigure = false, string hairColour = "Unknown") => new( ageBand, presentation, - "Unknown", + hairColour, "Unknown", "Unknown", "Unknown", @@ -866,6 +868,60 @@ static void IllustrationEvidenceTraceDoesNotRecursivelyStorePreviousEvidence() Assert(source.Contains("evidenceHash", StringComparison.Ordinal), "Evidence trace should keep only a compact previous evidence hash."); } +static void Phase21QRepairsProductionVisualisationRegressions() +{ + var unknownNamed = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Sarah", ["Sarah watches from the lane."], []); + var mrColin = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Mr Colin Webb", ["Mr Colin Webb is a driving examiner."], []); + var maggie = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Maggie", ["Maggie is sixteen.", "Maggie pushed back her red hair."], []); + var darkHairCandidate = Metadata("char-young-adult-brunette-observer", "OlderTeen", "Feminine", hairColour: "Brown"); + var teenCandidate = Metadata("char-young-helper", "YoungTeen", "Androgynous"); + + Assert(unknownNamed.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Unknown, $"Unknown named age must remain Unknown, got {unknownNamed.AgeBand}."); + Assert(mrColin.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"Mr Colin should infer Masculine, got {mrColin.Presentation}."); + Assert(mrColin.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, $"Driving examiner title/profession should infer Adult, got {mrColin.AgeBand}."); + Assert(Rejected(mrColin, teenCandidate).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Adult title/profession should reject child/teen candidates."); + Assert(maggie.HairColour == StoryIntelligenceIllustrationCompatibility.HairColours.Red, $"Maggie red-hair evidence should be explicit, got {maggie.HairColour}."); + Assert(Rejected(maggie, darkHairCandidate).Any(reason => reason.Contains("hair colour hard mismatch", StringComparison.OrdinalIgnoreCase)), "Red-hair evidence must invalidate dark-haired assignments."); + + Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Road", "Cock Hill Lane should classify as Road/Lane."); + Assert(StoryIntelligenceIllustrationCompatibility.LocationType("derelict hospital waiting room") == "Hospital", "Derelict hospital should remain Hospital rather than generic interior."); + Assert(StoryIntelligenceIllustrationCompatibility.LocationType("bench beside the road") == "Road", "Bench context beside road should not become indoor room artwork."); + Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Road", "CarPark"), "Lane/Road must not use car park artwork."); + Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("WaitingRoom", "FlatInterior"), "Waiting room must not use flat interior artwork."); + + Assert(StoryIntelligenceIllustrationCompatibility.AssetType("blue VW Golf") == "Hatchback", "Blue VW Golf should classify as Hatchback."); + Assert(StoryIntelligenceIllustrationCompatibility.AssetColour("blue VW Golf") == "Blue", "Blue VW Golf should retain explicit colour."); + Assert(StoryIntelligenceIllustrationCompatibility.AssetType("TR6 sports car") == "SportsCar", "TR6 should remain a sports car."); + Assert(StoryIntelligenceIllustrationCompatibility.AssetType("parcel") == "Parcel", "Parcel should not use letter/document imagery."); + Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("Hatchback", "SportsCar"), "Blue hatchback must not silently use red sports-car imagery."); + + var serviceSource = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs")); + Assert(serviceSource.Contains("DemandGenerationThreshold = 1", StringComparison.Ordinal), "Live import demand should queue on first repeated missing archetype pass, not after dozens of scenes."); + Assert(serviceSource.Contains("ApplySupportingIllustrationDemandAsync", StringComparison.Ordinal), "Location and asset demand must run from the visualisation pipeline."); + Assert(serviceSource.Contains("MateriallyWrong(existingScore.Candidate.Metadata, observation.Evidence)", StringComparison.Ordinal), "Existing assignments must be revalidated against current evidence."); + Assert(serviceSource.Contains("BalancedSkinToneFallback", StringComparison.Ordinal), "Unresolved appearance should use deterministic project/book diversity rather than one global skin-tone default."); + + var librarySource = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/IllustrationLibraryServices.cs")); + var applyApprovedSource = librarySource[librarySource.IndexOf("public async Task ApplyApprovedIllustrationsAsync", StringComparison.Ordinal)..]; + Assert(!applyApprovedSource.Contains("StarterCodes.Contains", StringComparison.Ordinal), "Generated demand illustrations must not be filtered out by the runtime approved-image lookup."); +} + +static void Phase21QViewKeepsPanelsBoundedAndRealControlsHidden() +{ + var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Development/StoryIntelligenceExperience.cshtml")); + var script = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/wwwroot/js/story-intelligence-experience-prototype.js")); + var css = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/wwwroot/css/story-intelligence-experience-prototype.css")); + + Assert(!view.Contains("data-scene-title", StringComparison.Ordinal), "Redundant scene heading should be removed from the real visualisation."); + Assert(view.IndexOf("data-knowledge", StringComparison.Ordinal) < view.IndexOf("data-relationships", StringComparison.Ordinal), "Knowledge threads should be prioritised above relationship changes."); + Assert(view.Contains("Model.Mode == \"simulation\"", StringComparison.Ordinal), "Prototype controls must remain simulation-only."); + Assert(script.Contains("relationships.slice(0, 2)", StringComparison.Ordinal), "Relationship changes should be capped to the two most relevant visible cards."); + Assert(script.Contains("renderOverflowSummary", StringComparison.Ordinal), "Relationship overflow should be indicated compactly."); + Assert(css.Contains("max-height: calc(100vh - 126px)", StringComparison.Ordinal), "Side columns should be bounded to the viewport."); + Assert(css.Contains("overflow: auto", StringComparison.Ordinal), "Side-panel content should have internal scrolling."); + Assert(css.Contains("z-index: 180", StringComparison.Ordinal), "Character labels should render in a high label layer above nodes and paths."); +} + static JsonSerializerOptions JsonOptions() => new() { diff --git a/PlotLine/Services/IllustrationLibraryServices.cs b/PlotLine/Services/IllustrationLibraryServices.cs index bb021e1..c0cbf5a 100644 --- a/PlotLine/Services/IllustrationLibraryServices.cs +++ b/PlotLine/Services/IllustrationLibraryServices.cs @@ -925,7 +925,6 @@ public sealed class IllustrationLibraryService( { var items = await repository.ListAsync(new IllustrationLibraryFilter()); var lookup = items - .Where(item => IllustrationStarterBatchDefinition.StarterCodes.Contains(item.StableCode)) .Where(item => item.Status is IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Generated) .OrderBy(item => item.Status == IllustrationLibraryStatuses.Approved ? 0 : 1) .ThenByDescending(item => item.UpdatedUtc) diff --git a/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs b/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs index 9d51d05..f3011d4 100644 --- a/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs +++ b/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs @@ -253,14 +253,20 @@ public static class StoryIntelligenceIllustrationCompatibility 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, "hospital", "derelict hospital")) return "Hospital"; + if (ContainsAny(text, "pub", "public house")) return "Pub"; + if (ContainsAny(text, "beach", "seafront")) return "Beach"; + if (ContainsAny(text, "railway station", "train station", "station platform")) return "RailwayStation"; + if (ContainsAny(text, "canal", "towpath")) return "Canal"; + if (ContainsAny(text, "garage")) return "Garage"; + if (ContainsAny(text, "front door", "porch", "entrance", "house exterior", "outside the 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 (ContainsAny(text, "road", "street", "lane", "drive", "close", "avenue", "boulevard", "motorway", "bridge", "roundabout", "footpath", "track")) return "Road"; + if (ContainsAny(text, "road", "street", "lane", "drive", "close", "avenue", "boulevard", "motorway", "bridge", "roundabout", "footpath", "path", "track")) return "Road"; if (ContainsAny(text, "car park", "parking")) return "CarPark"; if (text.Contains("shop", StringComparison.OrdinalIgnoreCase)) return "Shop"; - if (text.Contains("hospital", StringComparison.OrdinalIgnoreCase)) return "Hospital"; + if (ContainsAny(text, "bench", "bus stop")) return "StreetFurniture"; if (ContainsAny(text, "flat", "apartment", "room")) return "FlatInterior"; return "UnknownInterior"; } @@ -301,6 +307,20 @@ public static class StoryIntelligenceIllustrationCompatibility return "UnknownObject"; } + public static string AssetColour(string? value) + { + var text = (value ?? string.Empty).ToLowerInvariant(); + if (ContainsAny(text, "blue", "navy")) return "Blue"; + if (ContainsAny(text, "red", "scarlet", "crimson")) return "Red"; + if (ContainsAny(text, "green")) return "Green"; + if (ContainsAny(text, "yellow")) return "Yellow"; + if (ContainsAny(text, "black")) return "Black"; + if (ContainsAny(text, "white", "cream")) return "White"; + if (ContainsAny(text, "grey", "gray", "silver")) return "Grey"; + if (ContainsAny(text, "brown", "tan")) return "Brown"; + return "Unknown"; + } + public static bool LocationCompatible(string requestedType, string candidateType) => requestedType == candidateType || (requestedType is "FrontDoor" or "Porch" && candidateType is "HouseExterior") @@ -309,6 +329,8 @@ public static class StoryIntelligenceIllustrationCompatibility public static bool AssetCompatible(string requestedType, string candidateType) => requestedType == candidateType + || (requestedType == "Hatchback" && candidateType == "Car") + || (requestedType == "FamilyCar" && candidateType == "Car") || (requestedType == "Note" && candidateType is "Letter" or "Document") || (requestedType == "Rucksack" && candidateType == "Rucksack"); @@ -340,7 +362,7 @@ public static class StoryIntelligenceIllustrationCompatibility if (ContainsAny(text, "teenager", "teenage")) return new(AgeBands.OlderTeen, 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, " mr ", " mr. ", " mrs ", " mrs. ", " miss ", " miss. ", " ms ", " ms. ", " doctor", " dr ", " dr. ", " professor", " driving examiner", " teacher", " detective", " police officer", " nurse", " receptionist", " solicitor", " landlord", " manager")) return new(AgeBands.Adult, 0.88m); + if (ContainsAny(text, " mr ", " mr. ", " mrs ", " mrs. ", " miss ", " miss. ", " ms ", " ms. ", " doctor", " dr ", " dr. ", " professor", " driving examiner", " teacher", " detective", " police officer", " nurse", " receptionist", " solicitor", " landlord", " manager", "social worker")) return new(AgeBands.Adult, 0.88m); if (ContainsAny(text, "elderly", "senior", "old woman", "old man")) return new(AgeBands.Senior, 0.9m); return EvidenceSignal.Unknown; } @@ -405,8 +427,8 @@ public static class StoryIntelligenceIllustrationCompatibility private static EvidenceSignal NamePresentation(string text) { - if (ContainsAny(text, " beth ", " maggie ", " rosie ", " grace ", " rebecca ", " annie ", " mary ", " susan ", " helen ", " elen ")) return new(Presentations.Feminine, 0.82m); - if (ContainsAny(text, " adam ", " colin ", " david ", " kevin ", " simon ", " graham ", " gareth ", " john ", " michael ", " peter ", " george ")) return new(Presentations.Masculine, 0.82m); + if (ContainsAny(text, " beth ", " maggie ", " rosie ", " grace ", " zoe ", " rebecca ", " annie ", " mary ", " sharon ", " catherine ", " susan ", " helen ", " elen ")) return new(Presentations.Feminine, 0.82m); + if (ContainsAny(text, " adam ", " colin ", " david ", " kevin ", " simon ", " graham ", " gareth ", " john ", " michael ", " peter ", " richard ", " rick ", " george ")) return new(Presentations.Masculine, 0.82m); return EvidenceSignal.Unknown; } @@ -442,13 +464,13 @@ public static class StoryIntelligenceIllustrationCompatibility } if (StartsWithAny(clean, "mr", "father", "dad", "uncle", "brother", "son", "boy", "man", "male") - || clean is "adam" or "colin" or "david" or "kevin" or "simon" or "graham" or "gareth" or "john" or "michael" or "peter" or "george") + || clean is "adam" or "colin" or "david" or "kevin" or "simon" or "graham" or "gareth" or "john" or "michael" or "peter" or "george" or "richard" or "rick") { 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 "grace" or "rosie" or "rebecca" or "annie" or "mary" or "susan" or "helen" or "elen") + || clean is "beth" or "maggie" or "grace" or "rosie" or "rebecca" or "annie" or "mary" or "susan" or "helen" or "elen" or "zoe" or "sharon" or "catherine") { return new(Presentations.Feminine, 0.98m); } diff --git a/PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs b/PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs index 2ac344f..981e012 100644 --- a/PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs +++ b/PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs @@ -11,6 +11,7 @@ namespace PlotLine.Services; public interface IStoryIntelligenceIllustrationMatchingService { Task ApplyCharacterIllustrationsAsync(int projectId, StoryIntelligenceExperiencePrototypeViewModel prototype); + Task ApplySupportingIllustrationDemandAsync(int projectId, StoryIntelligenceExperiencePrototypeViewModel prototype); } public sealed class StoryIntelligenceIllustrationMatchingService( @@ -22,7 +23,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService( { private const int SuitableScore = 48; private const int ReassignmentMargin = 24; - private const int DemandGenerationThreshold = 3; + private const int DemandGenerationThreshold = 1; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, @@ -152,6 +153,225 @@ public sealed class StoryIntelligenceIllustrationMatchingService( } } + public async Task ApplySupportingIllustrationDemandAsync(int projectId, StoryIntelligenceExperiencePrototypeViewModel prototype) + { + if (projectId <= 0 || prototype.Scenes.Count == 0) + { + return; + } + + var items = await library.ListAsync(new IllustrationLibraryFilter()); + var lookup = items + .Where(item => item.Status is IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Generated) + .GroupBy(item => item.StableCode, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.ToList(), StringComparer.OrdinalIgnoreCase); + + foreach (var location in prototype.Scenes.Select(scene => scene.Location)) + { + await ApplyLocationDemandAsync(projectId, location, lookup); + } + + foreach (var asset in prototype.Scenes.SelectMany(scene => scene.Assets)) + { + await ApplyAssetDemandAsync(projectId, asset, lookup); + } + } + + private async Task ApplyLocationDemandAsync( + int projectId, + StoryIntelligenceExperienceLocationState location, + IReadOnlyDictionary> lookup) + { + var type = StoryIntelligenceIllustrationCompatibility.LocationType($"{location.Name} {location.Label}"); + if (type is "UnknownInterior") + { + location.ImageResolution.ReasonChosen ??= "Location type is unresolved; neutral location fallback retained and no finished unrelated artwork assigned."; + location.ImageResolution.DemandStatus ??= "Not queued until location type resolves"; + return; + } + + if (!location.ImageResolution.FallbackUsed && !string.IsNullOrWhiteSpace(location.ImageResolution.FinalImageUrl)) + { + return; + } + + var demandKey = $"location|{type}".ToLowerInvariant(); + var code = $"loc-demand-{StableHash(demandKey)}"; + location.LibraryCode = code; + + if (TryApplyGeneratedSupportingImage(location.Id, code, location.ImagePath, lookup, out var resolution)) + { + location.ImageResolution = WithDemandDiagnostics( + resolution, + demandKey, + "Satisfied by generated demand illustration", + $"Applied generated {type} location illustration from demand key {demandKey}."); + location.ImagePath = resolution.FinalImageUrl ?? location.ImagePath; + return; + } + + var demand = await assignments.UpsertDemandAsync(new StoryIntelligenceIllustrationDemandRequest( + projectId, + demandKey, + "Unknown", + "Unknown", + "Unknown", + "Unknown", + "Unknown", + "Unknown", + "Unknown")); + var queued = await QueueDemandSpecAsync(LocationDemandSpecification(code, demandKey, type)); + location.ImageResolution = WithDemandDiagnostics( + location.ImageResolution, + demandKey, + queued + ? $"Queued missing {type} location illustration ({demand.RequestCount:N0}/{DemandGenerationThreshold:N0})" + : $"Demand recorded for missing {type} location illustration ({demand.RequestCount:N0}/{DemandGenerationThreshold:N0})", + $"No generated compatible {type} location illustration exists; neutral typed fallback retained while reusable demand is generated.", + code, + queued ? "DemandQueued" : "DemandRecorded"); + } + + private async Task ApplyAssetDemandAsync( + int projectId, + StoryIntelligenceExperienceAssetState asset, + IReadOnlyDictionary> lookup) + { + var evidenceText = $"{asset.Name} {asset.Label}"; + var type = StoryIntelligenceIllustrationCompatibility.AssetType(evidenceText); + if (type is "UnknownObject") + { + asset.ImageResolution.ReasonChosen ??= "Asset type is unresolved; neutral asset fallback retained and no unrelated finished artwork assigned."; + asset.ImageResolution.DemandStatus ??= "Not queued until asset type resolves"; + return; + } + + if (!asset.ImageResolution.FallbackUsed && !string.IsNullOrWhiteSpace(asset.ImageResolution.FinalImageUrl)) + { + return; + } + + var colour = StoryIntelligenceIllustrationCompatibility.AssetColour(evidenceText); + var demandKey = $"asset|{type}|{colour}".ToLowerInvariant(); + var code = $"asset-demand-{StableHash(demandKey)}"; + asset.LibraryCode = code; + + if (TryApplyGeneratedSupportingImage(asset.Id, code, asset.ImagePath, lookup, out var resolution)) + { + asset.ImageResolution = WithDemandDiagnostics( + resolution, + demandKey, + "Satisfied by generated demand illustration", + $"Applied generated {type} asset illustration from demand key {demandKey}."); + asset.ImagePath = resolution.FinalImageUrl ?? asset.ImagePath; + return; + } + + var demand = await assignments.UpsertDemandAsync(new StoryIntelligenceIllustrationDemandRequest( + projectId, + demandKey, + "Unknown", + "Unknown", + "Unknown", + "Unknown", + "Unknown", + "Unknown", + "Unknown")); + var queued = await QueueDemandSpecAsync(AssetDemandSpecification(code, demandKey, type, colour)); + asset.ImageResolution = WithDemandDiagnostics( + asset.ImageResolution, + demandKey, + queued + ? $"Queued missing {colour.ToLowerInvariant()} {type} asset illustration ({demand.RequestCount:N0}/{DemandGenerationThreshold:N0})" + : $"Demand recorded for missing {colour.ToLowerInvariant()} {type} asset illustration ({demand.RequestCount:N0}/{DemandGenerationThreshold:N0})", + $"No generated compatible {type} asset illustration exists; neutral typed fallback retained while reusable demand is generated.", + code, + queued ? "DemandQueued" : "DemandRecorded"); + } + + private bool TryApplyGeneratedSupportingImage( + string entityId, + string code, + string fallbackImageUrl, + IReadOnlyDictionary> lookup, + out StoryIntelligenceExperienceImageResolution resolution) + { + if (lookup.TryGetValue(code, out var candidates)) + { + foreach (var candidate in candidates) + { + var publicUrl = ResolveExistingPublicUploadUrl(candidate.ThumbnailPath) ?? ResolveExistingPublicUploadUrl(candidate.FilePath); + if (publicUrl is null) + { + continue; + } + + resolution = new StoryIntelligenceExperienceImageResolution + { + EntityId = entityId, + StableCode = code, + IllustrationLibraryItemId = candidate.IllustrationLibraryItemID, + Status = candidate.Status, + FinalImageUrl = publicUrl, + FallbackImageUrl = fallbackImageUrl, + FallbackUsed = false + }; + return true; + } + } + + resolution = new StoryIntelligenceExperienceImageResolution(); + return false; + } + + private static StoryIntelligenceExperienceImageResolution WithDemandDiagnostics( + StoryIntelligenceExperienceImageResolution source, + string demandKey, + string demandStatus, + string reasonChosen, + string? stableCode = null, + string? status = null) + => new() + { + EntityId = source.EntityId, + StableCode = stableCode ?? source.StableCode, + IllustrationLibraryItemId = source.IllustrationLibraryItemId, + Status = status ?? source.Status, + FinalImageUrl = source.FinalImageUrl, + FallbackImageUrl = source.FallbackImageUrl, + FallbackUsed = source.FallbackUsed, + AssignmentConfidence = source.AssignmentConfidence, + MatchingScore = source.MatchingScore, + RejectedCandidates = source.RejectedCandidates, + ReasonChosen = reasonChosen, + ReasonReassigned = source.ReasonReassigned, + PreviousIllustration = source.PreviousIllustration, + PreviousEvidence = source.PreviousEvidence, + CurrentEvidence = source.CurrentEvidence, + DemandKey = demandKey, + EvidenceWarnings = source.EvidenceWarnings, + DemandStatus = demandStatus, + ResolvedPresentation = source.ResolvedPresentation, + ResolvedAgeBand = source.ResolvedAgeBand, + RelationshipDerivedConfidence = source.RelationshipDerivedConfidence, + NameDerivedConfidence = source.NameDerivedConfidence, + PronounDerivedConfidence = source.PronounDerivedConfidence, + IllustrationReuseCount = source.IllustrationReuseCount, + IllustrationAllocationStatus = source.IllustrationAllocationStatus + }; + + private async Task QueueDemandSpecAsync(IllustrationGenerationSpecification specification) + { + var prompt = promptBuilder.Build(specification); + var item = await library.UpsertPlannedAsync(specification, prompt); + if (item.Status is IllustrationLibraryStatuses.Queued or IllustrationLibraryStatuses.Generating or IllustrationLibraryStatuses.Generated or IllustrationLibraryStatuses.Approved) + { + return false; + } + + return await library.QueueAsync(item.IllustrationLibraryItemID, null) > 0; + } + private MatchScore? ChooseAssignment( CharacterObservation observation, StoryIntelligenceCharacterIllustrationAssignment? existing, @@ -171,7 +391,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService( } var materiallyWrong = MateriallyWrong(existingScore.Candidate.Metadata, observation.Evidence); - if (materiallyWrong && observation.Evidence.HasStrongPresentationEvidence) + if (materiallyWrong) { return best; } @@ -291,7 +511,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService( var presentation = NormaliseUnknown(demand.Presentation, "Androgynous"); var hairColour = NormaliseUnknown(demand.HairColour, "Brown"); var hairLength = NormaliseUnknown(demand.HairLength, "Medium"); - var skinTone = NormaliseUnknown(demand.SkinTone, "Medium"); + var skinTone = NormaliseUnknown(demand.SkinTone, BalancedSkinToneFallback(demand.DemandKey)); var features = new List { "DemandGenerated" }; if (string.Equals(demand.Glasses, "Yes", StringComparison.OrdinalIgnoreCase)) { @@ -335,6 +555,77 @@ public sealed class StoryIntelligenceIllustrationMatchingService( }; } + private static IllustrationGenerationSpecification LocationDemandSpecification(string code, string demandKey, string type) + => new() + { + Category = IllustrationLibraryCategories.Location, + Code = code, + Title = $"{ReadableToken(type)} location", + Description = "Demand-created reusable location illustration for Story Intelligence matching.", + VisualSubject = $"A clear reusable {ReadableToken(type).ToLowerInvariant()} environment suitable for manuscript visualisation.", + Composition = "single readable environment, no prominent people, strong central focal point, suitable for circular and wide crops", + Mood = "story-ready, atmospheric but clear, visually specific rather than generic", + LocationType = type, + Era = "Contemporary or late twentieth century", + Setting = type is "Road" ? "Exterior" : type is "CarPark" or "HouseExterior" ? "Exterior" : "Interior", + Palette = ["clear blue", "warm practical light", "balanced neutral accents"], + MetadataTags = ["location", "demand-generated", "story-intelligence"], + Metadata = new(StringComparer.OrdinalIgnoreCase) + { + ["libraryPurpose"] = "Story Intelligence demand-generated location", + ["privacy"] = "generic-no-manuscript-data", + ["style"] = "editorial-stylised-realism", + ["generationSource"] = "Demand generation", + ["demandKey"] = demandKey, + ["locationType"] = type + } + }; + + private static IllustrationGenerationSpecification AssetDemandSpecification(string code, string demandKey, string type, string colour) + => new() + { + Category = IllustrationLibraryCategories.Asset, + Code = code, + Title = $"{ReadableToken(colour)} {ReadableToken(type)}", + Description = "Demand-created reusable asset illustration for Story Intelligence matching.", + VisualSubject = colour == "Unknown" + ? $"A clear reusable {ReadableToken(type).ToLowerInvariant()} object." + : $"A clear reusable {colour.ToLowerInvariant()} {ReadableToken(type).ToLowerInvariant()} object.", + Composition = "single dominant centred object, crisp silhouette, full object visible where practical, no text or logos", + Mood = "clean, readable, evidence-like, story-ready", + ObjectType = type, + Colour = colour, + Era = "Contemporary or late twentieth century", + Palette = colour == "Unknown" + ? ["clear blue", "warm light", "balanced neutral"] + : [colour.ToLowerInvariant(), "clear blue", "warm light"], + MetadataTags = ["asset", "demand-generated", "story-intelligence"], + Metadata = new(StringComparer.OrdinalIgnoreCase) + { + ["libraryPurpose"] = "Story Intelligence demand-generated asset", + ["privacy"] = "generic-no-manuscript-data", + ["style"] = "editorial-stylised-realism", + ["generationSource"] = "Demand generation", + ["demandKey"] = demandKey, + ["objectType"] = type, + ["colour"] = colour + } + }; + + private static string ReadableToken(string value) + => Regex.Replace(value, "([a-z])([A-Z])", "$1 $2"); + + private static string BalancedSkinToneFallback(string demandKey) + { + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(demandKey)); + return (hash[0] % 3) switch + { + 0 => "Light", + 1 => "Medium", + _ => "Dark" + }; + } + private static Dictionary BuildCharacterObservations(StoryIntelligenceExperiencePrototypeViewModel prototype) { var observations = new Dictionary(StringComparer.OrdinalIgnoreCase); diff --git a/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs b/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs index 9cbb6ac..56999b9 100644 --- a/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs +++ b/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs @@ -147,6 +147,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( if (run.ProjectID.HasValue) { await illustrationMatcher.ApplyCharacterIllustrationsAsync(run.ProjectID.Value, model); + await illustrationMatcher.ApplySupportingIllustrationDemandAsync(run.ProjectID.Value, model); ApplyIllustrationDiagnostics(model); } StripLiveDiagnostics(model); @@ -283,6 +284,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( StabiliseCharacterIllustrations(model); await illustrationLibrary.ApplyApprovedIllustrationsAsync(model); await illustrationMatcher.ApplyCharacterIllustrationsAsync(session.ProjectID, model); + await illustrationMatcher.ApplySupportingIllustrationDemandAsync(session.ProjectID, model); ApplyIllustrationDiagnostics(model); StripLiveDiagnostics(model); LogSnapshotSynchronisation(importSessionId, currentSnapshot, current, scenes, model.ChangeToken); @@ -433,7 +435,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( Id = CharacterId(group.Key), LibraryCode = libraryCode, Name = group.Key, - Role = isPov ? "POV character" : FirstConfigured(character.RoleInScene, character.MentionedOnly == true ? "Referenced" : "Scene character"), + Role = isPov ? "POV character" : NonPovRole(character), Relevance = Trim(FirstConfigured(character.Notes, string.Join(", ", character.Actions ?? []), character.MentionedOnly == true ? "Mentioned only" : "Present"), 60), Weight = isPov ? 100 : CharacterWeight(character), ImagePath = CharacterFallback(group.Key) @@ -484,6 +486,16 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( .ToList(); } + private static string NonPovRole(SceneIntelligenceCharacter character) + { + var role = FirstConfigured(character.RoleInScene, character.MentionedOnly == true ? "Referenced" : "Scene character"); + return role.Contains("pov", StringComparison.OrdinalIgnoreCase) + || role.Contains("point of view", StringComparison.OrdinalIgnoreCase) + || role.Contains("narrator", StringComparison.OrdinalIgnoreCase) + ? character.MentionedOnly == true ? "Referenced" : "Scene character" + : role; + } + private static string? ResolveNarratorName(SceneIntelligenceScene scene) { var povName = Clean(scene.PointOfView?.CharacterName); diff --git a/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml b/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml index 3c235bd..5e8a9de 100644 --- a/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml +++ b/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml @@ -150,8 +150,7 @@

Living story view

-

Reading the manuscript

-

+

Reading the manuscript

@@ -177,10 +176,6 @@
diff --git a/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css b/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css index 369f963..c501799 100644 --- a/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css +++ b/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css @@ -299,7 +299,17 @@ input:focus-visible { display: grid; align-content: start; gap: 14px; + max-height: calc(100vh - 126px); min-height: 0; + overflow: hidden; +} + +.story-exp-left { + grid-template-rows: auto auto minmax(150px, 1fr) minmax(120px, 0.72fr); +} + +.story-exp-right { + grid-template-rows: minmax(190px, 1.2fr) minmax(150px, 0.95fr) auto; } .story-exp-left, @@ -324,6 +334,17 @@ input:focus-visible { padding: 16px; } +.story-exp-panel { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + min-height: 0; + overflow: hidden; +} + +.story-exp-panel--compact { + padding: 12px; +} + .story-exp-progress-ring { position: relative; display: grid; @@ -445,6 +466,9 @@ input:focus-visible { margin: 0; padding: 0; list-style: none; + min-height: 0; + overflow: auto; + scrollbar-width: thin; } .story-exp-list li, @@ -507,9 +531,8 @@ input:focus-visible { .story-exp-stage-copy { display: grid; - grid-template-columns: minmax(260px, 0.64fr) 1fr; + grid-template-columns: minmax(0, 1fr); align-items: center; - gap: 18px; min-height: 42px; } @@ -524,12 +547,18 @@ input:focus-visible { white-space: nowrap; } +.story-exp-scene-summary, .story-exp-stage-copy p:last-child { - grid-column: 2; + grid-column: 1; margin: 0; color: var(--story-soft); font-size: clamp(0.78rem, 0.86vw, 0.92rem); line-height: 1.28; + max-width: 100%; + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; } .story-exp-visual { @@ -672,6 +701,7 @@ input:focus-visible { .story-exp-characters { position: relative; + z-index: 90; width: 100%; height: 100%; } @@ -683,7 +713,7 @@ input:focus-visible { height: var(--node-size, 150px); transform: translate(var(--x, 0), var(--y, 0)) scale(var(--scale, 1)); opacity: var(--opacity, 1); - z-index: calc(var(--z, 3) + 30); + z-index: calc(var(--z, 3) + 90); pointer-events: auto; transition: transform 1050ms cubic-bezier(.16, .84, .28, 1), opacity 780ms ease; } @@ -729,7 +759,7 @@ input:focus-visible { .story-character__text { position: absolute; - z-index: 80; + z-index: 180; top: calc(var(--node-size, 150px) + 9px); left: 50%; display: grid; @@ -741,8 +771,8 @@ input:focus-visible { border: 1px solid rgba(151, 185, 218, 0.1); border-radius: 10px; padding: 5px 7px 6px; - background: rgba(4, 10, 20, 0.58); - box-shadow: 0 12px 26px rgba(0, 0, 0, 0.24); + background: rgba(4, 10, 20, 0.82); + box-shadow: 0 12px 26px rgba(0, 0, 0, 0.28), 0 0 0 1px rgba(4, 10, 20, 0.22); backdrop-filter: blur(8px); text-align: center; transform: translateX(-50%); @@ -1088,6 +1118,9 @@ input:focus-visible { .story-exp-insight-list { display: grid; gap: 9px; + min-height: 0; + overflow: auto; + scrollbar-width: thin; } .story-exp-insight { @@ -1100,6 +1133,31 @@ input:focus-visible { transition: opacity 560ms ease, transform 700ms cubic-bezier(.16, .84, .28, 1); } +.story-exp-panel--compact .story-exp-insight { + padding: 8px 9px; +} + +.story-exp-panel--compact .story-exp-insight p { + display: -webkit-box; + overflow: hidden; + margin: 0; + font-size: 0.76rem; + line-height: 1.25; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.story-exp-more { + border: 1px solid rgba(151, 185, 218, 0.12); + border-radius: 10px; + padding: 7px 9px; + background: rgba(255, 255, 255, 0.035); + color: var(--story-soft); + font-size: 0.78rem; + font-weight: 800; + text-align: left; +} + .story-exp-list li, .story-exp-feed li { transition: opacity 520ms ease, transform 640ms cubic-bezier(.16, .84, .28, 1); diff --git a/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js b/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js index 8d43c3c..6839f62 100644 --- a/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js +++ b/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js @@ -161,7 +161,6 @@ } function updateSceneCopy(scene) { - text(dom.sceneTitle, `Scene ${scene.sceneNumber} • ${scene.title}`); text(dom.sceneSummary, scene.summary); } @@ -346,12 +345,14 @@ } function reconcileRelationships(scene) { - renderInsights(dom.relationships, scene.relationships.map((relationship) => ({ + const relationships = scene.relationships.map((relationship) => ({ id: relationship.id, title: relationship.label, detail: relationship.state, tone: relationship.weight >= 90 ? "confirmed" : relationship.weight >= 70 ? "strengthening" : "emerging" - }))); + })); + renderInsights(dom.relationships, relationships.slice(0, 2)); + renderOverflowSummary(dom.relationships, relationships.length - 2, "relationship"); } function updateKnowledgeThreads(scene) { @@ -458,6 +459,26 @@ }); } + function renderOverflowSummary(container, hiddenCount, noun) { + if (!container) return; + let node = container.querySelector("[data-insight-overflow]"); + if (hiddenCount <= 0) { + node?.remove(); + return; + } + + if (!node) { + node = document.createElement("button"); + node.type = "button"; + node.className = "story-exp-more"; + node.dataset.insightOverflow = "true"; + container.append(node); + } + + node.textContent = `+${hiddenCount} more ${noun}${hiddenCount === 1 ? "" : "s"}`; + node.title = `${hiddenCount} additional ${noun}${hiddenCount === 1 ? "" : "s"} in this scene`; + } + function renderList(container, items) { const activeIds = new Set(items.map((item, index) => feedId(item, index))); items.forEach((item, index) => {