using System.Security.Cryptography; using System.Text; using System.Text.Json; using PlotLine.Data; using PlotLine.Models; namespace PlotLine.Services; public interface IStoryMemoryService { Task 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, IUploadStorageService uploadStorage, ILogger logger) : IStoryMemoryService { private const string ProcessorVersion = "21S"; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, WriteIndented = false }; public async Task 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); } await memory.SetAppearancePreferenceAsync(importSessionId, options.AppearancePreference); 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!, 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 }, 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, cancellationToken); await memory.MarkSceneResultProcessedAsync(importSessionId, sceneResultId, ProcessorVersion, StableContentHash(scene.Result.ParsedJson)); await ResolveIllustrationsAsync(importSessionId, options, cancellationToken); } public async Task ResolveIllustrationsAsync(int importSessionId, StoryMemoryResolveOptions options, CancellationToken cancellationToken = default) { 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.EntityContext == "Story" && !item.IsGroupEntity)) { cancellationToken.ThrowIfCancellationRequested(); var evidence = CharacterEvidence(character, attributes.Where(item => item.StoryMemoryCharacterID == character.StoryMemoryCharacterID)); 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; } 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)); } 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 { 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)); } } 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 { 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)); } } } private async Task UpsertSceneMemoryAsync(StoryIntelligenceBookPipelineState session, StoryIntelligenceSavedSceneResult result, SceneIntelligenceScene scene, 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 key = CharacterKey(sceneCharacter.Name); var isGroup = StoryIntelligenceIllustrationCompatibility.IsGroupEntity(sceneCharacter.Name); var character = await memory.UpsertCharacterAsync(new StoryMemoryCharacterSave( session.StoryIntelligenceBookPipelineID, session.ProjectID, session.BookID, key, sceneCharacter.Name.Trim(), context, sourceSectionType, IsNamedCharacter(sceneCharacter.Name), isGroup, true, string.Equals(scene.PointOfView?.CharacterName, sceneCharacter.Name, StringComparison.OrdinalIgnoreCase), string.Equals(scene.PointOfView?.CharacterName, sceneCharacter.Name, StringComparison.OrdinalIgnoreCase), 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, sceneCharacter.RoleInScene, sceneCharacter.Notes } .Concat(sceneCharacter.Actions ?? []) .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 inferred = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence(sceneCharacter.Name, identityEvidence, relationshipEvidence); await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "AgeBand", inferred.AgeBand, inferred.AgeConfidence, false, "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.7m, false, "Deterministic evidence inference", result.SceneResultID); await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "SkinTone", inferred.SkinTone, 0.7m, false, "Deterministic evidence inference", result.SceneResultID); await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "HairLength", inferred.HairLength, 0.6m, false, "Deterministic evidence inference", 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 source = await memory.UpsertCharacterAsync(new StoryMemoryCharacterSave(session.StoryIntelligenceBookPipelineID, session.ProjectID, session.BookID, CharacterKey(relationship.CharacterA), relationship.CharacterA.Trim(), context, sourceSectionType, IsNamedCharacter(relationship.CharacterA), StoryIntelligenceIllustrationCompatibility.IsGroupEntity(relationship.CharacterA), true, false, false, 40, result.SceneResultID)); var target = await memory.UpsertCharacterAsync(new StoryMemoryCharacterSave(session.StoryIntelligenceBookPipelineID, session.ProjectID, session.BookID, CharacterKey(relationship.CharacterB), relationship.CharacterB.Trim(), context, sourceSectionType, IsNamedCharacter(relationship.CharacterB), StoryIntelligenceIllustrationCompatibility.IsGroupEntity(relationship.CharacterB), 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> 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(); 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(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(JsonOptions); } } if (!string.IsNullOrWhiteSpace(result.OutputTextJson)) { return JsonSerializer.Deserialize(result.OutputTextJson, JsonOptions); } } catch (JsonException ex) { safeError = $"SceneResultID {result.SceneResultID} could not be parsed: {ex.Message}"; } return null; } private CharacterEvidenceProfile CharacterEvidence(StoryMemoryCharacter character, IEnumerable attributes) { var values = attributes.Select(item => $"{item.AttributeType} {item.NormalisedValue}").ToList(); return StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence(character.DisplayName, values, []); } 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 RecordDemandAsync(int importSessionId, int projectId, string entityType, string archetypeKey, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); await memory.UpsertDemandAsync(new StoryMemoryIllustrationDemandSave(importSessionId, projectId, entityType, archetypeKey, 1, "Recorded", null, null)); } private static string CharacterKey(string? name) => $"character-result-{StableKey(CanonicalCharacterIdentity(name))}"; 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) { var clean = (name ?? string.Empty).Trim(); if (clean.Length == 0) return "unknown"; var lower = clean.ToLowerInvariant(); 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 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 InferInteriorExterior(string semanticType) => semanticType is "Road" 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); }