Strengthen story intelligence portrait context

This commit is contained in:
Nick Beckley 2026-07-25 20:40:07 +00:00
parent 61b4d9d288
commit 02bc4fd2de
12 changed files with 294 additions and 38 deletions

View File

@ -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()

View File

@ -86,9 +86,13 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
[HttpPost("story-intelligence/start")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> StartStoryIntelligence([FromForm] string? appearancePreference)
public async Task<IActionResult> 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.";

View File

@ -24,7 +24,9 @@ public interface IStoryMemoryRepository
Task InvalidateAssignmentAsync(int assignmentId, string reason);
Task<StoryMemoryIllustrationDemand> UpsertDemandAsync(StoryMemoryIllustrationDemandSave request);
Task<string> GetAppearancePreferenceAsync(int importSessionId);
Task<StoryMemoryImportContext> GetImportContextAsync(int importSessionId);
Task SetAppearancePreferenceAsync(int importSessionId, string preference);
Task SetImportContextAsync(int importSessionId, string appearancePreference, string? primaryAgeGroup, string? storyEra, string? storyLocation);
Task<StoryMemoryDiagnostics> GetDiagnosticsAsync(int importSessionId);
}
@ -362,29 +364,75 @@ public sealed class StoryMemoryRepository(ISqlConnectionFactory connectionFactor
}
public async Task<string> GetAppearancePreferenceAsync(int importSessionId)
=> (await GetImportContextAsync(importSessionId)).AppearancePreference;
public async Task<StoryMemoryImportContext> GetImportContextAsync(int importSessionId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<string>(
"SELECT AppearancePreference FROM dbo.StoryMemoryImportPreferences WHERE ImportSessionID = @ImportSessionID;",
await EnsureImportPreferenceContextColumnsAsync(connection);
return await connection.QuerySingleOrDefaultAsync<StoryMemoryImportContext>(
"""
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<StoryMemoryDiagnostics> GetDiagnosticsAsync(int importSessionId)

View File

@ -214,18 +214,30 @@ public sealed class StoryMemoryRebuildResult
public IReadOnlyList<string> 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);

View File

@ -247,7 +247,10 @@ public class Program
builder.Services.AddHostedService<EmailQueueWorker>();
builder.Services.AddHostedService<StoryIntelligenceWorker>();
builder.Services.AddHostedService<PersistedStoryIntelligenceWorker>();
if (builder.Configuration.GetValue("Illustrations:GenerationWorkerEnabled", true))
{
builder.Services.AddHostedService<IllustrationGenerationWorker>();
}
var app = builder.Build();

View File

@ -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))),

View File

@ -9,7 +9,7 @@ namespace PlotLine.Services;
public interface IOnboardingStoryIntelligenceService
{
Task<StoryIntelligenceOverviewViewModel> GetOverviewAsync();
Task<OnboardingStoryIntelligenceBatch?> StartAsync(string? appearancePreference = null);
Task<OnboardingStoryIntelligenceBatch?> StartAsync(string? appearancePreference = null, string? primaryAgeGroup = null, string? storyEra = null, string? storyLocation = null);
Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid batchId);
Task<StoryIntelligenceProgressViewModel?> 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<OnboardingStoryIntelligenceBatch?> StartAsync(string? appearancePreference = null)
public async Task<OnboardingStoryIntelligenceBatch?> 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<StoryIntelligenceProgressViewModel?> 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<OnboardingStoryIntelligenceBatchItem> Items { get; init; } = [];
public List<OnboardingStoryIntelligenceCharacterDecision> CharacterDecisions { get; } = [];

View File

@ -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");

View File

@ -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<string, CharacterObservation> BuildCharacterObservations(StoryIntelligenceExperiencePrototypeViewModel prototype)
{
var observations = new Dictionary<string, CharacterObservation>(StringComparer.OrdinalIgnoreCase);

View File

@ -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<string, string>(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;
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<string, string> DemandMetadata(string category, string entityKey)
=> new(StringComparer.OrdinalIgnoreCase)
private static Dictionary<string, string> DemandMetadata(string category, string entityKey, StoryMemoryResolveOptions options)
{
var metadata = new Dictionary<string, string>(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");

View File

@ -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

View File

@ -91,6 +91,22 @@
<label><input type="radio" name="appearancePreference" value="@StoryMemoryAppearancePreferences.PredominantlyDark" /> Predominantly dark-skinned cast</label>
<label><input type="radio" name="appearancePreference" value="@StoryMemoryAppearancePreferences.CustomMix" /> Custom mix</label>
</fieldset>
<fieldset>
<legend>Story context for generated portraits</legend>
<p>These guide generated images when the manuscript is silent. Explicit manuscript evidence still wins.</p>
<label>
Primary character age group
<input class="form-control" type="text" name="primaryAgeGroup" placeholder="Late teen / early 20s" />
</label>
<label>
Era
<input class="form-control" type="text" name="storyEra" placeholder="1980s" />
</label>
<label>
Location
<input class="form-control" type="text" name="storyLocation" placeholder="UK" />
</label>
</fieldset>
<button class="btn btn-primary" type="submit" disabled="@(!Model.CanStart)">Analyse manuscript</button>
</form>
</div>