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