diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 1f62091..34155c1 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -1080,6 +1080,7 @@ static void Phase21UEnforcesAliasAgeSemanticAndUiResetRules() var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var onboarding = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligence.cshtml")); var storyMemory = File.ReadAllText(Path.Combine(root, "Services/StoryMemoryServices.cs")); + var persistedRunner = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceRunner.cs")); var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/story-intelligence-experience-prototype.js")); var css = File.ReadAllText(Path.Combine(root, "wwwroot/css/story-intelligence-experience-prototype.css")); var resetSql = File.ReadAllText(Path.Combine(root, "Sql/143_Phase21U_DevelopmentStoryIntelligenceReset.sql")); @@ -1087,6 +1088,13 @@ static void Phase21UEnforcesAliasAgeSemanticAndUiResetRules() Assert(onboarding.Contains("name=\"appearancePreference\"", StringComparison.Ordinal), "Import setup must ask for appearance preference."); Assert(storyMemory.Contains("CharacterAppearanceExtractionService.Extract", StringComparison.Ordinal), "Durable Story Memory must consume dedicated appearance extraction."); + Assert(persistedRunner.Contains("QueueDemand = true", StringComparison.Ordinal), "Live imports must queue missing illustration demand, not only record it."); + Assert(persistedRunner.Contains("MaxDemandToQueue = 24", StringComparison.Ordinal), "Live imports must cap automatic demand generation."); + Assert(storyMemory.Contains("UpsertPlannedAsync(specification", StringComparison.Ordinal), "Durable Story Memory demand must create reusable illustration library items."); + Assert(storyMemory.Contains("QueueAsync(planned.IllustrationLibraryItemID", StringComparison.Ordinal), "Durable Story Memory demand must queue generated library items for the worker."); + Assert(storyMemory.Contains("demand.IllustrationLibraryItemId", StringComparison.Ordinal), "Demand fallback assignments must retain queued item IDs so generated images can attach later."); + Assert(storyMemory.Contains("categoryCap", StringComparison.Ordinal), "Automatic demand generation must distribute the queue cap across characters, locations and assets."); + Assert(storyMemory.Contains("evidence.HairColour", StringComparison.Ordinal), "Character demand keys/specs must include durable hair colour evidence."); Assert(storyMemory.Contains("Maximum one primary POV", StringComparison.Ordinal) || storyMemory.Contains("ResolveScenePov", StringComparison.Ordinal), "Story Memory must use a single POV resolver."); Assert(!script.Contains("renderOverflowSummary", StringComparison.Ordinal), "Side panels must not use hidden '+N more' overflow controls."); Assert(css.Contains("-webkit-line-clamp: 3", StringComparison.Ordinal), "Insight body text should be clamped inside its card."); diff --git a/PlotLine/Services/PersistedStoryIntelligenceRunner.cs b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs index cfd9910..8e07ee9 100644 --- a/PlotLine/Services/PersistedStoryIntelligenceRunner.cs +++ b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs @@ -347,7 +347,7 @@ public sealed class PersistedStoryIntelligenceRunner( importSessionId.Value, run.UserID, sceneResultId, - new StoryMemoryResolveOptions { QueueDemand = false }, + new StoryMemoryResolveOptions { QueueDemand = true, MaxDemandToQueue = 24 }, cancellationToken); } catch (Exception ex) when (ex is not OperationCanceledException && !IsFatal(ex)) diff --git a/PlotLine/Services/StoryMemoryServices.cs b/PlotLine/Services/StoryMemoryServices.cs index 1731615..c044626 100644 --- a/PlotLine/Services/StoryMemoryServices.cs +++ b/PlotLine/Services/StoryMemoryServices.cs @@ -18,6 +18,7 @@ public sealed class StoryMemoryService( IStoryIntelligencePipelineRepository pipelines, IStoryMemoryRepository memory, IIllustrationLibraryRepository illustrationLibrary, + IIllustrationPromptBuilder promptBuilder, IUploadStorageService uploadStorage, ILogger logger) : IStoryMemoryService { @@ -180,8 +181,8 @@ public sealed class StoryMemoryService( continue; } - await RecordDemandAsync(importSessionId, character.ProjectID, StoryMemoryEntityTypes.Character, DemandKey(character.CanonicalIdentityKey, evidence.AgeBand, evidence.Presentation), cancellationToken); - await memory.UpsertAssignmentAsync(new StoryMemoryIllustrationAssignmentSave(importSessionId, StoryMemoryEntityTypes.Character, character.StoryMemoryCharacterID, null, null, "DemandRecorded", true, 0, 0, "No compatible character illustration exists; durable demand recorded.", ProcessorVersion)); + var demand = await RecordDemandAsync(importSessionId, character.ProjectID, StoryMemoryEntityTypes.Character, DemandKey(character.CanonicalIdentityKey, evidence.AgeBand, evidence.Presentation, evidence.HairColour, evidence.HairLength, evidence.SkinTone), options, CharacterDemandSpecification(character, evidence), cancellationToken); + await memory.UpsertAssignmentAsync(new StoryMemoryIllustrationAssignmentSave(importSessionId, StoryMemoryEntityTypes.Character, character.StoryMemoryCharacterID, demand.IllustrationLibraryItemId, demand.StableCode, demand.AssignmentStatus, true, 0, 0, demand.AssignmentReason, ProcessorVersion)); } foreach (var location in locations) @@ -201,8 +202,8 @@ public sealed class StoryMemoryService( } else { - await RecordDemandAsync(importSessionId, location.ProjectID, StoryMemoryEntityTypes.Location, DemandKey(location.CanonicalIdentityKey, requestedType), cancellationToken); - await memory.UpsertAssignmentAsync(new StoryMemoryIllustrationAssignmentSave(importSessionId, StoryMemoryEntityTypes.Location, location.StoryMemoryLocationID, null, null, "DemandRecorded", true, 0, 0, $"No compatible {requestedType} location illustration exists; demand recorded.", ProcessorVersion)); + var demand = await RecordDemandAsync(importSessionId, location.ProjectID, StoryMemoryEntityTypes.Location, DemandKey(location.CanonicalIdentityKey, requestedType), options, LocationDemandSpecification(location, requestedType), cancellationToken); + await memory.UpsertAssignmentAsync(new StoryMemoryIllustrationAssignmentSave(importSessionId, StoryMemoryEntityTypes.Location, location.StoryMemoryLocationID, demand.IllustrationLibraryItemId, demand.StableCode, demand.AssignmentStatus, true, 0, 0, demand.AssignmentReason, ProcessorVersion)); } } @@ -226,8 +227,8 @@ public sealed class StoryMemoryService( } else { - await RecordDemandAsync(importSessionId, asset.ProjectID, StoryMemoryEntityTypes.Asset, DemandKey(asset.CanonicalIdentityKey, requestedType, requestedColour), cancellationToken); - await memory.UpsertAssignmentAsync(new StoryMemoryIllustrationAssignmentSave(importSessionId, StoryMemoryEntityTypes.Asset, asset.StoryMemoryAssetID, null, null, "DemandRecorded", true, 0, 0, $"No compatible {requestedType} asset illustration exists; demand recorded.", ProcessorVersion)); + var demand = await RecordDemandAsync(importSessionId, asset.ProjectID, StoryMemoryEntityTypes.Asset, DemandKey(asset.CanonicalIdentityKey, requestedType, requestedColour), options, AssetDemandSpecification(asset, requestedType, requestedColour), cancellationToken); + await memory.UpsertAssignmentAsync(new StoryMemoryIllustrationAssignmentSave(importSessionId, StoryMemoryEntityTypes.Asset, asset.StoryMemoryAssetID, demand.IllustrationLibraryItemId, demand.StableCode, demand.AssignmentStatus, true, 0, 0, demand.AssignmentReason, ProcessorVersion)); } } } @@ -740,10 +741,182 @@ public sealed class StoryMemoryService( return physicalPath is not null && File.Exists(physicalPath) ? storedPath : null; } - private async Task RecordDemandAsync(int importSessionId, int projectId, string entityType, string archetypeKey, CancellationToken cancellationToken) + private async Task RecordDemandAsync( + int importSessionId, + int projectId, + string entityType, + string archetypeKey, + StoryMemoryResolveOptions options, + IllustrationGenerationSpecification specification, + CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - await memory.UpsertDemandAsync(new StoryMemoryIllustrationDemandSave(importSessionId, projectId, entityType, archetypeKey, 1, "Recorded", null, null)); + var recorded = await memory.UpsertDemandAsync(new StoryMemoryIllustrationDemandSave(importSessionId, projectId, entityType, archetypeKey, 1, "Recorded", null, null)); + if (!options.QueueDemand) + { + return DemandResolution.Recorded(); + } + + if (recorded.QueuedIllustrationLibraryItemID.HasValue || recorded.GeneratedIllustrationLibraryItemID.HasValue) + { + var existingId = recorded.GeneratedIllustrationLibraryItemID ?? recorded.QueuedIllustrationLibraryItemID; + return new(existingId, specification.Code, "DemandQueued", $"Demand already has queued illustration item {existingId:N0}."); + } + + if (options.MaxDemandToQueue is int maxDemandToQueue && maxDemandToQueue > 0) + { + var categoryCap = Math.Max(1, maxDemandToQueue / 3); + var queuedDemandItems = (await illustrationLibrary.ListAsync(new IllustrationLibraryFilter())) + .Count(item => string.Equals(item.Category, specification.Category, StringComparison.OrdinalIgnoreCase) + && item.IsActive + && item.StableCode.StartsWith("demand-", StringComparison.OrdinalIgnoreCase) + && item.Status is IllustrationLibraryStatuses.Planned + or IllustrationLibraryStatuses.Queued + or IllustrationLibraryStatuses.Generating + or IllustrationLibraryStatuses.Generated + or IllustrationLibraryStatuses.Approved); + if (queuedDemandItems >= categoryCap) + { + return new(null, null, "DemandRecorded", $"No compatible illustration exists; demand recorded but {specification.Category.ToLowerInvariant()} queue cap {categoryCap:N0} has been reached."); + } + } + + var prompt = promptBuilder.Build(specification); + var planned = await illustrationLibrary.UpsertPlannedAsync(specification, prompt); + await illustrationLibrary.QueueAsync(planned.IllustrationLibraryItemID, null); + var queued = await memory.UpsertDemandAsync(new StoryMemoryIllustrationDemandSave( + importSessionId, + projectId, + entityType, + archetypeKey, + 1, + "Queued", + planned.IllustrationLibraryItemID, + null)); + return new(queued.QueuedIllustrationLibraryItemID ?? planned.IllustrationLibraryItemID, planned.StableCode, "DemandQueued", $"No compatible illustration exists; queued demand-generated library item {planned.StableCode}."); + } + + private static IllustrationGenerationSpecification CharacterDemandSpecification(StoryMemoryCharacter character, CharacterEvidenceProfile evidence) + { + var code = DemandStableCode(StoryMemoryEntityTypes.Character, character.CanonicalIdentityKey, evidence.AgeBand, evidence.Presentation, evidence.HairColour, evidence.HairLength, evidence.SkinTone); + var age = UnknownToDefault(evidence.AgeBand, "Adult"); + var presentation = UnknownToDefault(evidence.Presentation, "Androgynous"); + var hairColour = UnknownToDefault(evidence.HairColour, "Brown"); + var hairLength = UnknownToDefault(evidence.HairLength, "Medium"); + var skinTone = UnknownToDefault(evidence.SkinTone, "Medium"); + return new IllustrationGenerationSpecification + { + Category = IllustrationLibraryCategories.Character, + Code = code, + Title = $"{age} {presentation} character", + Description = "Reusable character portrait generated from durable Story Memory appearance evidence. Not canonical to a specific manuscript.", + VisualSubject = $"A {age.ToLowerInvariant()} {presentation.ToLowerInvariant()} person with {hairLength.ToLowerInvariant()} {hairColour.ToLowerInvariant()} hair and {skinTone.ToLowerInvariant()} skin tone.", + Composition = "single head-and-shoulders portrait with clear facial lighting, neutral story background, and strong thumbnail readability", + Mood = "present, natural, story-aware", + ApparentAgeBand = age, + Presentation = presentation, + HairColour = hairColour, + HairLength = hairLength, + SkinTone = skinTone, + ClothingEra = "1980s", + ClothingStyle = "everyday naturalistic", + Palette = ["clear blue", "warm amber", "natural skin tones"], + MetadataTags = ["character", "story-memory-demand"], + Metadata = DemandMetadata("Character", character.CanonicalIdentityKey) + }; + } + + private static IllustrationGenerationSpecification LocationDemandSpecification(StoryMemoryLocation location, string requestedType) + { + var code = DemandStableCode(StoryMemoryEntityTypes.Location, location.CanonicalIdentityKey, requestedType); + var locationType = UnknownToDefault(requestedType, "UnknownInterior"); + return new IllustrationGenerationSpecification + { + Category = IllustrationLibraryCategories.Location, + Code = code, + Title = $"{SplitWords(locationType)} location", + Description = "Reusable location illustration generated from durable Story Memory semantic type. Not canonical to a specific manuscript.", + VisualSubject = $"A clear, reusable {SplitWords(locationType).ToLowerInvariant()} environment.", + Composition = "one readable focal environment, no prominent people, cinematic but bright enough for a small interface thumbnail", + Mood = "grounded, atmospheric, legible", + LocationType = locationType, + Era = "1980s", + Setting = location.InteriorExterior ?? "Story setting", + Palette = ["clear navy", "warm light", "balanced natural colour"], + MetadataTags = ["location", "story-memory-demand"], + Metadata = DemandMetadata("Location", location.CanonicalIdentityKey) + }; + } + + private static IllustrationGenerationSpecification AssetDemandSpecification(StoryMemoryAsset asset, string requestedType, string requestedColour) + { + var code = DemandStableCode(StoryMemoryEntityTypes.Asset, asset.CanonicalIdentityKey, requestedType, requestedColour); + var objectType = UnknownToDefault(requestedType, "UnknownObject"); + var colour = UnknownToDefault(requestedColour, "Unknown"); + return new IllustrationGenerationSpecification + { + Category = IllustrationLibraryCategories.Asset, + Code = code, + Title = $"{SplitWords(colour)} {SplitWords(objectType)} asset".Trim(), + Description = "Reusable asset illustration generated from durable Story Memory object type. Not canonical to a specific manuscript.", + VisualSubject = $"A single clear {SplitWords(objectType).ToLowerInvariant()} object" + (colour == "Unknown" ? "." : $" with {colour.ToLowerInvariant()} colour cues."), + Composition = "one dominant centred object with clean silhouette, bright contrast, no readable text or logos", + Mood = "specific, useful, readable", + ObjectType = objectType, + Colour = colour, + Era = asset.Era ?? "1980s", + Palette = ["clean blue", "warm light", "natural object colour"], + MetadataTags = ["asset", "story-memory-demand"], + Metadata = DemandMetadata("Asset", asset.CanonicalIdentityKey) + }; + } + + private static Dictionary DemandMetadata(string category, string entityKey) + => new(StringComparer.OrdinalIgnoreCase) + { + ["category"] = category, + ["generationSource"] = "Story Memory demand", + ["libraryPurpose"] = "Live import demand fulfilment", + ["privacy"] = "generic-no-manuscript-data", + ["storyMemoryEntityHash"] = ShortHash(entityKey) + }; + + private static string DemandStableCode(string entityType, params string[] parts) + => $"demand-{StableKey(entityType)}-{ShortHash(string.Join("|", parts.Where(part => !string.IsNullOrWhiteSpace(part))))}"; + + private static string ShortHash(string value) + => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant()[..12]; + + private static string UnknownToDefault(string? value, string fallback) + => string.IsNullOrWhiteSpace(value) || string.Equals(value, "Unknown", StringComparison.OrdinalIgnoreCase) + ? fallback + : value.Trim(); + + private static string SplitWords(string? value) + { + var clean = UnknownToDefault(value, "Unknown"); + var builder = new StringBuilder(clean.Length + 8); + for (var i = 0; i < clean.Length; i++) + { + if (i > 0 && char.IsUpper(clean[i]) && char.IsLower(clean[i - 1])) + { + builder.Append(' '); + } + + builder.Append(clean[i]); + } + + return builder.ToString(); + } + + private sealed record DemandResolution( + int? IllustrationLibraryItemId, + string? StableCode, + string AssignmentStatus, + string AssignmentReason) + { + public static DemandResolution Recorded() + => new(null, null, "DemandRecorded", "No compatible illustration exists; durable demand recorded."); } private static string CharacterKey(string? name, IEnumerable? aliases = null) => $"character-result-{StableKey(CanonicalCharacterIdentity(name, aliases))}";