PlotDirector/PlotLine/Services/StoryMemoryServices.cs

1318 lines
73 KiB
C#

using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using PlotLine.Data;
using PlotLine.Models;
namespace PlotLine.Services;
public interface IStoryMemoryService
{
Task<StoryMemoryRebuildResult> RebuildImportSessionAsync(int importSessionId, int userId, StoryMemoryRebuildOptions options, CancellationToken cancellationToken = default);
Task ProcessSceneResultAsync(int importSessionId, int userId, int sceneResultId, StoryMemoryResolveOptions options, CancellationToken cancellationToken = default);
Task ResolveIllustrationsAsync(int importSessionId, StoryMemoryResolveOptions options, CancellationToken cancellationToken = default);
}
public sealed class StoryMemoryService(
IStoryIntelligenceResultRepository results,
IStoryIntelligencePipelineRepository pipelines,
IStoryMemoryRepository memory,
IIllustrationLibraryRepository illustrationLibrary,
IIllustrationPromptBuilder promptBuilder,
IUploadStorageService uploadStorage,
ILogger<StoryMemoryService> logger) : IStoryMemoryService
{
private const string ProcessorVersion = "21S";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
WriteIndented = false
};
public async Task<StoryMemoryRebuildResult> RebuildImportSessionAsync(int importSessionId, int userId, StoryMemoryRebuildOptions options, CancellationToken cancellationToken = default)
{
var session = await pipelines.GetByIdForUserAsync(importSessionId, userId)
?? throw new InvalidOperationException($"Import Session {importSessionId} could not be found for this user.");
var runScenes = await LoadImportScenesAsync(session, cancellationToken);
var completedScenes = runScenes.Where(item => item.Scene is not null).ToList();
if (options.DryRun)
{
return new StoryMemoryRebuildResult
{
ImportSessionID = importSessionId,
DryRun = true,
SceneResultsRead = runScenes.Count,
SceneResultsProcessed = completedScenes.Count,
CharactersDiscovered = completedScenes.Sum(item => item.Scene!.Characters?.Count ?? 0),
LocationsDiscovered = completedScenes.Sum(item => (item.Scene!.Locations?.Count ?? 0) + (string.IsNullOrWhiteSpace(item.Scene.Setting?.LocationName) ? 0 : 1)),
AssetsDiscovered = completedScenes.Sum(item => item.Scene!.Assets?.Count ?? 0),
RelationshipsDiscovered = completedScenes.Sum(item => item.Scene!.Relationships?.Count ?? 0),
AiCalls = 0,
Warnings = runScenes.Where(item => item.Scene is null && !string.IsNullOrWhiteSpace(item.ParseError)).Select(item => item.ParseError!).Take(20).ToList()
};
}
if (options.RebuildAll)
{
await memory.ClearDerivedStateAsync(importSessionId);
}
var identityAliases = BuildCrossSceneIdentityAliases(completedScenes);
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)
{
cancellationToken.ThrowIfCancellationRequested();
if (!options.RebuildAll && processed.Contains(scene.Result.SceneResultID))
{
continue;
}
await UpsertSceneMemoryAsync(session, scene.Result, scene.Scene!, identityAliases, cancellationToken);
await memory.MarkSceneResultProcessedAsync(importSessionId, scene.Result.SceneResultID, ProcessorVersion, StableContentHash(scene.Result.ParsedJson));
processedCount++;
}
await ResolveIllustrationsAsync(importSessionId, new StoryMemoryResolveOptions
{
QueueDemand = options.QueueDemand,
MaxDemandToQueue = options.MaxDemandToQueue,
AppearancePreference = options.AppearancePreference,
PrimaryAgeGroup = options.PrimaryAgeGroup,
StoryEra = options.StoryEra,
StoryLocation = options.StoryLocation
}, cancellationToken);
var diagnostics = await memory.GetDiagnosticsAsync(importSessionId);
return new StoryMemoryRebuildResult
{
ImportSessionID = importSessionId,
DryRun = false,
SceneResultsRead = runScenes.Count,
SceneResultsProcessed = processedCount,
CharactersDiscovered = diagnostics.CharacterCount,
LocationsDiscovered = diagnostics.LocationCount,
AssetsDiscovered = diagnostics.AssetCount,
RelationshipsDiscovered = diagnostics.RelationshipCount,
AssignmentsCreatedOrValidated = diagnostics.AssignmentCount,
DemandsCreated = diagnostics.DemandCount,
FallbacksRemaining = diagnostics.FallbackCount,
AiCalls = 0
};
}
public async Task ProcessSceneResultAsync(int importSessionId, int userId, int sceneResultId, StoryMemoryResolveOptions options, CancellationToken cancellationToken = default)
{
var session = await pipelines.GetByIdForUserAsync(importSessionId, userId);
if (session is null)
{
logger.LogWarning("Story Memory skipped scene result {SceneResultId}: import session {ImportSessionId} was not visible to user {UserId}.", sceneResultId, importSessionId, userId);
return;
}
var scene = (await LoadImportScenesAsync(session, cancellationToken)).FirstOrDefault(item => item.Result.SceneResultID == sceneResultId);
if (scene is null || scene.Scene is null)
{
return;
}
await UpsertSceneMemoryAsync(session, scene.Result, scene.Scene, new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase), cancellationToken);
await memory.MarkSceneResultProcessedAsync(importSessionId, sceneResultId, ProcessorVersion, StableContentHash(scene.Result.ParsedJson));
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);
var assets = await memory.ListAssetsAsync(importSessionId);
var assignments = await memory.ListAssignmentsAsync(importSessionId);
var catalogue = (await illustrationLibrary.ListAsync(new IllustrationLibraryFilter()))
.Where(item => item.Status is IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Generated)
.Where(item => ResolveUploadUrl(item) is not null)
.ToList();
var usedItemIds = assignments
.Where(item => item.IllustrationLibraryItemID.HasValue)
.Select(item => item.IllustrationLibraryItemID!.Value)
.ToHashSet();
foreach (var character in characters.Where(item => !item.IsGroupEntity))
{
cancellationToken.ThrowIfCancellationRequested();
var evidence = ApplyAppearancePreference(
CharacterEvidence(character, attributes.Where(item => item.StoryMemoryCharacterID == character.StoryMemoryCharacterID)),
character.CanonicalIdentityKey,
options.AppearancePreference);
evidence = ApplyPrimaryAgeContext(evidence, options.PrimaryAgeGroup, character.DisplayName);
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)
: null;
var existingValid = existingItem is not null && StoryIntelligenceIllustrationCompatibility
.CharacterHardRejections(evidence, StoryIntelligenceIllustrationCompatibility.CharacterMetadataFrom(existingItem), character.IsNamed, false)
.Count == 0;
var candidates = catalogue
.Where(item => item.Category == IllustrationLibraryCategories.Character)
.Select(item => ScoreCharacter(item, evidence, character.IsNamed, usedItemIds.Contains(item.IllustrationLibraryItemID) && item.IllustrationLibraryItemID != existing?.IllustrationLibraryItemID))
.Where(item => item.IsCompatible)
.OrderByDescending(item => item.Score)
.ThenBy(item => item.Item!.StableCode)
.ToList();
var chosen = candidates.FirstOrDefault();
if (chosen.Item is not null)
{
usedItemIds.Add(chosen.Item.IllustrationLibraryItemID);
await memory.UpsertAssignmentAsync(new StoryMemoryIllustrationAssignmentSave(
importSessionId,
StoryMemoryEntityTypes.Character,
character.StoryMemoryCharacterID,
chosen.Item.IllustrationLibraryItemID,
chosen.Item.StableCode,
"Assigned",
false,
chosen.Score,
Math.Min(0.99m, chosen.Score / 200m),
existingValid && existingItem?.IllustrationLibraryItemID == chosen.Item.IllustrationLibraryItemID
? $"Retained {chosen.Item.StableCode}; durable evidence passed hard revalidation."
: $"Assigned {chosen.Item.StableCode}; durable Story Memory was resolved against hard character constraints.",
ProcessorVersion));
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, options), 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)
{
cancellationToken.ThrowIfCancellationRequested();
var requestedType = StoryIntelligenceIllustrationCompatibility.LocationType($"{location.DisplayName} {location.SemanticType} {location.Subtype}");
var chosen = catalogue
.Where(item => item.Category == IllustrationLibraryCategories.Location)
.Select(item => (Item: item, Type: StoryIntelligenceIllustrationCompatibility.LocationType($"{item.StableCode} {item.DisplayTitle} {item.ShortDescription} {item.MetadataJson} {item.SpecificationJson}")))
.Where(item => StoryIntelligenceIllustrationCompatibility.LocationCompatible(requestedType, item.Type))
.OrderByDescending(item => string.Equals(item.Type, requestedType, StringComparison.OrdinalIgnoreCase) ? 100 : 50)
.ThenBy(item => item.Item.StableCode)
.FirstOrDefault();
if (chosen.Item is not null)
{
await memory.UpsertAssignmentAsync(new StoryMemoryIllustrationAssignmentSave(importSessionId, StoryMemoryEntityTypes.Location, location.StoryMemoryLocationID, chosen.Item.IllustrationLibraryItemID, chosen.Item.StableCode, "Assigned", false, 100, 0.9m, $"Assigned {chosen.Item.StableCode}; location type {requestedType} matched {chosen.Type}.", ProcessorVersion));
}
else
{
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));
}
}
foreach (var asset in assets)
{
cancellationToken.ThrowIfCancellationRequested();
var requestedType = StoryIntelligenceIllustrationCompatibility.AssetType($"{asset.DisplayName} {asset.SemanticType} {asset.Subtype}");
var requestedColour = StoryIntelligenceIllustrationCompatibility.AssetColour($"{asset.DisplayName} {asset.Colour}");
var chosen = catalogue
.Where(item => item.Category == IllustrationLibraryCategories.Asset)
.Select(item => (Item: item, Type: StoryIntelligenceIllustrationCompatibility.AssetType($"{item.StableCode} {item.DisplayTitle} {item.ShortDescription} {item.MetadataJson} {item.SpecificationJson}"), Colour: StoryIntelligenceIllustrationCompatibility.AssetColour($"{item.StableCode} {item.DisplayTitle} {item.ShortDescription} {item.MetadataJson} {item.SpecificationJson}")))
.Where(item => StoryIntelligenceIllustrationCompatibility.AssetCompatible(requestedType, item.Type))
.Where(item => requestedColour == "Unknown" || item.Colour == "Unknown" || string.Equals(requestedColour, item.Colour, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(item => string.Equals(item.Type, requestedType, StringComparison.OrdinalIgnoreCase) ? 100 : 50)
.ThenByDescending(item => string.Equals(item.Colour, requestedColour, StringComparison.OrdinalIgnoreCase) ? 10 : 0)
.ThenBy(item => item.Item.StableCode)
.FirstOrDefault();
if (chosen.Item is not null)
{
await memory.UpsertAssignmentAsync(new StoryMemoryIllustrationAssignmentSave(importSessionId, StoryMemoryEntityTypes.Asset, asset.StoryMemoryAssetID, chosen.Item.IllustrationLibraryItemID, chosen.Item.StableCode, "Assigned", false, 100, 0.9m, $"Assigned {chosen.Item.StableCode}; asset type {requestedType} matched {chosen.Type}.", ProcessorVersion));
}
else
{
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));
}
}
}
private async Task UpsertSceneMemoryAsync(
StoryIntelligenceBookPipelineState session,
StoryIntelligenceSavedSceneResult result,
SceneIntelligenceScene scene,
IReadOnlyDictionary<string, string> identityAliases,
CancellationToken cancellationToken)
{
var context = SourceSectionType(result, scene);
var sourceSectionType = context == "Story" ? "Story" : "Authorial";
foreach (var sceneCharacter in scene.Characters ?? [])
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(sceneCharacter.Name))
{
continue;
}
var sceneAppearanceText = CharacterText(sceneCharacter);
var knownNames = KnownNames(sceneCharacter.Name, sceneCharacter.Aliases);
var appearanceAliases = CharacterAppearanceExtractionService.AliasMap(sceneAppearanceText, knownNames);
var resolvedName = CharacterAppearanceExtractionService.CanonicalName(
ResolveIdentityAlias(sceneCharacter.Name, identityAliases),
appearanceAliases);
var resolvedAliases = string.Equals(resolvedName, sceneCharacter.Name, StringComparison.OrdinalIgnoreCase)
? sceneCharacter.Aliases
: (sceneCharacter.Aliases ?? []).Concat([sceneCharacter.Name!]).ToList();
var key = CharacterKey(resolvedName, resolvedAliases);
var displayName = CanonicalCharacterDisplayName(resolvedName, resolvedAliases);
var isGroup = StoryIntelligenceIllustrationCompatibility.IsGroupEntity(sceneCharacter.Name);
var resolvedPovName = ResolveScenePov(scene);
var isPrimaryPov = string.Equals(resolvedPovName, sceneCharacter.Name, StringComparison.OrdinalIgnoreCase)
|| string.Equals(resolvedPovName, resolvedName, StringComparison.OrdinalIgnoreCase)
|| (sceneCharacter.Aliases ?? []).Any(alias => string.Equals(resolvedPovName, alias, StringComparison.OrdinalIgnoreCase));
var character = await memory.UpsertCharacterAsync(new StoryMemoryCharacterSave(
session.StoryIntelligenceBookPipelineID,
session.ProjectID,
session.BookID,
key,
displayName,
context,
sourceSectionType,
IsNamedCharacter(sceneCharacter.Name),
isGroup,
true,
isPrimaryPov,
isPrimaryPov,
CharacterSignificance(sceneCharacter),
result.SceneResultID));
await memory.AddCharacterAliasAsync(character.StoryMemoryCharacterID, sceneCharacter.Name.Trim(), Normalise(sceneCharacter.Name), "Scene character name", result.SceneResultID, sceneCharacter.Confidence ?? 0.8m);
foreach (var alias in sceneCharacter.Aliases ?? [])
{
await memory.AddCharacterAliasAsync(character.StoryMemoryCharacterID, alias, Normalise(alias), "Scene character alias", result.SceneResultID, sceneCharacter.Confidence ?? 0.7m);
}
var identityEvidence = new[] { sceneCharacter.Name, resolvedName, displayName, sceneCharacter.RoleInScene, sceneCharacter.Notes }
.Concat(sceneCharacter.Actions ?? [])
.Concat(sceneCharacter.Aliases ?? [])
.Concat(ExtensionEvidence(sceneCharacter.ExtensionData))
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value!);
var relationshipEvidence = (scene.Relationships ?? [])
.Where(item => string.Equals(item.CharacterA, sceneCharacter.Name, StringComparison.OrdinalIgnoreCase) || string.Equals(item.CharacterB, sceneCharacter.Name, StringComparison.OrdinalIgnoreCase))
.Select(item => $"{item.RelationshipSignal} {item.Evidence}");
var evidenceName = string.Equals(resolvedName, sceneCharacter.Name, StringComparison.OrdinalIgnoreCase)
? resolvedName
: sceneCharacter.Name;
var inferred = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence(evidenceName, identityEvidence, relationshipEvidence);
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "AgeBand", inferred.AgeBand, inferred.AgeConfidence, inferred.AgeConfidence >= 0.85m, "Deterministic evidence inference", result.SceneResultID);
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "Presentation", inferred.Presentation, inferred.PresentationConfidence, inferred.PresentationConfidence >= 0.8m, "Deterministic evidence inference", result.SceneResultID);
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "HairColour", inferred.HairColour, 0.82m, inferred.HairColour != StoryIntelligenceIllustrationCompatibility.HairColours.Unknown, "Deterministic evidence inference", result.SceneResultID);
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "SkinTone", inferred.SkinTone, 0.8m, inferred.SkinTone != StoryIntelligenceIllustrationCompatibility.SkinTones.Unknown, "Deterministic evidence inference", result.SceneResultID);
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "HairLength", inferred.HairLength, 0.72m, inferred.HairLength != "Unknown", "Deterministic evidence inference", result.SceneResultID);
foreach (var fact in CharacterAppearanceExtractionService.Extract(sceneAppearanceText, [displayName, .. knownNames], $"SceneResultID {result.SceneResultID}"))
{
if (fact.AttributeType == "Alias")
{
await memory.AddCharacterAliasAsync(character.StoryMemoryCharacterID, fact.NormalisedValue, Normalise(fact.NormalisedValue), fact.EvidenceFragment, result.SceneResultID, fact.Confidence);
}
else
{
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, fact.AttributeType, fact.NormalisedValue, fact.Confidence, fact.IsExplicit, fact.EvidenceFragment, result.SceneResultID);
}
}
}
foreach (var appearance in scene.CharacterAppearance ?? [])
{
if (string.IsNullOrWhiteSpace(appearance.CanonicalName))
{
continue;
}
var aliases = appearance.Aliases ?? [];
var canonical = CharacterAppearanceExtractionService.CanonicalName(
appearance.CanonicalName,
CharacterAppearanceExtractionService.AliasMap(appearance.Evidence ?? string.Empty, [appearance.CanonicalName, .. aliases]));
var displayName = CanonicalCharacterDisplayName(canonical, aliases);
var character = await memory.UpsertCharacterAsync(new StoryMemoryCharacterSave(
session.StoryIntelligenceBookPipelineID,
session.ProjectID,
session.BookID,
CharacterKey(displayName, aliases),
displayName,
context,
sourceSectionType,
IsNamedCharacter(displayName),
StoryIntelligenceIllustrationCompatibility.IsGroupEntity(displayName),
true,
false,
false,
55,
result.SceneResultID));
await memory.AddCharacterAliasAsync(character.StoryMemoryCharacterID, displayName, Normalise(displayName), "Structured character appearance canonical identity", result.SceneResultID, appearance.Confidence ?? 0.85m);
foreach (var alias in aliases.Where(alias => !string.IsNullOrWhiteSpace(alias)))
{
await memory.AddCharacterAliasAsync(character.StoryMemoryCharacterID, alias.Trim(), Normalise(alias), "Structured character appearance alias", result.SceneResultID, appearance.Confidence ?? 0.8m);
}
var evidence = string.IsNullOrWhiteSpace(appearance.Evidence) ? "Structured character appearance extraction" : appearance.Evidence.Trim();
var explicitFact = string.Equals(appearance.EvidenceKind, "explicit", StringComparison.OrdinalIgnoreCase);
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "AgeBand", StoryIntelligenceIllustrationCompatibility.NormalizeAgeBand(appearance.InferredAgeBand ?? appearance.ExplicitAge ?? appearance.RoleOrOccupation), appearance.Confidence ?? 0.82m, explicitFact || !string.IsNullOrWhiteSpace(appearance.ExplicitAge), evidence, result.SceneResultID);
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "Presentation", StoryIntelligenceIllustrationCompatibility.NormalizePresentation(appearance.Presentation ?? appearance.PronounEvidence ?? appearance.Title), appearance.Confidence ?? 0.82m, explicitFact, evidence, result.SceneResultID);
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "HairColour", StoryIntelligenceIllustrationCompatibility.NormalizeHairColour(appearance.HairColour), appearance.Confidence ?? 0.9m, explicitFact, evidence, result.SceneResultID);
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "HairLength", StoryIntelligenceIllustrationCompatibility.NormalizeHairLength(appearance.HairLength), appearance.Confidence ?? 0.86m, explicitFact, evidence, result.SceneResultID);
}
if (!string.IsNullOrWhiteSpace(scene.Setting?.LocationName))
{
await UpsertLocationAsync(session, result, context, sourceSectionType, scene.Setting.LocationName, scene.Setting.LocationType, scene.Setting.GenericRoomType, scene.Setting.ParentLocationHint, scene.Setting.Confidence);
}
foreach (var location in scene.Locations ?? [])
{
if (!string.IsNullOrWhiteSpace(location.Name))
{
await UpsertLocationAsync(session, result, context, sourceSectionType, location.Name, location.LocationType, location.GenericRoomType, location.ParentLocationHint, location.Confidence);
}
}
foreach (var asset in scene.Assets ?? [])
{
if (string.IsNullOrWhiteSpace(asset.Name))
{
continue;
}
var semantic = StoryIntelligenceIllustrationCompatibility.AssetType($"{asset.Name} {asset.AssetType} {asset.Notes}");
await memory.UpsertAssetAsync(new StoryMemoryAssetSave(
session.StoryIntelligenceBookPipelineID,
session.ProjectID,
session.BookID,
AssetKey(asset.Name),
asset.Name.Trim(),
semantic,
asset.AssetType,
StoryIntelligenceIllustrationCompatibility.AssetColour($"{asset.Name} {asset.Notes}"),
null,
result.SceneResultID));
}
foreach (var relationship in scene.Relationships ?? [])
{
if (string.IsNullOrWhiteSpace(relationship.CharacterA) || string.IsNullOrWhiteSpace(relationship.CharacterB))
{
continue;
}
var sourceName = ResolveIdentityAlias(relationship.CharacterA, identityAliases);
var targetName = ResolveIdentityAlias(relationship.CharacterB, identityAliases);
var source = await memory.UpsertCharacterAsync(new StoryMemoryCharacterSave(session.StoryIntelligenceBookPipelineID, session.ProjectID, session.BookID, CharacterKey(sourceName), CanonicalCharacterDisplayName(sourceName, null), context, sourceSectionType, IsNamedCharacter(sourceName), StoryIntelligenceIllustrationCompatibility.IsGroupEntity(sourceName), true, false, false, 40, result.SceneResultID));
var target = await memory.UpsertCharacterAsync(new StoryMemoryCharacterSave(session.StoryIntelligenceBookPipelineID, session.ProjectID, session.BookID, CharacterKey(targetName), CanonicalCharacterDisplayName(targetName, null), context, sourceSectionType, IsNamedCharacter(targetName), StoryIntelligenceIllustrationCompatibility.IsGroupEntity(targetName), true, false, false, 40, result.SceneResultID));
await memory.UpsertRelationshipAsync(session.StoryIntelligenceBookPipelineID, StoryMemoryEntityTypes.Character, source.StoryMemoryCharacterID, StoryMemoryEntityTypes.Character, target.StoryMemoryCharacterID, relationship.RelationshipSignal ?? "related", relationship.Confidence ?? 0.6m, !string.IsNullOrWhiteSpace(relationship.Evidence), result.SceneResultID);
}
}
private async Task UpsertLocationAsync(StoryIntelligenceBookPipelineState session, StoryIntelligenceSavedSceneResult result, string context, string sourceSectionType, string name, string? locationType, string? genericRoomType, string? parent, decimal? confidence)
{
var semantic = StoryIntelligenceIllustrationCompatibility.LocationType($"{name} {locationType} {genericRoomType}");
await memory.UpsertLocationAsync(new StoryMemoryLocationSave(session.StoryIntelligenceBookPipelineID, session.ProjectID, session.BookID, LocationKey(name), name.Trim(), semantic, genericRoomType ?? locationType, InferInteriorExterior(semantic), result.SceneResultID));
}
private async Task AddKnownAttributeAsync(int characterId, string attributeType, string value, decimal confidence, bool isExplicit, string evidence, int sceneResultId)
{
if (string.IsNullOrWhiteSpace(value) || string.Equals(value, "Unknown", StringComparison.OrdinalIgnoreCase))
{
return;
}
await memory.AddCharacterAttributeAsync(characterId, attributeType, value, confidence <= 0 ? 0.6m : confidence, isExplicit, evidence, sceneResultId);
}
private async Task<IReadOnlyList<SceneResultWithParse>> LoadImportScenesAsync(StoryIntelligenceBookPipelineState session, CancellationToken cancellationToken)
{
var runs = (await results.ListRunsAsync())
.Where(run => run.ProjectID == session.ProjectID && run.BookID == session.BookID)
.Select(run => new StoryIntelligenceSavedRun
{
StoryIntelligenceRunID = run.StoryIntelligenceRunID,
UserID = run.UserID,
ProjectID = run.ProjectID,
ProjectTitle = run.ProjectTitle,
BookID = run.BookID,
BookTitle = run.BookTitle,
ChapterID = run.ChapterID,
ChapterNumber = run.ChapterNumber,
Status = run.Status,
SourceType = run.SourceType,
CreatedUtc = run.CreatedUtc,
UpdatedUtc = run.CreatedUtc
})
.ToList();
var items = new List<SceneResultWithParse>();
foreach (var run in runs.OrderBy(item => item.ChapterNumber ?? decimal.MaxValue).ThenBy(item => item.StoryIntelligenceRunID))
{
cancellationToken.ThrowIfCancellationRequested();
var sceneResults = await results.ListSceneResultsAsync(run.StoryIntelligenceRunID);
items.AddRange(sceneResults.Select(item => new SceneResultWithParse(run, item, TryReadScene(item, out var error), error)));
}
return items
.OrderBy(item => item.Run.ChapterNumber ?? decimal.MaxValue)
.ThenBy(item => item.Result.TemporarySceneNumber)
.ThenBy(item => item.Result.SceneResultID)
.ToList();
}
private static SceneIntelligenceScene? TryReadScene(StoryIntelligenceSavedSceneResult result, out string? safeError)
{
safeError = null;
try
{
if (!string.IsNullOrWhiteSpace(result.ParsedJson))
{
var parsed = JsonSerializer.Deserialize<SceneIntelligenceScene>(result.ParsedJson, JsonOptions);
if (parsed?.SceneReference is not null || parsed?.Summary is not null || parsed?.Characters is not null)
{
return parsed;
}
using var document = JsonDocument.Parse(result.ParsedJson);
if (document.RootElement.TryGetProperty("parsedScene", out var parsedScene))
{
return parsedScene.Deserialize<SceneIntelligenceScene>(JsonOptions);
}
}
if (!string.IsNullOrWhiteSpace(result.OutputTextJson))
{
return JsonSerializer.Deserialize<SceneIntelligenceScene>(result.OutputTextJson, JsonOptions);
}
}
catch (JsonException ex)
{
safeError = $"SceneResultID {result.SceneResultID} could not be parsed: {ex.Message}";
}
return null;
}
private static CharacterEvidenceProfile CharacterEvidence(StoryMemoryCharacter character, IEnumerable<StoryMemoryCharacterAttribute> attributes)
=> ResolveCharacterEvidenceFromAttributes(character.DisplayName, attributes);
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;
}
var skinTone = preference switch
{
StoryMemoryAppearancePreferences.PredominantlyLight => StoryIntelligenceIllustrationCompatibility.SkinTones.Light,
StoryMemoryAppearancePreferences.PredominantlyMedium => StoryIntelligenceIllustrationCompatibility.SkinTones.Medium,
StoryMemoryAppearancePreferences.PredominantlyDark => StoryIntelligenceIllustrationCompatibility.SkinTones.Dark,
StoryMemoryAppearancePreferences.Balanced => BalancedSkinTone(identityKey),
StoryMemoryAppearancePreferences.CustomMix => BalancedSkinTone(identityKey),
_ => StoryIntelligenceIllustrationCompatibility.SkinTones.Unknown
};
return string.Equals(skinTone, StoryIntelligenceIllustrationCompatibility.SkinTones.Unknown, StringComparison.OrdinalIgnoreCase)
? evidence
: evidence with { SkinTone = skinTone };
}
public static CharacterEvidenceProfile ApplyImportContextToEvidence(CharacterEvidenceProfile evidence, string displayName, StoryMemoryImportContext context)
{
var withAppearance = ApplyAppearancePreference(evidence, Normalise(displayName), context.AppearancePreference);
return ApplyPrimaryAgeContext(withAppearance, context.PrimaryAgeGroup, displayName);
}
private static CharacterEvidenceProfile ApplyPrimaryAgeContext(CharacterEvidenceProfile evidence, string primaryAgeGroup, string displayName)
{
var contextAge = StoryIntelligenceIllustrationCompatibility.NormalizeAgeBand(primaryAgeGroup);
if (contextAge == StoryIntelligenceIllustrationCompatibility.AgeBands.Unknown)
{
return evidence;
}
return string.Equals(evidence.AgeBand, StoryIntelligenceIllustrationCompatibility.AgeBands.Unknown, StringComparison.OrdinalIgnoreCase)
|| (string.Equals(evidence.AgeBand, StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, StringComparison.OrdinalIgnoreCase)
&& !NameImpliesAdultRole(displayName))
? 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));
return (bytes[0] % 3) switch
{
0 => StoryIntelligenceIllustrationCompatibility.SkinTones.Light,
1 => StoryIntelligenceIllustrationCompatibility.SkinTones.Medium,
_ => StoryIntelligenceIllustrationCompatibility.SkinTones.Dark
};
}
public static CharacterEvidenceProfile ResolveCharacterEvidenceFromAttributes(string displayName, IEnumerable<StoryMemoryCharacterAttribute> attributes)
{
var attributeList = attributes
.Where(item => !string.IsNullOrWhiteSpace(item.AttributeType) && !string.IsNullOrWhiteSpace(item.NormalisedValue))
.ToList();
var inferred = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence(
displayName,
[displayName],
[]);
var age = PickAgeAttribute(attributeList, displayName, inferred.AgeBand, inferred.AgeConfidence);
var presentation = GuardPresentation(displayName, PickAttribute(attributeList, "Presentation", inferred.Presentation, inferred.PresentationConfidence), inferred);
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,
presentation.Value,
hairColour.Value,
skinTone.Value,
hairLength.Value,
"Unknown",
"Unknown",
age.Confidence,
presentation.Confidence,
inferred.RelationshipConfidence,
inferred.NameConfidence,
inferred.PronounConfidence,
inferred.Warnings);
}
private static (string Value, decimal Confidence) PickAttribute(
IReadOnlyList<StoryMemoryCharacterAttribute> attributes,
string attributeType,
string fallback,
decimal fallbackConfidence)
{
var selected = attributes
.Where(item => string.Equals(item.AttributeType, attributeType, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(item => item.IsExplicit)
.ThenByDescending(item => item.Confidence)
.ThenByDescending(item => item.SourceSceneResultID ?? 0)
.FirstOrDefault();
return selected is null
? (fallback, fallbackConfidence)
: (selected.NormalisedValue, selected.Confidence);
}
private static (string Value, decimal Confidence) PickAgeAttribute(
IReadOnlyList<StoryMemoryCharacterAttribute> attributes,
string displayName,
string fallback,
decimal fallbackConfidence)
{
var ageAttributes = attributes
.Where(item => string.Equals(item.AttributeType, "AgeBand", StringComparison.OrdinalIgnoreCase))
.Where(item => !LooksLikeLeakedFamilyAge(displayName, item))
.ToList();
if (ageAttributes.Any(item => IsTeenOrAdultAge(item.NormalisedValue)))
{
ageAttributes = ageAttributes
.Where(item => !LooksLikeLeakedChildAge(displayName, item))
.ToList();
}
var selected = ageAttributes
.OrderByDescending(item => item.IsExplicit)
.ThenByDescending(item => item.Confidence)
.ThenByDescending(item => AgeSpecificity(item.NormalisedValue))
.ThenByDescending(item => item.SourceSceneResultID ?? 0)
.FirstOrDefault();
return selected is null
? (fallback, fallbackConfidence)
: (selected.NormalisedValue, selected.Confidence);
}
private static (string Value, decimal Confidence) GuardPresentation(
string displayName,
(string Value, decimal Confidence) selected,
CharacterEvidenceProfile inferred)
{
if (string.Equals(inferred.Presentation, StoryIntelligenceIllustrationCompatibility.Presentations.Unknown, StringComparison.OrdinalIgnoreCase)
|| inferred.PresentationConfidence < 0.9m
|| string.Equals(selected.Value, inferred.Presentation, StringComparison.OrdinalIgnoreCase))
{
return selected;
}
return (inferred.Presentation, Math.Max(selected.Confidence, inferred.PresentationConfidence));
}
private static bool LooksLikeLeakedFamilyAge(string displayName, StoryMemoryCharacterAttribute attribute)
{
if (!string.Equals(attribute.NormalisedValue, StoryIntelligenceIllustrationCompatibility.AgeBands.MatureAdult, StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (NameImpliesAdultRole(displayName))
{
return false;
}
var evidence = attribute.EvidenceSummary ?? string.Empty;
return ContainsAnyLocal(evidence, " mum", "mother", " dad", "father", "aunt", "uncle", "parent");
}
private static bool LooksLikeLeakedChildAge(string displayName, StoryMemoryCharacterAttribute attribute)
{
if (!string.Equals(attribute.NormalisedValue, StoryIntelligenceIllustrationCompatibility.AgeBands.Child, StringComparison.OrdinalIgnoreCase))
{
return false;
}
var evidence = $"{displayName} {attribute.EvidenceSummary}";
if (ContainsAnyLocal(evidence, "toddler", "infant", "newborn", "schoolboy", "schoolgirl", "ten-year-old", "ten year old", "aged 10", "aged 11", "aged 12"))
{
return false;
}
if (ContainsAnyLocal(evidence, "baby of the group", "baby of group"))
{
return true;
}
return !ContainsAnyLocal(evidence, " child", " child ", "young child", " boy", " girl aged", "age 10", "age 11", "age 12");
}
private static bool IsTeenOrAdultAge(string? ageBand)
=> ageBand is StoryIntelligenceIllustrationCompatibility.AgeBands.YoungTeen
or StoryIntelligenceIllustrationCompatibility.AgeBands.OlderTeen
or StoryIntelligenceIllustrationCompatibility.AgeBands.YoungAdult
or StoryIntelligenceIllustrationCompatibility.AgeBands.Adult
or StoryIntelligenceIllustrationCompatibility.AgeBands.MatureAdult
or StoryIntelligenceIllustrationCompatibility.AgeBands.Senior;
private static bool ContainsAnyLocal(string text, params string[] needles)
=> needles.Any(needle => text.Contains(needle, StringComparison.OrdinalIgnoreCase));
private static int AgeSpecificity(string? ageBand)
=> ageBand switch
{
StoryIntelligenceIllustrationCompatibility.AgeBands.Child => 5,
StoryIntelligenceIllustrationCompatibility.AgeBands.YoungTeen => 5,
StoryIntelligenceIllustrationCompatibility.AgeBands.OlderTeen => 5,
StoryIntelligenceIllustrationCompatibility.AgeBands.YoungAdult => 4,
StoryIntelligenceIllustrationCompatibility.AgeBands.MatureAdult => 4,
StoryIntelligenceIllustrationCompatibility.AgeBands.Senior => 4,
StoryIntelligenceIllustrationCompatibility.AgeBands.Adult => 1,
_ => 0
};
private static bool NameImpliesAdultRole(string? displayName)
{
var clean = Normalise(displayName);
return clean is "mum" or "mother" or "dad" or "father" or "aunt" or "uncle"
|| clean.Contains(" mum", StringComparison.Ordinal)
|| clean.Contains(" mother", StringComparison.Ordinal)
|| clean.Contains(" dad", StringComparison.Ordinal)
|| clean.Contains(" father", StringComparison.Ordinal)
|| clean.Contains(" aunt", StringComparison.Ordinal)
|| clean.Contains(" uncle", StringComparison.Ordinal)
|| clean.StartsWith("mrs ", StringComparison.Ordinal)
|| clean.StartsWith("mr ", StringComparison.Ordinal)
|| clean.StartsWith("dr ", StringComparison.Ordinal)
|| clean.StartsWith("professor ", StringComparison.Ordinal);
}
private static IReadOnlyDictionary<string, string> BuildCrossSceneIdentityAliases(IReadOnlyList<SceneResultWithParse> scenes)
{
var aliases = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var ordered = scenes
.Where(item => item.Scene is not null)
.OrderBy(item => item.Run.StoryIntelligenceRunID)
.ThenBy(item => item.Result.TemporarySceneNumber)
.ThenBy(item => item.Result.SceneResultID)
.ToList();
for (var i = 0; i < ordered.Count - 1; i++)
{
var current = ordered[i];
var next = ordered[i + 1];
if (current.Run.StoryIntelligenceRunID != next.Run.StoryIntelligenceRunID
|| current.Scene is null
|| next.Scene is null)
{
continue;
}
var descriptive = (current.Scene.Characters ?? [])
.Where(character => IsDescriptiveRedHairedGirl(character.Name))
.Where(character => character.MentionedOnly != true)
.ToList();
if (descriptive.Count == 0)
{
continue;
}
var revealed = (next.Scene.Characters ?? [])
.Where(character => IsSimpleNamedPerson(character.Name))
.Where(character => CharacterText(character).Contains("returns", StringComparison.OrdinalIgnoreCase)
|| CharacterText(character).Contains("greeting", StringComparison.OrdinalIgnoreCase)
|| CharacterText(character).Contains("introduces", StringComparison.OrdinalIgnoreCase)
|| CharacterText(character).Contains("bar", StringComparison.OrdinalIgnoreCase)
|| CharacterText(character).Contains("friend", StringComparison.OrdinalIgnoreCase))
.OrderByDescending(character => character.Confidence ?? 0)
.FirstOrDefault();
if (revealed?.Name is null)
{
continue;
}
foreach (var character in descriptive)
{
aliases[Normalise(character.Name)] = revealed.Name.Trim();
foreach (var alias in character.Aliases ?? [])
{
if (!string.IsNullOrWhiteSpace(alias))
{
aliases[Normalise(alias)] = revealed.Name.Trim();
}
}
}
}
foreach (var character in ordered.SelectMany(item => item.Scene?.Characters ?? []))
{
var evidence = CharacterText(character);
var localAliases = CharacterAppearanceExtractionService.AliasMap(evidence, KnownNames(character.Name, character.Aliases));
foreach (var alias in localAliases)
{
aliases[alias.Key] = alias.Value;
}
}
return aliases;
}
private static string ResolveIdentityAlias(string? name, IReadOnlyDictionary<string, string> identityAliases)
{
var normalised = Normalise(name);
return normalised.Length > 0 && identityAliases.TryGetValue(normalised, out var resolved)
? resolved
: name ?? string.Empty;
}
private static bool IsDescriptiveRedHairedGirl(string? name)
{
var normalised = Normalise(name);
var clean = normalised.Replace('-', ' ');
return clean.Contains("red haired girl", StringComparison.OrdinalIgnoreCase)
|| clean.Contains("girl in red", StringComparison.OrdinalIgnoreCase);
}
private static bool IsSimpleNamedPerson(string? name)
{
var clean = (name ?? string.Empty).Trim();
if (clean.Length == 0 || !char.IsUpper(clean[0]))
{
return false;
}
var normalised = Normalise(clean);
if (StoryIntelligenceIllustrationCompatibility.IsGroupEntity(clean)
|| normalised.Contains("girl", StringComparison.Ordinal)
|| normalised.Contains("woman", StringComparison.Ordinal)
|| normalised.Contains("man", StringComparison.Ordinal)
|| normalised.Contains("narrator", StringComparison.Ordinal))
{
return false;
}
return normalised.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length <= 2;
}
private static string CanonicalCharacterDisplayName(string? name, IEnumerable<string>? aliases)
{
var clean = (name ?? string.Empty).Trim();
var known = KnownNames(clean, aliases);
var aliasMap = CharacterAppearanceExtractionService.AliasMap(string.Empty, known);
var canonical = CharacterAppearanceExtractionService.CanonicalName(clean, aliasMap);
return string.IsNullOrWhiteSpace(canonical) ? clean : canonical;
}
private static IReadOnlyList<string> KnownNames(string? name, IEnumerable<string>? aliases)
=> (aliases ?? [])
.Prepend(name ?? string.Empty)
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
private static string CharacterText(SceneIntelligenceCharacter character)
=> string.Join(' ', new[]
{
character.Name,
character.RoleInScene,
character.Notes,
string.Join(' ', character.Aliases ?? []),
string.Join(' ', character.Actions ?? [])
}
.Where(value => !string.IsNullOrWhiteSpace(value)));
private (IllustrationLibraryItem? Item, int Score, bool IsCompatible) ScoreCharacter(IllustrationLibraryItem item, CharacterEvidenceProfile evidence, bool isNamed, bool alreadyUsed)
{
var metadata = StoryIntelligenceIllustrationCompatibility.CharacterMetadataFrom(item);
var rejections = StoryIntelligenceIllustrationCompatibility.CharacterHardRejections(evidence, metadata, isNamed, alreadyUsed);
if (rejections.Count > 0)
{
return (item, 0, false);
}
var score = 25;
if (string.Equals(evidence.AgeBand, metadata.AgeBand, StringComparison.OrdinalIgnoreCase)) score += 50;
if (string.Equals(evidence.Presentation, metadata.Presentation, StringComparison.OrdinalIgnoreCase)) score += 100;
if (string.Equals(evidence.HairColour, metadata.HairColour, StringComparison.OrdinalIgnoreCase)) score += 20;
if (string.Equals(evidence.SkinTone, metadata.SkinTone, StringComparison.OrdinalIgnoreCase)) score += 10;
if (item.Status == IllustrationLibraryStatuses.Approved) score += 10;
return (item, score, true);
}
private string? ResolveUploadUrl(IllustrationLibraryItem item)
=> ResolveExistingPublicUploadUrl(item.ThumbnailPath) ?? ResolveExistingPublicUploadUrl(item.FilePath);
private string? ResolveExistingPublicUploadUrl(string? storedPath)
{
var physicalPath = uploadStorage.TryResolvePublicPath(storedPath, "uploads/story-intelligence-library");
return physicalPath is not null && File.Exists(physicalPath) ? storedPath : null;
}
private async Task<DemandResolution> RecordDemandAsync(
int importSessionId,
int projectId,
string entityType,
string archetypeKey,
StoryMemoryResolveOptions options,
IllustrationGenerationSpecification specification,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
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;
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 = maxDemandToQueue;
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);
if (queuedDemandItems >= categoryCap)
{
return new(null, null, "DemandRecorded", $"No compatible illustration exists; demand recorded but {specification.Category.ToLowerInvariant()} in-flight generation 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, StoryMemoryResolveOptions options)
{
var code = DemandStableCode(StoryMemoryEntityTypes.Character, character.CanonicalIdentityKey, evidence.AgeBand, evidence.Presentation, evidence.HairColour, evidence.HairLength, evidence.SkinTone);
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 {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,
Presentation = presentation,
HairColour = hairColour,
HairLength = hairLength,
SkinTone = skinTone,
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, options)
};
}
private static IllustrationGenerationSpecification LocationDemandSpecification(StoryMemoryLocation location, string requestedType, StoryMemoryResolveOptions options)
{
var code = DemandStableCode(StoryMemoryEntityTypes.Location, location.CanonicalIdentityKey, requestedType);
var locationType = UnknownToDefault(requestedType, "UnknownInterior");
var era = UnknownToDefault(options.StoryEra, "1980s");
var storyLocation = UnknownToDefault(options.StoryLocation, "UK");
var subject = LocationVisualSubject(location.DisplayName, locationType, era, storyLocation);
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 = subject,
Composition = LocationComposition(locationType),
Mood = "grounded, atmospheric, legible",
LocationType = locationType,
Era = era,
Setting = location.InteriorExterior ?? InferInteriorExterior(locationType),
Palette = ["clear navy", "warm light", "balanced natural colour"],
MetadataTags = ["location", "story-memory-demand"],
Metadata = DemandMetadata("Location", location.CanonicalIdentityKey, options)
};
}
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");
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, options)
};
}
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",
["libraryPurpose"] = "Live import demand fulfilment",
["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))))}";
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 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");
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))}";
private static string LocationKey(string? name) => $"location-result-{StableKey(name)}";
private static string AssetKey(string? name) => $"asset-result-{StableKey(name)}";
private static string DemandKey(params string[] parts) => StableKey(string.Join("-", parts.Where(part => !string.IsNullOrWhiteSpace(part))));
private static string CanonicalCharacterIdentity(string? name, IEnumerable<string>? aliases = null)
{
var clean = (name ?? string.Empty).Trim();
if (clean.Length == 0) return "unknown";
var lower = clean.ToLowerInvariant();
var aliasList = (aliases ?? [])
.Select(alias => (Raw: alias.Trim(), Normalised: Normalise(alias)))
.Where(alias => alias.Normalised.Length > 0)
.ToList();
var normalised = Normalise(clean);
var words = normalised.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (words.Length == 2
&& !IsTitleWord(words[0])
&& aliasList.Any(alias => string.Equals(alias.Normalised, words[0], StringComparison.OrdinalIgnoreCase)))
{
return words[0];
}
if (words.Length == 2
&& !IsTitleWord(words[0])
&& char.IsUpper(clean[0])
&& clean.All(ch => char.IsLetter(ch) || char.IsWhiteSpace(ch) || ch == '\'' || ch == '\u2019'))
{
return words[0];
}
return lower switch
{
"i" or "me" or "myself" or "narrator" => "pov-character",
"mother" or "mum" or "her mother" or "his mother" => "mother",
"father" or "dad" or "her father" or "his father" => "father",
_ => clean
};
}
private static bool IsTitleWord(string value)
=> value is "mr" or "mrs" or "miss" or "ms" or "dr" or "doctor" or "sir" or "lady";
private static string? ResolveScenePov(SceneIntelligenceScene scene)
{
var raw = (scene.PointOfView?.CharacterName ?? string.Empty).Trim();
if (raw.Length == 0)
{
return null;
}
var normalised = Normalise(raw);
if (normalised is not ("i" or "me" or "my" or "myself" or "narrator" or "first person narrator" or "unnamed narrator"))
{
return raw;
}
var evidence = string.Join(' ', new[]
{
scene.PointOfView?.Evidence,
scene.Summary?.Short,
scene.Summary?.Detailed,
scene.ScenePurpose?.ObservedFunction
}.Where(value => !string.IsNullOrWhiteSpace(value)));
var named = (scene.Characters ?? [])
.Where(character => !string.IsNullOrWhiteSpace(character.Name))
.Select(character => character.Name!.Trim())
.Where(name => Normalise(name) is not ("i" or "me" or "my" or "myself" or "narrator"))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
return named.FirstOrDefault(name => evidence.Contains(name, StringComparison.OrdinalIgnoreCase))
?? (named.Count == 1 ? named[0] : null);
}
private static IEnumerable<string> ExtensionEvidence(Dictionary<string, JsonElement>? extensionData)
{
if (extensionData is null)
{
yield break;
}
foreach (var (key, value) in extensionData)
{
if (!key.Contains("appearance", StringComparison.OrdinalIgnoreCase)
&& !key.Contains("description", StringComparison.OrdinalIgnoreCase)
&& !key.Contains("physical", StringComparison.OrdinalIgnoreCase)
&& !key.Contains("hair", StringComparison.OrdinalIgnoreCase)
&& !key.Contains("skin", StringComparison.OrdinalIgnoreCase)
&& !key.Contains("age", StringComparison.OrdinalIgnoreCase)
&& !key.Contains("gender", StringComparison.OrdinalIgnoreCase)
&& !key.Contains("presentation", StringComparison.OrdinalIgnoreCase))
{
continue;
}
yield return $"{key}: {value}";
}
}
private static string Normalise(string? value)
=> string.Join(' ', (value ?? string.Empty).Trim().ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries));
private static string StableKey(string? value)
{
var normalised = Normalise(value);
if (normalised.Length == 0) return "unknown";
var sb = new StringBuilder(normalised.Length);
foreach (var character in normalised)
{
if (char.IsLetterOrDigit(character)) sb.Append(character);
else if (sb.Length == 0 || sb[^1] != '-') sb.Append('-');
}
return sb.ToString().Trim('-');
}
private static string StableContentHash(string? value)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
private static bool IsNamedCharacter(string? name)
=> !string.IsNullOrWhiteSpace(name)
&& !StoryIntelligenceIllustrationCompatibility.IsGroupEntity(name)
&& char.IsUpper(name.Trim()[0]);
private static int CharacterSignificance(SceneIntelligenceCharacter character)
=> character.MentionedOnly == true ? 25 : string.Equals(character.RoleInScene, "POV", StringComparison.OrdinalIgnoreCase) ? 100 : 60;
private static string LocationVisualSubject(string displayName, string locationType, string era, string storyLocation)
{
var nameHint = string.IsNullOrWhiteSpace(displayName) ? SplitWords(locationType).ToLowerInvariant() : displayName.Trim();
var setting = $"{era} {storyLocation}".Trim();
return locationType switch
{
"Lane" => $"A clear {setting} narrow lane or side road matching '{nameHint}', readable road surface, kerbs, houses or hedges, no prominent people.",
"Street" => $"A clear {setting} side street matching '{nameHint}', readable street layout, pavements and buildings, no prominent people.",
"Road" => $"A clear {setting} road scene matching '{nameHint}', readable carriageway, kerbs, junction markings or suburban road context, no prominent people.",
"Motorway" => $"A clear {setting} motorway or major road scene matching '{nameHint}', multiple lanes, slip road or roundabout context, no prominent people.",
"Flyover" => $"A clear {setting} road under or beside a flyover matching '{nameHint}', concrete overpass structure, road shadows and urban edge detail, no prominent people.",
"Underpass" => $"A clear {setting} road or pedestrian underpass matching '{nameHint}', concrete structure and readable path or carriageway, no prominent people.",
"Bridge" => $"A clear {setting} road bridge scene matching '{nameHint}', bridge structure and readable road context, no prominent people.",
"CarPark" => $"A clear {setting} car park scene matching '{nameHint}', marked bays and parked cars, no prominent people.",
_ => $"A clear, reusable {SplitWords(locationType).ToLowerInvariant()} environment matching '{nameHint}'."
};
}
private static string LocationComposition(string locationType)
=> locationType is "Road" or "Lane" or "Street" or "Motorway" or "Flyover" or "Underpass" or "Bridge"
? "one readable exterior transport environment, road type obvious at thumbnail size, landscape-friendly crop, no text or readable signage"
: "one readable focal environment, no prominent people, cinematic but bright enough for a small interface thumbnail";
private static string InferInteriorExterior(string semanticType)
=> semanticType is "Road" or "Lane" or "Street" or "Avenue" or "Drive" or "Close" or "Motorway" or "Bridge" or "Flyover" or "Underpass" or "CarPark" or "Beach" or "Canal" or "HouseExterior" or "StreetFurniture" ? "Exterior" : "Interior";
private static string SourceSectionType(StoryIntelligenceSavedSceneResult result, SceneIntelligenceScene scene)
{
var text = $"{result.SourceLabel} {scene.Summary?.Short} {scene.ScenePurpose?.ObservedFunction}".ToLowerInvariant();
return text.Contains("author", StringComparison.Ordinal) || text.Contains("acknowledg", StringComparison.Ordinal) || text.Contains("copyright", StringComparison.Ordinal) || text.Contains("preface", StringComparison.Ordinal)
? "Authorial"
: "Story";
}
private sealed record SceneResultWithParse(StoryIntelligenceSavedRun Run, StoryIntelligenceSavedSceneResult Result, SceneIntelligenceScene? Scene, string? ParseError);
}