Strengthen story intelligence portrait context
This commit is contained in:
parent
61b4d9d288
commit
02bc4fd2de
@ -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.");
|
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(
|
=> new(
|
||||||
ageBand,
|
ageBand,
|
||||||
presentation,
|
presentation,
|
||||||
hairColour,
|
hairColour,
|
||||||
"Unknown",
|
skinTone,
|
||||||
"Unknown",
|
"Unknown",
|
||||||
"Unknown",
|
"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.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.OlderTeen, $"Maggie should be OlderTeen, got {maggie.AgeBand}.");
|
||||||
Assert(maggie.HairColour == StoryIntelligenceIllustrationCompatibility.HairColours.Red, $"Maggie should be Red, got {maggie.HairColour}.");
|
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.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(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}.");
|
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 mum = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Mum", ["Mum is mother of teenage daughters."], []);
|
||||||
var examiner = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("driving examiner", ["the driving examiner"], []);
|
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 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(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(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.");
|
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"));
|
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=\"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("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("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(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("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("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(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(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()
|
static void Phase21RSceneBrowserAndPanelsAreReplayFriendly()
|
||||||
|
|||||||
@ -86,9 +86,13 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
|||||||
|
|
||||||
[HttpPost("story-intelligence/start")]
|
[HttpPost("story-intelligence/start")]
|
||||||
[ValidateAntiForgeryToken]
|
[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)
|
if (job is null)
|
||||||
{
|
{
|
||||||
TempData["OnboardingStoryIntelligenceError"] = "Review the manuscript scan before starting Story Intelligence.";
|
TempData["OnboardingStoryIntelligenceError"] = "Review the manuscript scan before starting Story Intelligence.";
|
||||||
|
|||||||
@ -24,7 +24,9 @@ public interface IStoryMemoryRepository
|
|||||||
Task InvalidateAssignmentAsync(int assignmentId, string reason);
|
Task InvalidateAssignmentAsync(int assignmentId, string reason);
|
||||||
Task<StoryMemoryIllustrationDemand> UpsertDemandAsync(StoryMemoryIllustrationDemandSave request);
|
Task<StoryMemoryIllustrationDemand> UpsertDemandAsync(StoryMemoryIllustrationDemandSave request);
|
||||||
Task<string> GetAppearancePreferenceAsync(int importSessionId);
|
Task<string> GetAppearancePreferenceAsync(int importSessionId);
|
||||||
|
Task<StoryMemoryImportContext> GetImportContextAsync(int importSessionId);
|
||||||
Task SetAppearancePreferenceAsync(int importSessionId, string preference);
|
Task SetAppearancePreferenceAsync(int importSessionId, string preference);
|
||||||
|
Task SetImportContextAsync(int importSessionId, string appearancePreference, string? primaryAgeGroup, string? storyEra, string? storyLocation);
|
||||||
Task<StoryMemoryDiagnostics> GetDiagnosticsAsync(int importSessionId);
|
Task<StoryMemoryDiagnostics> GetDiagnosticsAsync(int importSessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -362,29 +364,75 @@ public sealed class StoryMemoryRepository(ISqlConnectionFactory connectionFactor
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> GetAppearancePreferenceAsync(int importSessionId)
|
public async Task<string> GetAppearancePreferenceAsync(int importSessionId)
|
||||||
|
=> (await GetImportContextAsync(importSessionId)).AppearancePreference;
|
||||||
|
|
||||||
|
public async Task<StoryMemoryImportContext> GetImportContextAsync(int importSessionId)
|
||||||
{
|
{
|
||||||
using var connection = connectionFactory.CreateConnection();
|
using var connection = connectionFactory.CreateConnection();
|
||||||
return await connection.QuerySingleOrDefaultAsync<string>(
|
await EnsureImportPreferenceContextColumnsAsync(connection);
|
||||||
"SELECT AppearancePreference FROM dbo.StoryMemoryImportPreferences WHERE ImportSessionID = @ImportSessionID;",
|
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 })
|
new { ImportSessionID = importSessionId })
|
||||||
?? StoryMemoryAppearancePreferences.Balanced;
|
?? new StoryMemoryImportContext(StoryMemoryAppearancePreferences.Balanced, string.Empty, string.Empty, string.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SetAppearancePreferenceAsync(int importSessionId, string preference)
|
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();
|
using var connection = connectionFactory.CreateConnection();
|
||||||
|
await EnsureImportPreferenceContextColumnsAsync(connection);
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
"""
|
"""
|
||||||
MERGE dbo.StoryMemoryImportPreferences WITH (HOLDLOCK) AS target
|
MERGE dbo.StoryMemoryImportPreferences WITH (HOLDLOCK) AS target
|
||||||
USING (SELECT @ImportSessionID AS ImportSessionID) AS source
|
USING (SELECT @ImportSessionID AS ImportSessionID) AS source
|
||||||
ON target.ImportSessionID = source.ImportSessionID
|
ON target.ImportSessionID = source.ImportSessionID
|
||||||
WHEN MATCHED THEN
|
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
|
WHEN NOT MATCHED THEN
|
||||||
INSERT (ImportSessionID, AppearancePreference)
|
INSERT (ImportSessionID, AppearancePreference, PrimaryAgeGroup, StoryEra, StoryLocation)
|
||||||
VALUES (@ImportSessionID, @AppearancePreference);
|
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)
|
public async Task<StoryMemoryDiagnostics> GetDiagnosticsAsync(int importSessionId)
|
||||||
|
|||||||
@ -214,18 +214,30 @@ public sealed class StoryMemoryRebuildResult
|
|||||||
public IReadOnlyList<string> Warnings { get; init; } = [];
|
public IReadOnlyList<string> Warnings { get; init; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class StoryMemoryRebuildOptions
|
public sealed record StoryMemoryRebuildOptions
|
||||||
{
|
{
|
||||||
public bool DryRun { get; init; }
|
public bool DryRun { get; init; }
|
||||||
public bool RebuildAll { get; init; }
|
public bool RebuildAll { get; init; }
|
||||||
public bool QueueDemand { get; init; }
|
public bool QueueDemand { get; init; }
|
||||||
public int? MaxDemandToQueue { get; init; }
|
public int? MaxDemandToQueue { get; init; }
|
||||||
public string AppearancePreference { get; init; } = StoryMemoryAppearancePreferences.Balanced;
|
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 bool QueueDemand { get; init; }
|
||||||
public int? MaxDemandToQueue { get; init; }
|
public int? MaxDemandToQueue { get; init; }
|
||||||
public string AppearancePreference { get; init; } = StoryMemoryAppearancePreferences.Balanced;
|
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);
|
||||||
|
|||||||
@ -247,7 +247,10 @@ public class Program
|
|||||||
builder.Services.AddHostedService<EmailQueueWorker>();
|
builder.Services.AddHostedService<EmailQueueWorker>();
|
||||||
builder.Services.AddHostedService<StoryIntelligenceWorker>();
|
builder.Services.AddHostedService<StoryIntelligenceWorker>();
|
||||||
builder.Services.AddHostedService<PersistedStoryIntelligenceWorker>();
|
builder.Services.AddHostedService<PersistedStoryIntelligenceWorker>();
|
||||||
|
if (builder.Configuration.GetValue("Illustrations:GenerationWorkerEnabled", true))
|
||||||
|
{
|
||||||
builder.Services.AddHostedService<IllustrationGenerationWorker>();
|
builder.Services.AddHostedService<IllustrationGenerationWorker>();
|
||||||
|
}
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
|||||||
@ -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
|
- 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 visible text, captions, logos, watermarks, UI, signatures, or typography
|
||||||
- no anime, cartoon, Pixar-like, caricature, celebrity likeness, or photoreal identity claim
|
- 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
|
- 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 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
|
- 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}",
|
$"apparent age band {specification.ApparentAgeBand}",
|
||||||
$"presentation {specification.Presentation}",
|
$"presentation {specification.Presentation}",
|
||||||
$"hair {specification.HairLength} {specification.HairColour}",
|
$"hair {specification.HairLength} {specification.HairColour}",
|
||||||
$"skin tone range {specification.SkinTone}",
|
$"required skin tone {specification.SkinTone}",
|
||||||
$"features {string.Join(", ", specification.Features)}",
|
$"features {string.Join(", ", specification.Features)}",
|
||||||
$"clothing {specification.ClothingEra} {specification.ClothingStyle}"
|
$"clothing {specification.ClothingEra} {specification.ClothingStyle}"
|
||||||
}.Where(item => !item.EndsWith(" ", StringComparison.Ordinal))),
|
}.Where(item => !item.EndsWith(" ", StringComparison.Ordinal))),
|
||||||
|
|||||||
@ -9,7 +9,7 @@ namespace PlotLine.Services;
|
|||||||
public interface IOnboardingStoryIntelligenceService
|
public interface IOnboardingStoryIntelligenceService
|
||||||
{
|
{
|
||||||
Task<StoryIntelligenceOverviewViewModel> GetOverviewAsync();
|
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?> GetProgressAsync(Guid batchId);
|
||||||
Task<StoryIntelligenceProgressViewModel?> CancelAsync(Guid batchId);
|
Task<StoryIntelligenceProgressViewModel?> CancelAsync(Guid batchId);
|
||||||
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAsync(Guid batchId, int runId);
|
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAsync(Guid batchId, int runId);
|
||||||
@ -40,6 +40,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
IStoryIntelligenceRelationshipImportService relationshipImport,
|
IStoryIntelligenceRelationshipImportService relationshipImport,
|
||||||
IStoryIntelligenceKnowledgeImportService knowledgeImport,
|
IStoryIntelligenceKnowledgeImportService knowledgeImport,
|
||||||
IStoryIntelligencePipelineStateService pipelineState,
|
IStoryIntelligencePipelineStateService pipelineState,
|
||||||
|
IStoryMemoryRepository storyMemory,
|
||||||
IStoryIntelligenceClient client,
|
IStoryIntelligenceClient client,
|
||||||
IOnboardingStoryIntelligenceBatchStore batchStore,
|
IOnboardingStoryIntelligenceBatchStore batchStore,
|
||||||
IStoryIntelligenceProgressNotifier notifier,
|
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 userId = RequireUserId();
|
||||||
var context = await GetContextAsync(userId);
|
var context = await GetContextAsync(userId);
|
||||||
@ -161,6 +162,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
ChapterID = chapterId,
|
ChapterID = chapterId,
|
||||||
RunID = runId
|
RunID = runId
|
||||||
});
|
});
|
||||||
|
await pipelineState.RecordChapterAnalysisQueuedAsync(preview.ProjectID, preview.BookID, runId);
|
||||||
|
|
||||||
await notifier.PublishAsync(new StoryIntelligenceRunProgressEvent
|
await notifier.PublishAsync(new StoryIntelligenceRunProgressEvent
|
||||||
{
|
{
|
||||||
@ -190,9 +192,22 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
ProjectName = wizard.SelectedProjectName,
|
ProjectName = wizard.SelectedProjectName,
|
||||||
BookTitle = wizard.SelectedBookTitle,
|
BookTitle = wizard.SelectedBookTitle,
|
||||||
AppearancePreference = NormaliseAppearancePreference(appearancePreference),
|
AppearancePreference = NormaliseAppearancePreference(appearancePreference),
|
||||||
|
PrimaryAgeGroup = CleanContext(primaryAgeGroup),
|
||||||
|
StoryEra = CleanContext(storyEra),
|
||||||
|
StoryLocation = CleanContext(storyLocation),
|
||||||
Items = batchItems
|
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);
|
await batchStore.SaveAsync(batch);
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Queued onboarding Story Intelligence batch {BatchID}. UserID={UserID} ProjectID={ProjectID} BookID={BookID} Chapters={ChapterCount}",
|
"Queued onboarding Story Intelligence batch {BatchID}. UserID={UserID} ProjectID={ProjectID} BookID={BookID} Chapters={ChapterCount}",
|
||||||
@ -217,6 +232,12 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
_ => StoryMemoryAppearancePreferences.Balanced
|
_ => 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)
|
public async Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid batchId)
|
||||||
{
|
{
|
||||||
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
|
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
|
||||||
@ -933,6 +954,9 @@ public sealed class OnboardingStoryIntelligenceBatch
|
|||||||
public string? ProjectName { get; init; }
|
public string? ProjectName { get; init; }
|
||||||
public string? BookTitle { get; init; }
|
public string? BookTitle { get; init; }
|
||||||
public string AppearancePreference { get; init; } = StoryMemoryAppearancePreferences.Balanced;
|
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 DateTime CreatedUtc { get; init; } = DateTime.UtcNow;
|
||||||
public IReadOnlyList<OnboardingStoryIntelligenceBatchItem> Items { get; init; } = [];
|
public IReadOnlyList<OnboardingStoryIntelligenceBatchItem> Items { get; init; } = [];
|
||||||
public List<OnboardingStoryIntelligenceCharacterDecision> CharacterDecisions { get; } = [];
|
public List<OnboardingStoryIntelligenceCharacterDecision> CharacterDecisions { get; } = [];
|
||||||
|
|||||||
@ -191,6 +191,13 @@ public static class StoryIntelligenceIllustrationCompatibility
|
|||||||
rejected.Add($"hair colour hard mismatch: wanted {evidence.HairColour}, candidate {candidate.HairColour}");
|
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)
|
if (alreadyAssignedToAnotherSignificantCharacter && isNamedCharacter)
|
||||||
{
|
{
|
||||||
rejected.Add("already assigned to another significant named character");
|
rejected.Add("already assigned to another significant named character");
|
||||||
|
|||||||
@ -591,7 +591,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
|
|||||||
Code = $"char-demand-{StableHash(demand.DemandKey)}",
|
Code = $"char-demand-{StableHash(demand.DemandKey)}",
|
||||||
Title = $"{age} {presentation.ToLowerInvariant()} character",
|
Title = $"{age} {presentation.ToLowerInvariant()} character",
|
||||||
Description = "Demand-created reusable character illustration for Story Intelligence matching.",
|
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",
|
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",
|
Mood = "neutral, story-ready, emotionally readable, subdued and cinematic",
|
||||||
ApparentAgeBand = age,
|
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)
|
private static Dictionary<string, CharacterObservation> BuildCharacterObservations(StoryIntelligenceExperiencePrototypeViewModel prototype)
|
||||||
{
|
{
|
||||||
var observations = new Dictionary<string, CharacterObservation>(StringComparer.OrdinalIgnoreCase);
|
var observations = new Dictionary<string, CharacterObservation>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|||||||
@ -60,7 +60,7 @@ public sealed class StoryMemoryService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var identityAliases = BuildCrossSceneIdentityAliases(completedScenes);
|
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 processed = await memory.ListProcessedSceneResultIdsAsync(importSessionId, ProcessorVersion);
|
||||||
var processedCount = 0;
|
var processedCount = 0;
|
||||||
foreach (var scene in completedScenes)
|
foreach (var scene in completedScenes)
|
||||||
@ -80,7 +80,10 @@ public sealed class StoryMemoryService(
|
|||||||
{
|
{
|
||||||
QueueDemand = options.QueueDemand,
|
QueueDemand = options.QueueDemand,
|
||||||
MaxDemandToQueue = options.MaxDemandToQueue,
|
MaxDemandToQueue = options.MaxDemandToQueue,
|
||||||
AppearancePreference = options.AppearancePreference
|
AppearancePreference = options.AppearancePreference,
|
||||||
|
PrimaryAgeGroup = options.PrimaryAgeGroup,
|
||||||
|
StoryEra = options.StoryEra,
|
||||||
|
StoryLocation = options.StoryLocation
|
||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
|
|
||||||
var diagnostics = await memory.GetDiagnosticsAsync(importSessionId);
|
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 UpsertSceneMemoryAsync(session, scene.Result, scene.Scene, new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase), cancellationToken);
|
||||||
await memory.MarkSceneResultProcessedAsync(importSessionId, sceneResultId, ProcessorVersion, StableContentHash(scene.Result.ParsedJson));
|
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)
|
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 characters = await memory.ListCharactersAsync(importSessionId);
|
||||||
var attributes = await memory.ListCharacterAttributesAsync(importSessionId);
|
var attributes = await memory.ListCharacterAttributesAsync(importSessionId);
|
||||||
var locations = await memory.ListLocationsAsync(importSessionId);
|
var locations = await memory.ListLocationsAsync(importSessionId);
|
||||||
@ -145,6 +164,7 @@ public sealed class StoryMemoryService(
|
|||||||
CharacterEvidence(character, attributes.Where(item => item.StoryMemoryCharacterID == character.StoryMemoryCharacterID)),
|
CharacterEvidence(character, attributes.Where(item => item.StoryMemoryCharacterID == character.StoryMemoryCharacterID)),
|
||||||
character.CanonicalIdentityKey,
|
character.CanonicalIdentityKey,
|
||||||
options.AppearancePreference);
|
options.AppearancePreference);
|
||||||
|
evidence = ApplyPrimaryAgeContext(evidence, options.PrimaryAgeGroup);
|
||||||
var existing = assignments.FirstOrDefault(item => item.EntityType == StoryMemoryEntityTypes.Character && item.EntityID == character.StoryMemoryCharacterID);
|
var existing = assignments.FirstOrDefault(item => item.EntityType == StoryMemoryEntityTypes.Character && item.EntityID == character.StoryMemoryCharacterID);
|
||||||
var existingItem = existing?.IllustrationLibraryItemID is int existingId
|
var existingItem = existing?.IllustrationLibraryItemID is int existingId
|
||||||
? catalogue.FirstOrDefault(item => item.IllustrationLibraryItemID == existingId)
|
? catalogue.FirstOrDefault(item => item.IllustrationLibraryItemID == existingId)
|
||||||
@ -181,7 +201,7 @@ public sealed class StoryMemoryService(
|
|||||||
continue;
|
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));
|
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
|
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));
|
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
|
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));
|
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)
|
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))
|
if (!string.Equals(evidence.SkinTone, StoryIntelligenceIllustrationCompatibility.SkinTones.Unknown, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return evidence;
|
return evidence;
|
||||||
@ -519,6 +545,20 @@ public sealed class StoryMemoryService(
|
|||||||
: evidence with { SkinTone = skinTone };
|
: 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)
|
private static string BalancedSkinTone(string key)
|
||||||
{
|
{
|
||||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(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 hairColour = PickAttribute(attributeList, "HairColour", inferred.HairColour, 0.1m);
|
||||||
var skinTone = PickAttribute(attributeList, "SkinTone", inferred.SkinTone, 0.1m);
|
var skinTone = PickAttribute(attributeList, "SkinTone", inferred.SkinTone, 0.1m);
|
||||||
var hairLength = PickAttribute(attributeList, "HairLength", inferred.HairLength, 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(
|
return new(
|
||||||
age.Value,
|
age.Value,
|
||||||
@ -760,12 +805,26 @@ public sealed class StoryMemoryService(
|
|||||||
if (recorded.QueuedIllustrationLibraryItemID.HasValue || recorded.GeneratedIllustrationLibraryItemID.HasValue)
|
if (recorded.QueuedIllustrationLibraryItemID.HasValue || recorded.GeneratedIllustrationLibraryItemID.HasValue)
|
||||||
{
|
{
|
||||||
var existingId = recorded.GeneratedIllustrationLibraryItemID ?? recorded.QueuedIllustrationLibraryItemID;
|
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}.");
|
return new(existingId, specification.Code, "DemandQueued", $"Demand already has queued illustration item {existingId:N0}.");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (options.MaxDemandToQueue is int maxDemandToQueue && maxDemandToQueue > 0)
|
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()))
|
var queuedDemandItems = (await illustrationLibrary.ListAsync(new IllustrationLibraryFilter()))
|
||||||
.Count(item => string.Equals(item.Category, specification.Category, StringComparison.OrdinalIgnoreCase)
|
.Count(item => string.Equals(item.Category, specification.Category, StringComparison.OrdinalIgnoreCase)
|
||||||
&& item.IsActive
|
&& 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}.");
|
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 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 presentation = UnknownToDefault(evidence.Presentation, "Androgynous");
|
||||||
var hairColour = UnknownToDefault(evidence.HairColour, "Brown");
|
var hairColour = UnknownToDefault(evidence.HairColour, "Brown");
|
||||||
var hairLength = UnknownToDefault(evidence.HairLength, "Medium");
|
var hairLength = UnknownToDefault(evidence.HairLength, "Medium");
|
||||||
var skinTone = UnknownToDefault(evidence.SkinTone, "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
|
return new IllustrationGenerationSpecification
|
||||||
{
|
{
|
||||||
Category = IllustrationLibraryCategories.Character,
|
Category = IllustrationLibraryCategories.Character,
|
||||||
Code = code,
|
Code = code,
|
||||||
Title = $"{age} {presentation} character",
|
Title = $"{age} {presentation} character",
|
||||||
Description = "Reusable character portrait generated from durable Story Memory appearance evidence. Not canonical to a specific manuscript.",
|
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",
|
Composition = "single head-and-shoulders portrait with clear facial lighting, neutral story background, and strong thumbnail readability",
|
||||||
Mood = "present, natural, story-aware",
|
Mood = "present, natural, story-aware",
|
||||||
ApparentAgeBand = age,
|
ApparentAgeBand = age,
|
||||||
@ -818,15 +881,15 @@ public sealed class StoryMemoryService(
|
|||||||
HairColour = hairColour,
|
HairColour = hairColour,
|
||||||
HairLength = hairLength,
|
HairLength = hairLength,
|
||||||
SkinTone = skinTone,
|
SkinTone = skinTone,
|
||||||
ClothingEra = "1980s",
|
ClothingEra = era,
|
||||||
ClothingStyle = "everyday naturalistic",
|
ClothingStyle = $"{era} {location} everyday naturalistic",
|
||||||
Palette = ["clear blue", "warm amber", "natural skin tones"],
|
Palette = ["clear blue", "warm amber", "natural skin tones"],
|
||||||
MetadataTags = ["character", "story-memory-demand"],
|
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 code = DemandStableCode(StoryMemoryEntityTypes.Location, location.CanonicalIdentityKey, requestedType);
|
||||||
var locationType = UnknownToDefault(requestedType, "UnknownInterior");
|
var locationType = UnknownToDefault(requestedType, "UnknownInterior");
|
||||||
@ -844,11 +907,11 @@ public sealed class StoryMemoryService(
|
|||||||
Setting = location.InteriorExterior ?? "Story setting",
|
Setting = location.InteriorExterior ?? "Story setting",
|
||||||
Palette = ["clear navy", "warm light", "balanced natural colour"],
|
Palette = ["clear navy", "warm light", "balanced natural colour"],
|
||||||
MetadataTags = ["location", "story-memory-demand"],
|
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 code = DemandStableCode(StoryMemoryEntityTypes.Asset, asset.CanonicalIdentityKey, requestedType, requestedColour);
|
||||||
var objectType = UnknownToDefault(requestedType, "UnknownObject");
|
var objectType = UnknownToDefault(requestedType, "UnknownObject");
|
||||||
@ -867,12 +930,13 @@ public sealed class StoryMemoryService(
|
|||||||
Era = asset.Era ?? "1980s",
|
Era = asset.Era ?? "1980s",
|
||||||
Palette = ["clean blue", "warm light", "natural object colour"],
|
Palette = ["clean blue", "warm light", "natural object colour"],
|
||||||
MetadataTags = ["asset", "story-memory-demand"],
|
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)
|
private static Dictionary<string, string> DemandMetadata(string category, string entityKey, StoryMemoryResolveOptions options)
|
||||||
=> new(StringComparer.OrdinalIgnoreCase)
|
{
|
||||||
|
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
["category"] = category,
|
["category"] = category,
|
||||||
["generationSource"] = "Story Memory demand",
|
["generationSource"] = "Story Memory demand",
|
||||||
@ -880,6 +944,19 @@ public sealed class StoryMemoryService(
|
|||||||
["privacy"] = "generic-no-manuscript-data",
|
["privacy"] = "generic-no-manuscript-data",
|
||||||
["storyMemoryEntityHash"] = ShortHash(entityKey)
|
["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)
|
private static string DemandStableCode(string entityType, params string[] parts)
|
||||||
=> $"demand-{StableKey(entityType)}-{ShortHash(string.Join("|", parts.Where(part => !string.IsNullOrWhiteSpace(part))))}";
|
=> $"demand-{StableKey(entityType)}-{ShortHash(string.Join("|", parts.Where(part => !string.IsNullOrWhiteSpace(part))))}";
|
||||||
@ -892,6 +969,34 @@ public sealed class StoryMemoryService(
|
|||||||
? fallback
|
? fallback
|
||||||
: value.Trim();
|
: 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)
|
private static string SplitWords(string? value)
|
||||||
{
|
{
|
||||||
var clean = UnknownToDefault(value, "Unknown");
|
var clean = UnknownToDefault(value, "Unknown");
|
||||||
|
|||||||
11
PlotLine/Sql/144_Phase21V_StoryMemoryImportContext.sql
Normal file
11
PlotLine/Sql/144_Phase21V_StoryMemoryImportContext.sql
Normal 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
|
||||||
@ -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.PredominantlyDark" /> Predominantly dark-skinned cast</label>
|
||||||
<label><input type="radio" name="appearancePreference" value="@StoryMemoryAppearancePreferences.CustomMix" /> Custom mix</label>
|
<label><input type="radio" name="appearancePreference" value="@StoryMemoryAppearancePreferences.CustomMix" /> Custom mix</label>
|
||||||
</fieldset>
|
</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>
|
<button class="btn btn-primary" type="submit" disabled="@(!Model.CanStart)">Analyse manuscript</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user