diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 34155c1..6d81e95 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -662,12 +662,12 @@ 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, string hairColour = "Unknown") +static IllustrationCharacterMetadata Metadata(string code, string ageBand, string presentation, bool isUnknownFigure = false, string hairColour = "Unknown", string skinTone = "Unknown") => new( ageBand, presentation, hairColour, - "Unknown", + skinTone, "Unknown", "Unknown", "Unknown", @@ -1044,6 +1044,10 @@ static void Phase21UExtractsMandatoryCharacterAppearanceFixtures() Assert(maggie.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.OlderTeen, $"Maggie should be OlderTeen, got {maggie.AgeBand}."); Assert(maggie.HairColour == StoryIntelligenceIllustrationCompatibility.HairColours.Red, $"Maggie should be Red, got {maggie.HairColour}."); + var maggieMemory = StoryMemoryService.ResolveCharacterEvidenceFromAttributes("Maggie", [ + new StoryMemoryCharacterAttribute { AttributeType = "HairColour", NormalisedValue = StoryIntelligenceIllustrationCompatibility.HairColours.Red, Confidence = 0.95m, IsExplicit = true } + ]); + Assert(maggieMemory.SkinTone == StoryIntelligenceIllustrationCompatibility.SkinTones.Light, $"Natural red hair should default unknown skin tone to Light, got {maggieMemory.SkinTone}."); Assert(beth.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.OlderTeen, $"Beth should be OlderTeen, got {beth.AgeBand}."); Assert(beth.HairColour == StoryIntelligenceIllustrationCompatibility.HairColours.Blonde, $"Beth should be Blonde, got {beth.HairColour}."); Assert(rosie.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.YoungAdult, $"Rosie should be YoungAdult, got {rosie.AgeBand}."); @@ -1058,8 +1062,13 @@ static void Phase21UEnforcesAliasAgeSemanticAndUiResetRules() var mum = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Mum", ["Mum is mother of teenage daughters."], []); var examiner = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("driving examiner", ["the driving examiner"], []); + var redHairedMaggie = StoryMemoryService.ResolveCharacterEvidenceFromAttributes("Maggie", [ + new StoryMemoryCharacterAttribute { AttributeType = "HairColour", NormalisedValue = StoryIntelligenceIllustrationCompatibility.HairColours.Red, Confidence = 0.95m, IsExplicit = true } + ]); var childCandidate = Metadata("char-child", "Child", "Feminine"); + var darkRedCandidate = Metadata("char-red-dark", "OlderTeen", "Feminine", hairColour: "Red", skinTone: "Dark"); Assert(Rejected(mum, childCandidate).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Parent role should reject child portraits."); + Assert(Rejected(redHairedMaggie, darkRedCandidate).Any(reason => reason.Contains("skin tone hard mismatch", StringComparison.OrdinalIgnoreCase)), "Red-haired light-skin evidence must reject dark-skin generated portraits."); Assert(examiner.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, $"Driving examiner should infer Adult, got {examiner.AgeBand}."); Assert(StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("girl", ["girl"], []).AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Unknown, "Girl alone must not mean Child."); @@ -1087,7 +1096,13 @@ static void Phase21UEnforcesAliasAgeSemanticAndUiResetRules() var scenePrompt = File.ReadAllText(Path.Combine(root, "Docs/AI/Scene-Prompt-V2.md")); Assert(onboarding.Contains("name=\"appearancePreference\"", StringComparison.Ordinal), "Import setup must ask for appearance preference."); + Assert(onboarding.Contains("name=\"primaryAgeGroup\"", StringComparison.Ordinal), "Import setup must ask for primary age group."); + Assert(onboarding.Contains("name=\"storyEra\"", StringComparison.Ordinal), "Import setup must ask for era."); + Assert(onboarding.Contains("name=\"storyLocation\"", StringComparison.Ordinal), "Import setup must ask for location."); Assert(storyMemory.Contains("CharacterAppearanceExtractionService.Extract", StringComparison.Ordinal), "Durable Story Memory must consume dedicated appearance extraction."); + Assert(storyMemory.Contains("SkinToneInstruction(skinTone)", StringComparison.Ordinal), "Demand generation must use hard skin-tone wording."); + Assert(storyMemory.Contains("HairColours.Red", StringComparison.Ordinal) && storyMemory.Contains("SkinTones.Light", StringComparison.Ordinal), "Red-hair demand evidence must force light skin when skin is otherwise unknown."); + Assert(storyMemory.Contains("ApplyPrimaryAgeContext", StringComparison.Ordinal), "Primary age-group context must guide generic adult/unknown character ages."); 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."); @@ -1101,6 +1116,7 @@ static void Phase21UEnforcesAliasAgeSemanticAndUiResetRules() Assert(resetSql.Contains("StoryIntelligence_DevelopmentAccountReset", StringComparison.Ordinal), "Phase 21U reset procedure is missing."); Assert(resetSql.Contains("ProjectHardDelete_ForOwner", StringComparison.Ordinal), "Reset must reuse owner-scoped project hard delete."); Assert(scenePrompt.Contains("characterAppearance", StringComparison.OrdinalIgnoreCase), "Scene prompt must request structured character appearance."); + Assert(File.Exists(Path.Combine(root, "Sql/144_Phase21V_StoryMemoryImportContext.sql")), "Import context SQL migration is missing."); } static void Phase21RSceneBrowserAndPanelsAreReplayFriendly() diff --git a/PlotLine/Controllers/OnboardingController.cs b/PlotLine/Controllers/OnboardingController.cs index 3617c05..2e304ec 100644 --- a/PlotLine/Controllers/OnboardingController.cs +++ b/PlotLine/Controllers/OnboardingController.cs @@ -86,9 +86,13 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard [HttpPost("story-intelligence/start")] [ValidateAntiForgeryToken] - public async Task StartStoryIntelligence([FromForm] string? appearancePreference) + public async Task StartStoryIntelligence( + [FromForm] string? appearancePreference, + [FromForm] string? primaryAgeGroup, + [FromForm] string? storyEra, + [FromForm] string? storyLocation) { - var job = await storyIntelligence.StartAsync(appearancePreference); + var job = await storyIntelligence.StartAsync(appearancePreference, primaryAgeGroup, storyEra, storyLocation); if (job is null) { TempData["OnboardingStoryIntelligenceError"] = "Review the manuscript scan before starting Story Intelligence."; diff --git a/PlotLine/Data/StoryMemoryRepository.cs b/PlotLine/Data/StoryMemoryRepository.cs index 7ea33d4..58b9124 100644 --- a/PlotLine/Data/StoryMemoryRepository.cs +++ b/PlotLine/Data/StoryMemoryRepository.cs @@ -24,7 +24,9 @@ public interface IStoryMemoryRepository Task InvalidateAssignmentAsync(int assignmentId, string reason); Task UpsertDemandAsync(StoryMemoryIllustrationDemandSave request); Task GetAppearancePreferenceAsync(int importSessionId); + Task GetImportContextAsync(int importSessionId); Task SetAppearancePreferenceAsync(int importSessionId, string preference); + Task SetImportContextAsync(int importSessionId, string appearancePreference, string? primaryAgeGroup, string? storyEra, string? storyLocation); Task GetDiagnosticsAsync(int importSessionId); } @@ -362,29 +364,75 @@ public sealed class StoryMemoryRepository(ISqlConnectionFactory connectionFactor } public async Task GetAppearancePreferenceAsync(int importSessionId) + => (await GetImportContextAsync(importSessionId)).AppearancePreference; + + public async Task GetImportContextAsync(int importSessionId) { using var connection = connectionFactory.CreateConnection(); - return await connection.QuerySingleOrDefaultAsync( - "SELECT AppearancePreference FROM dbo.StoryMemoryImportPreferences WHERE ImportSessionID = @ImportSessionID;", + await EnsureImportPreferenceContextColumnsAsync(connection); + return await connection.QuerySingleOrDefaultAsync( + """ + SELECT + AppearancePreference, + COALESCE(PrimaryAgeGroup, N'') AS PrimaryAgeGroup, + COALESCE(StoryEra, N'') AS StoryEra, + COALESCE(StoryLocation, N'') AS StoryLocation + FROM dbo.StoryMemoryImportPreferences + WHERE ImportSessionID = @ImportSessionID; + """, new { ImportSessionID = importSessionId }) - ?? StoryMemoryAppearancePreferences.Balanced; + ?? new StoryMemoryImportContext(StoryMemoryAppearancePreferences.Balanced, string.Empty, string.Empty, string.Empty); } public async Task SetAppearancePreferenceAsync(int importSessionId, string preference) + => await SetImportContextAsync(importSessionId, preference, null, null, null); + + public async Task SetImportContextAsync(int importSessionId, string appearancePreference, string? primaryAgeGroup, string? storyEra, string? storyLocation) { using var connection = connectionFactory.CreateConnection(); + await EnsureImportPreferenceContextColumnsAsync(connection); await connection.ExecuteAsync( """ MERGE dbo.StoryMemoryImportPreferences WITH (HOLDLOCK) AS target USING (SELECT @ImportSessionID AS ImportSessionID) AS source ON target.ImportSessionID = source.ImportSessionID WHEN MATCHED THEN - UPDATE SET AppearancePreference = @AppearancePreference, UpdatedUtc = SYSUTCDATETIME() + UPDATE SET AppearancePreference = @AppearancePreference, + PrimaryAgeGroup = COALESCE(@PrimaryAgeGroup, PrimaryAgeGroup), + StoryEra = COALESCE(@StoryEra, StoryEra), + StoryLocation = COALESCE(@StoryLocation, StoryLocation), + UpdatedUtc = SYSUTCDATETIME() WHEN NOT MATCHED THEN - INSERT (ImportSessionID, AppearancePreference) - VALUES (@ImportSessionID, @AppearancePreference); + INSERT (ImportSessionID, AppearancePreference, PrimaryAgeGroup, StoryEra, StoryLocation) + VALUES (@ImportSessionID, @AppearancePreference, COALESCE(@PrimaryAgeGroup, N''), COALESCE(@StoryEra, N''), COALESCE(@StoryLocation, N'')); """, - new { ImportSessionID = importSessionId, AppearancePreference = preference }); + new + { + ImportSessionID = importSessionId, + AppearancePreference = appearancePreference, + PrimaryAgeGroup = TrimContext(primaryAgeGroup, 120), + StoryEra = TrimContext(storyEra, 120), + StoryLocation = TrimContext(storyLocation, 160) + }); + } + + private static async Task EnsureImportPreferenceContextColumnsAsync(System.Data.IDbConnection connection) + { + await connection.ExecuteAsync( + """ + IF COL_LENGTH(N'dbo.StoryMemoryImportPreferences', N'PrimaryAgeGroup') IS NULL + ALTER TABLE dbo.StoryMemoryImportPreferences ADD PrimaryAgeGroup nvarchar(120) NULL; + IF COL_LENGTH(N'dbo.StoryMemoryImportPreferences', N'StoryEra') IS NULL + ALTER TABLE dbo.StoryMemoryImportPreferences ADD StoryEra nvarchar(120) NULL; + IF COL_LENGTH(N'dbo.StoryMemoryImportPreferences', N'StoryLocation') IS NULL + ALTER TABLE dbo.StoryMemoryImportPreferences ADD StoryLocation nvarchar(160) NULL; + """); + } + + private static string? TrimContext(string? value, int maxLength) + { + var clean = value?.Trim(); + return string.IsNullOrWhiteSpace(clean) ? null : clean[..Math.Min(clean.Length, maxLength)]; } public async Task GetDiagnosticsAsync(int importSessionId) diff --git a/PlotLine/Models/StoryMemoryModels.cs b/PlotLine/Models/StoryMemoryModels.cs index 3d977b1..ba1e92a 100644 --- a/PlotLine/Models/StoryMemoryModels.cs +++ b/PlotLine/Models/StoryMemoryModels.cs @@ -214,18 +214,30 @@ public sealed class StoryMemoryRebuildResult public IReadOnlyList Warnings { get; init; } = []; } -public sealed class StoryMemoryRebuildOptions +public sealed record StoryMemoryRebuildOptions { public bool DryRun { get; init; } public bool RebuildAll { get; init; } public bool QueueDemand { get; init; } public int? MaxDemandToQueue { get; init; } public string AppearancePreference { get; init; } = StoryMemoryAppearancePreferences.Balanced; + public string PrimaryAgeGroup { get; init; } = string.Empty; + public string StoryEra { get; init; } = string.Empty; + public string StoryLocation { get; init; } = string.Empty; } -public sealed class StoryMemoryResolveOptions +public sealed record StoryMemoryResolveOptions { public bool QueueDemand { get; init; } public int? MaxDemandToQueue { get; init; } public string AppearancePreference { get; init; } = StoryMemoryAppearancePreferences.Balanced; + public string PrimaryAgeGroup { get; init; } = string.Empty; + public string StoryEra { get; init; } = string.Empty; + public string StoryLocation { get; init; } = string.Empty; } + +public sealed record StoryMemoryImportContext( + string AppearancePreference, + string PrimaryAgeGroup, + string StoryEra, + string StoryLocation); diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index ba1f5f6..630469c 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -247,7 +247,10 @@ public class Program builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); - builder.Services.AddHostedService(); + if (builder.Configuration.GetValue("Illustrations:GenerationWorkerEnabled", true)) + { + builder.Services.AddHostedService(); + } var app = builder.Build(); diff --git a/PlotLine/Services/IllustrationLibraryServices.cs b/PlotLine/Services/IllustrationLibraryServices.cs index c0cbf5a..6d9c8eb 100644 --- a/PlotLine/Services/IllustrationLibraryServices.cs +++ b/PlotLine/Services/IllustrationLibraryServices.cs @@ -81,6 +81,7 @@ public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder - subtle depth of field, clean modern rendering, and enough exposure to remain clear inside PlotDirector's dark navy interface - no visible text, captions, logos, watermarks, UI, signatures, or typography - no anime, cartoon, Pixar-like, caricature, celebrity likeness, or photoreal identity claim + - for character images, the specified hair colour, hair length, apparent age, era, location context and skin tone are hard requirements; do not reinterpret or diversify away from them - no bright flat studio backdrop, no plain colour-block background, no stock-photo lighting, and no cartoon simplicity - avoid private manuscript details, real names, excerpts, or recognisable copyrighted characters - avoid muddy olive and brown grading, heavy sepia tones, underexposure, excessive darkness, low-contrast lighting, flat colour, antique oil-painting texture, distressed canvas texture, rough brushwork, overly sombre default expressions, backgrounds that merge into the subject, and monochrome or near-monochrome results @@ -115,7 +116,7 @@ public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder $"apparent age band {specification.ApparentAgeBand}", $"presentation {specification.Presentation}", $"hair {specification.HairLength} {specification.HairColour}", - $"skin tone range {specification.SkinTone}", + $"required skin tone {specification.SkinTone}", $"features {string.Join(", ", specification.Features)}", $"clothing {specification.ClothingEra} {specification.ClothingStyle}" }.Where(item => !item.EndsWith(" ", StringComparison.Ordinal))), diff --git a/PlotLine/Services/OnboardingStoryIntelligenceService.cs b/PlotLine/Services/OnboardingStoryIntelligenceService.cs index 0bcc99d..53b7566 100644 --- a/PlotLine/Services/OnboardingStoryIntelligenceService.cs +++ b/PlotLine/Services/OnboardingStoryIntelligenceService.cs @@ -9,7 +9,7 @@ namespace PlotLine.Services; public interface IOnboardingStoryIntelligenceService { Task GetOverviewAsync(); - Task StartAsync(string? appearancePreference = null); + Task StartAsync(string? appearancePreference = null, string? primaryAgeGroup = null, string? storyEra = null, string? storyLocation = null); Task GetProgressAsync(Guid batchId); Task CancelAsync(Guid batchId); Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAsync(Guid batchId, int runId); @@ -40,6 +40,7 @@ public sealed class OnboardingStoryIntelligenceService( IStoryIntelligenceRelationshipImportService relationshipImport, IStoryIntelligenceKnowledgeImportService knowledgeImport, IStoryIntelligencePipelineStateService pipelineState, + IStoryMemoryRepository storyMemory, IStoryIntelligenceClient client, IOnboardingStoryIntelligenceBatchStore batchStore, IStoryIntelligenceProgressNotifier notifier, @@ -83,7 +84,7 @@ public sealed class OnboardingStoryIntelligenceService( }; } - public async Task StartAsync(string? appearancePreference = null) + public async Task StartAsync(string? appearancePreference = null, string? primaryAgeGroup = null, string? storyEra = null, string? storyLocation = null) { var userId = RequireUserId(); var context = await GetContextAsync(userId); @@ -161,6 +162,7 @@ public sealed class OnboardingStoryIntelligenceService( ChapterID = chapterId, RunID = runId }); + await pipelineState.RecordChapterAnalysisQueuedAsync(preview.ProjectID, preview.BookID, runId); await notifier.PublishAsync(new StoryIntelligenceRunProgressEvent { @@ -190,9 +192,22 @@ public sealed class OnboardingStoryIntelligenceService( ProjectName = wizard.SelectedProjectName, BookTitle = wizard.SelectedBookTitle, AppearancePreference = NormaliseAppearancePreference(appearancePreference), + PrimaryAgeGroup = CleanContext(primaryAgeGroup), + StoryEra = CleanContext(storyEra), + StoryLocation = CleanContext(storyLocation), Items = batchItems }; + if (await pipelineState.GetForBookAsync(preview.BookID, userId) is { } pipeline) + { + await storyMemory.SetImportContextAsync( + pipeline.StoryIntelligenceBookPipelineID, + batch.AppearancePreference, + batch.PrimaryAgeGroup, + batch.StoryEra, + batch.StoryLocation); + } + await batchStore.SaveAsync(batch); logger.LogInformation( "Queued onboarding Story Intelligence batch {BatchID}. UserID={UserID} ProjectID={ProjectID} BookID={BookID} Chapters={ChapterCount}", @@ -217,6 +232,12 @@ public sealed class OnboardingStoryIntelligenceService( _ => StoryMemoryAppearancePreferences.Balanced }; + private static string CleanContext(string? value) + { + var clean = value?.Trim(); + return string.IsNullOrWhiteSpace(clean) ? string.Empty : clean[..Math.Min(clean.Length, 160)]; + } + public async Task GetProgressAsync(Guid batchId) { var batch = await batchStore.GetAsync(RequireUserId(), batchId); @@ -933,6 +954,9 @@ public sealed class OnboardingStoryIntelligenceBatch public string? ProjectName { get; init; } public string? BookTitle { get; init; } public string AppearancePreference { get; init; } = StoryMemoryAppearancePreferences.Balanced; + public string PrimaryAgeGroup { get; init; } = string.Empty; + public string StoryEra { get; init; } = string.Empty; + public string StoryLocation { get; init; } = string.Empty; public DateTime CreatedUtc { get; init; } = DateTime.UtcNow; public IReadOnlyList Items { get; init; } = []; public List CharacterDecisions { get; } = []; diff --git a/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs b/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs index 51a14e6..f83d699 100644 --- a/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs +++ b/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs @@ -191,6 +191,13 @@ public static class StoryIntelligenceIllustrationCompatibility rejected.Add($"hair colour hard mismatch: wanted {evidence.HairColour}, candidate {candidate.HairColour}"); } + if (!string.Equals(evidence.SkinTone, SkinTones.Unknown, StringComparison.OrdinalIgnoreCase) + && !string.Equals(candidate.SkinTone, SkinTones.Unknown, StringComparison.OrdinalIgnoreCase) + && KnownMismatch(evidence.SkinTone, candidate.SkinTone)) + { + rejected.Add($"skin tone hard mismatch: wanted {evidence.SkinTone}, candidate {candidate.SkinTone}"); + } + if (alreadyAssignedToAnotherSignificantCharacter && isNamedCharacter) { rejected.Add("already assigned to another significant named character"); diff --git a/PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs b/PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs index 33b3b48..2574b86 100644 --- a/PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs +++ b/PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs @@ -591,7 +591,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService( Code = $"char-demand-{StableHash(demand.DemandKey)}", Title = $"{age} {presentation.ToLowerInvariant()} character", Description = "Demand-created reusable character illustration for Story Intelligence matching.", - VisualSubject = $"{age} {presentation.ToLowerInvariant()} character with {hairLength.ToLowerInvariant()} {hairColour.ToLowerInvariant()} hair and {skinTone.ToLowerInvariant()} skin tone range.", + VisualSubject = $"{age} {presentation.ToLowerInvariant()} character with {hairLength.ToLowerInvariant()} {hairColour.ToLowerInvariant()} hair. Skin tone requirement: {SkinToneInstruction(skinTone)}.", Composition = "clear head-and-shoulders cinematic portrait with readable face, subtle atmospheric story background, clean separation, suitable for circular cropping", Mood = "neutral, story-ready, emotionally readable, subdued and cinematic", ApparentAgeBand = age, @@ -688,6 +688,15 @@ public sealed class StoryIntelligenceIllustrationMatchingService( }; } + private static string SkinToneInstruction(string skinTone) + => skinTone switch + { + StoryIntelligenceIllustrationCompatibility.SkinTones.Light => "must have light/fair skin; do not depict dark or black skin unless explicit manuscript evidence says so", + StoryIntelligenceIllustrationCompatibility.SkinTones.Medium => "must have medium skin tone; do not drift to dark or very pale skin", + StoryIntelligenceIllustrationCompatibility.SkinTones.Dark => "must have dark skin tone", + _ => "not specified" + }; + private static Dictionary BuildCharacterObservations(StoryIntelligenceExperiencePrototypeViewModel prototype) { var observations = new Dictionary(StringComparer.OrdinalIgnoreCase); diff --git a/PlotLine/Services/StoryMemoryServices.cs b/PlotLine/Services/StoryMemoryServices.cs index c044626..38eaff2 100644 --- a/PlotLine/Services/StoryMemoryServices.cs +++ b/PlotLine/Services/StoryMemoryServices.cs @@ -60,7 +60,7 @@ public sealed class StoryMemoryService( } var identityAliases = BuildCrossSceneIdentityAliases(completedScenes); - await memory.SetAppearancePreferenceAsync(importSessionId, options.AppearancePreference); + await memory.SetImportContextAsync(importSessionId, options.AppearancePreference, options.PrimaryAgeGroup, options.StoryEra, options.StoryLocation); var processed = await memory.ListProcessedSceneResultIdsAsync(importSessionId, ProcessorVersion); var processedCount = 0; foreach (var scene in completedScenes) @@ -80,7 +80,10 @@ public sealed class StoryMemoryService( { QueueDemand = options.QueueDemand, MaxDemandToQueue = options.MaxDemandToQueue, - AppearancePreference = options.AppearancePreference + AppearancePreference = options.AppearancePreference, + PrimaryAgeGroup = options.PrimaryAgeGroup, + StoryEra = options.StoryEra, + StoryLocation = options.StoryLocation }, cancellationToken); var diagnostics = await memory.GetDiagnosticsAsync(importSessionId); @@ -118,11 +121,27 @@ public sealed class StoryMemoryService( await UpsertSceneMemoryAsync(session, scene.Result, scene.Scene, new Dictionary(StringComparer.OrdinalIgnoreCase), cancellationToken); await memory.MarkSceneResultProcessedAsync(importSessionId, sceneResultId, ProcessorVersion, StableContentHash(scene.Result.ParsedJson)); - await ResolveIllustrationsAsync(importSessionId, options, cancellationToken); + var context = await memory.GetImportContextAsync(importSessionId); + await ResolveIllustrationsAsync(importSessionId, options with + { + AppearancePreference = string.IsNullOrWhiteSpace(options.AppearancePreference) ? context.AppearancePreference : options.AppearancePreference, + PrimaryAgeGroup = string.IsNullOrWhiteSpace(options.PrimaryAgeGroup) ? context.PrimaryAgeGroup : options.PrimaryAgeGroup, + StoryEra = string.IsNullOrWhiteSpace(options.StoryEra) ? context.StoryEra : options.StoryEra, + StoryLocation = string.IsNullOrWhiteSpace(options.StoryLocation) ? context.StoryLocation : options.StoryLocation + }, cancellationToken); } public async Task ResolveIllustrationsAsync(int importSessionId, StoryMemoryResolveOptions options, CancellationToken cancellationToken = default) { + var context = await memory.GetImportContextAsync(importSessionId); + options = options with + { + AppearancePreference = context.AppearancePreference, + PrimaryAgeGroup = FirstConfigured(context.PrimaryAgeGroup, options.PrimaryAgeGroup), + StoryEra = FirstConfigured(context.StoryEra, options.StoryEra), + StoryLocation = FirstConfigured(context.StoryLocation, options.StoryLocation) + }; + var characters = await memory.ListCharactersAsync(importSessionId); var attributes = await memory.ListCharacterAttributesAsync(importSessionId); var locations = await memory.ListLocationsAsync(importSessionId); @@ -145,6 +164,7 @@ public sealed class StoryMemoryService( CharacterEvidence(character, attributes.Where(item => item.StoryMemoryCharacterID == character.StoryMemoryCharacterID)), character.CanonicalIdentityKey, options.AppearancePreference); + evidence = ApplyPrimaryAgeContext(evidence, options.PrimaryAgeGroup); var existing = assignments.FirstOrDefault(item => item.EntityType == StoryMemoryEntityTypes.Character && item.EntityID == character.StoryMemoryCharacterID); var existingItem = existing?.IllustrationLibraryItemID is int existingId ? catalogue.FirstOrDefault(item => item.IllustrationLibraryItemID == existingId) @@ -181,7 +201,7 @@ public sealed class StoryMemoryService( continue; } - 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); + 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, options), cancellationToken); await memory.UpsertAssignmentAsync(new StoryMemoryIllustrationAssignmentSave(importSessionId, StoryMemoryEntityTypes.Character, character.StoryMemoryCharacterID, demand.IllustrationLibraryItemId, demand.StableCode, demand.AssignmentStatus, true, 0, 0, demand.AssignmentReason, ProcessorVersion)); } @@ -202,7 +222,7 @@ public sealed class StoryMemoryService( } else { - var demand = await RecordDemandAsync(importSessionId, location.ProjectID, StoryMemoryEntityTypes.Location, DemandKey(location.CanonicalIdentityKey, requestedType), options, LocationDemandSpecification(location, requestedType), cancellationToken); + var demand = await RecordDemandAsync(importSessionId, location.ProjectID, StoryMemoryEntityTypes.Location, DemandKey(location.CanonicalIdentityKey, requestedType), options, LocationDemandSpecification(location, requestedType, options), cancellationToken); await memory.UpsertAssignmentAsync(new StoryMemoryIllustrationAssignmentSave(importSessionId, StoryMemoryEntityTypes.Location, location.StoryMemoryLocationID, demand.IllustrationLibraryItemId, demand.StableCode, demand.AssignmentStatus, true, 0, 0, demand.AssignmentReason, ProcessorVersion)); } } @@ -227,7 +247,7 @@ public sealed class StoryMemoryService( } else { - var demand = await RecordDemandAsync(importSessionId, asset.ProjectID, StoryMemoryEntityTypes.Asset, DemandKey(asset.CanonicalIdentityKey, requestedType, requestedColour), options, AssetDemandSpecification(asset, requestedType, requestedColour), cancellationToken); + var demand = await RecordDemandAsync(importSessionId, asset.ProjectID, StoryMemoryEntityTypes.Asset, DemandKey(asset.CanonicalIdentityKey, requestedType, requestedColour), options, AssetDemandSpecification(asset, requestedType, requestedColour, options), cancellationToken); await memory.UpsertAssignmentAsync(new StoryMemoryIllustrationAssignmentSave(importSessionId, StoryMemoryEntityTypes.Asset, asset.StoryMemoryAssetID, demand.IllustrationLibraryItemId, demand.StableCode, demand.AssignmentStatus, true, 0, 0, demand.AssignmentReason, ProcessorVersion)); } } @@ -499,6 +519,12 @@ public sealed class StoryMemoryService( private static CharacterEvidenceProfile ApplyAppearancePreference(CharacterEvidenceProfile evidence, string identityKey, string preference) { + if (string.Equals(evidence.HairColour, StoryIntelligenceIllustrationCompatibility.HairColours.Red, StringComparison.OrdinalIgnoreCase) + && string.Equals(evidence.SkinTone, StoryIntelligenceIllustrationCompatibility.SkinTones.Unknown, StringComparison.OrdinalIgnoreCase)) + { + return evidence with { SkinTone = StoryIntelligenceIllustrationCompatibility.SkinTones.Light }; + } + if (!string.Equals(evidence.SkinTone, StoryIntelligenceIllustrationCompatibility.SkinTones.Unknown, StringComparison.OrdinalIgnoreCase)) { return evidence; @@ -519,6 +545,20 @@ public sealed class StoryMemoryService( : evidence with { SkinTone = skinTone }; } + private static CharacterEvidenceProfile ApplyPrimaryAgeContext(CharacterEvidenceProfile evidence, string primaryAgeGroup) + { + var contextAge = StoryIntelligenceIllustrationCompatibility.NormalizeAgeBand(primaryAgeGroup); + if (contextAge == StoryIntelligenceIllustrationCompatibility.AgeBands.Unknown) + { + return evidence; + } + + return evidence.AgeBand is StoryIntelligenceIllustrationCompatibility.AgeBands.Unknown + or StoryIntelligenceIllustrationCompatibility.AgeBands.Adult + ? evidence with { AgeBand = contextAge, AgeConfidence = Math.Max(evidence.AgeConfidence, 0.74m) } + : evidence; + } + private static string BalancedSkinTone(string key) { var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(key)); @@ -545,6 +585,11 @@ public sealed class StoryMemoryService( var hairColour = PickAttribute(attributeList, "HairColour", inferred.HairColour, 0.1m); var skinTone = PickAttribute(attributeList, "SkinTone", inferred.SkinTone, 0.1m); var hairLength = PickAttribute(attributeList, "HairLength", inferred.HairLength, 0.1m); + if (string.Equals(hairColour.Value, StoryIntelligenceIllustrationCompatibility.HairColours.Red, StringComparison.OrdinalIgnoreCase) + && string.Equals(skinTone.Value, StoryIntelligenceIllustrationCompatibility.SkinTones.Unknown, StringComparison.OrdinalIgnoreCase)) + { + skinTone = (StoryIntelligenceIllustrationCompatibility.SkinTones.Light, Math.Max(skinTone.Confidence, 0.82m)); + } return new( age.Value, @@ -760,12 +805,26 @@ public sealed class StoryMemoryService( 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}."); + var existingItem = (await illustrationLibrary.ListAsync(new IllustrationLibraryFilter())) + .FirstOrDefault(item => item.IllustrationLibraryItemID == existingId); + if (existingItem is not null + && existingItem.IsActive + && string.Equals(existingItem.StableCode, specification.Code, StringComparison.OrdinalIgnoreCase) + && existingItem.Status is IllustrationLibraryStatuses.Planned + or IllustrationLibraryStatuses.Queued + or IllustrationLibraryStatuses.Generating + or IllustrationLibraryStatuses.Generated + or IllustrationLibraryStatuses.Approved) + { + 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 categoryCap = specification.Category == IllustrationLibraryCategories.Character + ? maxDemandToQueue + : Math.Max(1, maxDemandToQueue / 3); var queuedDemandItems = (await illustrationLibrary.ListAsync(new IllustrationLibraryFilter())) .Count(item => string.Equals(item.Category, specification.Category, StringComparison.OrdinalIgnoreCase) && item.IsActive @@ -796,21 +855,25 @@ public sealed class StoryMemoryService( 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) + private static IllustrationGenerationSpecification CharacterDemandSpecification(StoryMemoryCharacter character, CharacterEvidenceProfile evidence, StoryMemoryResolveOptions options) { var code = DemandStableCode(StoryMemoryEntityTypes.Character, character.CanonicalIdentityKey, evidence.AgeBand, evidence.Presentation, evidence.HairColour, evidence.HairLength, evidence.SkinTone); - var age = UnknownToDefault(evidence.AgeBand, "Adult"); + var age = UnknownToDefault(evidence.AgeBand, NormaliseAgeContext(options.PrimaryAgeGroup)); var presentation = UnknownToDefault(evidence.Presentation, "Androgynous"); var hairColour = UnknownToDefault(evidence.HairColour, "Brown"); var hairLength = UnknownToDefault(evidence.HairLength, "Medium"); var skinTone = UnknownToDefault(evidence.SkinTone, "Medium"); + var era = UnknownToDefault(options.StoryEra, "1980s"); + var location = UnknownToDefault(options.StoryLocation, "UK"); + var skinInstruction = SkinToneInstruction(skinTone); + var agePrompt = AgePromptText(age); 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.", + VisualSubject = $"A {agePrompt} {presentation.ToLowerInvariant()} person from {era} {location} with {hairLength.ToLowerInvariant()} {hairColour.ToLowerInvariant()} hair. Skin tone requirement: {skinInstruction}.", Composition = "single head-and-shoulders portrait with clear facial lighting, neutral story background, and strong thumbnail readability", Mood = "present, natural, story-aware", ApparentAgeBand = age, @@ -818,15 +881,15 @@ public sealed class StoryMemoryService( HairColour = hairColour, HairLength = hairLength, SkinTone = skinTone, - ClothingEra = "1980s", - ClothingStyle = "everyday naturalistic", + ClothingEra = era, + ClothingStyle = $"{era} {location} everyday naturalistic", Palette = ["clear blue", "warm amber", "natural skin tones"], MetadataTags = ["character", "story-memory-demand"], - Metadata = DemandMetadata("Character", character.CanonicalIdentityKey) + Metadata = DemandMetadata("Character", character.CanonicalIdentityKey, options) }; } - private static IllustrationGenerationSpecification LocationDemandSpecification(StoryMemoryLocation location, string requestedType) + private static IllustrationGenerationSpecification LocationDemandSpecification(StoryMemoryLocation location, string requestedType, StoryMemoryResolveOptions options) { var code = DemandStableCode(StoryMemoryEntityTypes.Location, location.CanonicalIdentityKey, requestedType); var locationType = UnknownToDefault(requestedType, "UnknownInterior"); @@ -844,11 +907,11 @@ public sealed class StoryMemoryService( Setting = location.InteriorExterior ?? "Story setting", Palette = ["clear navy", "warm light", "balanced natural colour"], MetadataTags = ["location", "story-memory-demand"], - Metadata = DemandMetadata("Location", location.CanonicalIdentityKey) + Metadata = DemandMetadata("Location", location.CanonicalIdentityKey, options) }; } - private static IllustrationGenerationSpecification AssetDemandSpecification(StoryMemoryAsset asset, string requestedType, string requestedColour) + private static IllustrationGenerationSpecification AssetDemandSpecification(StoryMemoryAsset asset, string requestedType, string requestedColour, StoryMemoryResolveOptions options) { var code = DemandStableCode(StoryMemoryEntityTypes.Asset, asset.CanonicalIdentityKey, requestedType, requestedColour); var objectType = UnknownToDefault(requestedType, "UnknownObject"); @@ -867,12 +930,13 @@ public sealed class StoryMemoryService( Era = asset.Era ?? "1980s", Palette = ["clean blue", "warm light", "natural object colour"], MetadataTags = ["asset", "story-memory-demand"], - Metadata = DemandMetadata("Asset", asset.CanonicalIdentityKey) + Metadata = DemandMetadata("Asset", asset.CanonicalIdentityKey, options) }; } - private static Dictionary DemandMetadata(string category, string entityKey) - => new(StringComparer.OrdinalIgnoreCase) + private static Dictionary DemandMetadata(string category, string entityKey, StoryMemoryResolveOptions options) + { + var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["category"] = category, ["generationSource"] = "Story Memory demand", @@ -880,6 +944,19 @@ public sealed class StoryMemoryService( ["privacy"] = "generic-no-manuscript-data", ["storyMemoryEntityHash"] = ShortHash(entityKey) }; + Add("primaryAgeGroup", options.PrimaryAgeGroup); + Add("storyEra", options.StoryEra); + Add("storyLocation", options.StoryLocation); + return metadata; + + void Add(string key, string value) + { + if (!string.IsNullOrWhiteSpace(value)) + { + metadata[key] = value.Trim(); + } + } + } private static string DemandStableCode(string entityType, params string[] parts) => $"demand-{StableKey(entityType)}-{ShortHash(string.Join("|", parts.Where(part => !string.IsNullOrWhiteSpace(part))))}"; @@ -892,6 +969,34 @@ public sealed class StoryMemoryService( ? fallback : value.Trim(); + private static string FirstConfigured(params string?[] values) + => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty; + + private static string NormaliseAgeContext(string? value) + { + var normalised = StoryIntelligenceIllustrationCompatibility.NormalizeAgeBand(value); + return normalised == StoryIntelligenceIllustrationCompatibility.AgeBands.Unknown ? "Adult" : normalised; + } + + private static string AgePromptText(string age) + => age switch + { + StoryIntelligenceIllustrationCompatibility.AgeBands.OlderTeen => "late-teen", + StoryIntelligenceIllustrationCompatibility.AgeBands.YoungAdult => "early-twenties", + StoryIntelligenceIllustrationCompatibility.AgeBands.YoungTeen => "young-teen", + StoryIntelligenceIllustrationCompatibility.AgeBands.MatureAdult => "middle-aged adult", + _ => age.ToLowerInvariant() + }; + + private static string SkinToneInstruction(string skinTone) + => skinTone switch + { + StoryIntelligenceIllustrationCompatibility.SkinTones.Light => "must have light/fair skin; do not depict dark or black skin unless explicit manuscript evidence says so", + StoryIntelligenceIllustrationCompatibility.SkinTones.Medium => "must have medium skin tone; do not drift to dark or very pale skin", + StoryIntelligenceIllustrationCompatibility.SkinTones.Dark => "must have dark skin tone", + _ => "not specified" + }; + private static string SplitWords(string? value) { var clean = UnknownToDefault(value, "Unknown"); diff --git a/PlotLine/Sql/144_Phase21V_StoryMemoryImportContext.sql b/PlotLine/Sql/144_Phase21V_StoryMemoryImportContext.sql new file mode 100644 index 0000000..d107574 --- /dev/null +++ b/PlotLine/Sql/144_Phase21V_StoryMemoryImportContext.sql @@ -0,0 +1,11 @@ +IF COL_LENGTH(N'dbo.StoryMemoryImportPreferences', N'PrimaryAgeGroup') IS NULL + ALTER TABLE dbo.StoryMemoryImportPreferences ADD PrimaryAgeGroup nvarchar(120) NULL; +GO + +IF COL_LENGTH(N'dbo.StoryMemoryImportPreferences', N'StoryEra') IS NULL + ALTER TABLE dbo.StoryMemoryImportPreferences ADD StoryEra nvarchar(120) NULL; +GO + +IF COL_LENGTH(N'dbo.StoryMemoryImportPreferences', N'StoryLocation') IS NULL + ALTER TABLE dbo.StoryMemoryImportPreferences ADD StoryLocation nvarchar(160) NULL; +GO diff --git a/PlotLine/Views/Onboarding/StoryIntelligence.cshtml b/PlotLine/Views/Onboarding/StoryIntelligence.cshtml index ee27c38..e587a1f 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligence.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligence.cshtml @@ -91,6 +91,22 @@ +
+ Story context for generated portraits +

These guide generated images when the manuscript is silent. Explicit manuscript evidence still wins.

+ + + +