diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index dd49e61..134b890 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -56,6 +56,9 @@ var tests = new (string Name, Action Test)[] ("Phase 21P uses one authoritative current visualisation scene", Phase21PUsesOneAuthoritativeCurrentVisualisationScene), ("Phase 21Q repairs production visualisation regressions", Phase21QRepairsProductionVisualisationRegressions), ("Phase 21Q view keeps panels bounded and real controls hidden", Phase21QViewKeepsPanelsBoundedAndRealControlsHidden), + ("Phase 21R replay uses persisted data only", Phase21RReplayUsesPersistedDataOnly), + ("Phase 21R scene browser and panels are replay friendly", Phase21RSceneBrowserAndPanelsAreReplayFriendly), + ("Phase 21S uses durable Story Memory assignments", Phase21SUsesDurableStoryMemoryAssignments), ("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions), ("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel), ("Live visualisation strips illustration diagnostics", LiveVisualisationStripsIllustrationDiagnostics), @@ -635,7 +638,7 @@ static void Phase21OPreservesAdultDefaultsAndRealModeContinuity() var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "PlotLine", "Views", "Development", "StoryIntelligenceExperience.cshtml")); Assert(view.Contains("Return to Import", StringComparison.Ordinal), "Real visualisation should expose a return-to-import link."); - Assert(view.Contains("Model.Mode == \"simulation\"", StringComparison.Ordinal), "Prototype controls should be gated to simulation mode."); + Assert(view.Contains("Model.Mode == \"simulation\" || Model.Mode == \"replay\"", StringComparison.Ordinal), "Prototype controls should be gated to simulation and replay modes."); } static void Phase21PUsesOneAuthoritativeCurrentVisualisationScene() @@ -914,14 +917,79 @@ static void Phase21QViewKeepsPanelsBoundedAndRealControlsHidden() Assert(!view.Contains("data-scene-title", StringComparison.Ordinal), "Redundant scene heading should be removed from the real visualisation."); Assert(view.IndexOf("data-knowledge", StringComparison.Ordinal) < view.IndexOf("data-relationships", StringComparison.Ordinal), "Knowledge threads should be prioritised above relationship changes."); - Assert(view.Contains("Model.Mode == \"simulation\"", StringComparison.Ordinal), "Prototype controls must remain simulation-only."); + Assert(view.Contains("Model.Mode == \"simulation\" || Model.Mode == \"replay\"", StringComparison.Ordinal), "Prototype controls must remain hidden in live mode while supporting replay."); Assert(script.Contains("relationships.slice(0, 2)", StringComparison.Ordinal), "Relationship changes should be capped to the two most relevant visible cards."); Assert(script.Contains("renderOverflowSummary", StringComparison.Ordinal), "Relationship overflow should be indicated compactly."); Assert(css.Contains("max-height: calc(100vh - 126px)", StringComparison.Ordinal), "Side columns should be bounded to the viewport."); - Assert(css.Contains("overflow: auto", StringComparison.Ordinal), "Side-panel content should have internal scrolling."); + Assert(css.Contains("overflow-y: auto", StringComparison.Ordinal), "Side columns should keep a single hidden-track vertical scroll."); Assert(css.Contains("z-index: 180", StringComparison.Ordinal), "Character labels should render in a high label layer above nodes and paths."); } +static void Phase21RReplayUsesPersistedDataOnly() +{ + var snapshotService = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs")); + var matcher = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs")); + var development = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Controllers/DevelopmentController.cs")); + + Assert(snapshotService.Contains("BuildReplaySnapshotAsync", StringComparison.Ordinal), "Replay mode needs a dedicated snapshot builder."); + Assert(snapshotService.Contains("Mode = replayMode ? \"replay\" : \"real\"", StringComparison.Ordinal), "Replay snapshots should be explicitly labelled."); + Assert(snapshotService.Contains("ApplyPersistedStoryMemoryAssignmentsAsync(importSessionId, model)", StringComparison.Ordinal), "Replay must use durable Story Memory illustration assignments only."); + Assert(!snapshotService.Contains("await illustrationMatcher.ApplyPersistedCharacterIllustrationsAsync", StringComparison.Ordinal), "Replay should not use the legacy character-only matcher."); + Assert(!snapshotService.Contains("await illustrationMatcher.ApplySupportingIllustrationDemandAsync", StringComparison.Ordinal), "Snapshot rendering must not create location/asset demand."); + Assert(snapshotService.Contains("AiCallsDuringReplay = 0", StringComparison.Ordinal), "Replay diagnostics must state that no AI calls are made."); + Assert(development.Contains("BuildReplaySnapshotAsync(importSessionId.Value, userId)", StringComparison.Ordinal), "Development route must call the replay snapshot builder for mode=replay."); + Assert(development.Contains("RebuildStoryIntelligenceExperience", StringComparison.Ordinal), "Development route should expose a saved-analysis rebuild action."); + Assert(development.Contains("AI calls", StringComparison.Ordinal), "Rebuild action should report that it made no AI calls."); +} + +static void Phase21SUsesDurableStoryMemoryAssignments() +{ + var sql = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Sql/142_Phase21S_DurableStoryMemory.sql")); + var models = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Models/StoryMemoryModels.cs")); + var service = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryMemoryServices.cs")); + var snapshot = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs")); + var runner = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/PersistedStoryIntelligenceRunner.cs")); + var development = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Controllers/DevelopmentController.cs")); + + Assert(sql.Contains("StoryMemoryCharacters", StringComparison.Ordinal), "Durable Story Memory character table is missing."); + Assert(sql.Contains("StoryMemoryLocations", StringComparison.Ordinal), "Durable Story Memory location table is missing."); + Assert(sql.Contains("StoryMemoryAssets", StringComparison.Ordinal), "Durable Story Memory asset table is missing."); + Assert(sql.Contains("StoryMemoryIllustrationAssignments", StringComparison.Ordinal), "Durable illustration assignment table is missing."); + Assert(sql.Contains("StoryMemoryIllustrationDemands", StringComparison.Ordinal), "Durable illustration demand table is missing."); + Assert(models.Contains("StoryMemoryAppearancePreferences", StringComparison.Ordinal), "Import-level appearance preference model is missing."); + Assert(service.Contains("IStoryMemoryService", StringComparison.Ordinal), "Durable Story Memory service is missing."); + Assert(service.Contains("QueueDemand = false", StringComparison.Ordinal) || service.Contains("QueueDemand", StringComparison.Ordinal), "Demand queueing must be explicit."); + Assert(service.Contains("InferCharacterEvidence", StringComparison.Ordinal), "Durable updater must use deterministic character evidence extraction."); + Assert(service.Contains("CharacterHardRejections", StringComparison.Ordinal), "Resolver must revalidate hard character constraints."); + Assert(service.Contains("LocationCompatible", StringComparison.Ordinal), "Resolver must enforce location compatibility."); + Assert(service.Contains("AssetCompatible", StringComparison.Ordinal), "Resolver must enforce asset compatibility."); + Assert(snapshot.Contains("ApplyPersistedStoryMemoryAssignmentsAsync", StringComparison.Ordinal), "Snapshot must read durable persisted assignments."); + Assert(runner.Contains("storyMemory.ProcessSceneResultAsync", StringComparison.Ordinal), "Importer must update Story Memory as scenes are persisted."); + Assert(development.Contains("StoryMemoryDiagnostics", StringComparison.Ordinal), "Development diagnostics endpoint is missing."); + Assert(development.Contains("RebuildImportSessionAsync", StringComparison.Ordinal), "Development rebuild action must call the durable memory rebuild."); +} + +static void Phase21RSceneBrowserAndPanelsAreReplayFriendly() +{ + var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Development/StoryIntelligenceExperience.cshtml")); + var script = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/wwwroot/js/story-intelligence-experience-prototype.js")); + var css = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/wwwroot/css/story-intelligence-experience-prototype.css")); + var progressViewModel = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/ViewModels/OnboardingViewModels.cs")); + + Assert(view.Contains("mode=replay", StringComparison.Ordinal), "Replay links should open the visualisation in replay mode."); + Assert(view.Contains("data-replay-chapter", StringComparison.Ordinal), "Replay scene browser should expose chapter selection."); + Assert(view.Contains("data-detail-panel", StringComparison.Ordinal), "Dense side-panel items should have an expanded detail presentation."); + Assert(view.Contains("Rebuild Visualisation from Saved Analysis", StringComparison.Ordinal), "Replay mode should expose the development rebuild action."); + Assert(script.Contains("const replayMode = root.dataset.mode === \"replay\"", StringComparison.Ordinal), "Browser script should distinguish replay mode."); + Assert(script.Contains("setupReplayBrowser", StringComparison.Ordinal), "Replay browser setup was not found."); + Assert(script.Contains("root.dataset.failedChapters", StringComparison.Ordinal), "Failed chapters should appear as replay browser gaps."); + Assert(script.Contains("if (!realMode || !snapshotUrl || state.terminal) return;", StringComparison.Ordinal), "Replay must not poll live snapshot endpoints."); + Assert(css.Contains(".story-exp-replay-browser", StringComparison.Ordinal), "Replay browser styling was not found."); + Assert(css.Contains("scrollbar-width: none", StringComparison.Ordinal), "Side-panel native scrollbar tracks should be suppressed."); + Assert(!css.Contains(".story-exp-insight-list {\n display: grid;\n gap: 9px;\n min-height: 0;\n overflow: auto", StringComparison.Ordinal), "Nested insight lists should not create native scrollbars."); + Assert(progressViewModel.Contains("public int? ImportSessionID", StringComparison.Ordinal), "Import progress page should carry the import session for replay links."); +} + static JsonSerializerOptions JsonOptions() => new() { diff --git a/PlotLine/Controllers/DevelopmentController.cs b/PlotLine/Controllers/DevelopmentController.cs index 712801a..d9fad1f 100644 --- a/PlotLine/Controllers/DevelopmentController.cs +++ b/PlotLine/Controllers/DevelopmentController.cs @@ -1,5 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using PlotLine.Data; +using PlotLine.Models; using PlotLine.Services; using PlotLine.ViewModels; @@ -11,6 +13,8 @@ public sealed class DevelopmentController( IWebHostEnvironment environment, ICurrentUserService currentUser, IIllustrationLibraryService illustrationLibrary, + IStoryMemoryService storyMemory, + IStoryMemoryRepository storyMemoryRepository, IStoryIntelligenceVisualisationSnapshotService snapshots) : Controller { [HttpGet("StoryIntelligenceExperience")] @@ -23,10 +27,13 @@ public sealed class DevelopmentController( var requestedMode = string.IsNullOrWhiteSpace(mode) ? "real" : mode.Trim(); var simulationRequested = string.Equals(requestedMode, "simulation", StringComparison.OrdinalIgnoreCase); + var replayRequested = string.Equals(requestedMode, "replay", StringComparison.OrdinalIgnoreCase); if (!simulationRequested && importSessionId.HasValue && currentUser.UserId is int userId) { - var realModel = await snapshots.BuildImportSessionSnapshotAsync(importSessionId.Value, userId); + var realModel = replayRequested + ? await snapshots.BuildReplaySnapshotAsync(importSessionId.Value, userId) + : await snapshots.BuildImportSessionSnapshotAsync(importSessionId.Value, userId); if (realModel is null) { return View(ErrorModel( @@ -83,6 +90,69 @@ public sealed class DevelopmentController( return View(ErrorModel(requestedMode, importSessionId, "No development user is signed in.")); } + [HttpPost("StoryIntelligenceExperience/Rebuild")] + [ValidateAntiForgeryToken] + public async Task RebuildStoryIntelligenceExperience( + [FromForm] int importSessionId, + [FromForm] string? rebuildScope, + [FromForm] bool dryRun = false, + [FromForm] bool queueDemand = false, + [FromForm] int? maxDemandToQueue = null, + [FromForm] string? appearancePreference = null) + { + if (!environment.IsDevelopment()) + { + return NotFound(); + } + + if (importSessionId <= 0 || currentUser.UserId is not int userId) + { + return BadRequest("A valid importSessionId is required."); + } + + var rebuild = await storyMemory.RebuildImportSessionAsync( + importSessionId, + userId, + new StoryMemoryRebuildOptions + { + DryRun = dryRun, + RebuildAll = !string.Equals(rebuildScope, "incremental", StringComparison.OrdinalIgnoreCase), + QueueDemand = queueDemand, + MaxDemandToQueue = maxDemandToQueue, + AppearancePreference = string.IsNullOrWhiteSpace(appearancePreference) + ? StoryMemoryAppearancePreferences.Balanced + : appearancePreference.Trim() + }, + HttpContext.RequestAborted); + if (rebuild.DryRun) + { + TempData["StoryIntelligenceReplayMessage"] = $"Dry run complete. {rebuild.SceneResultsRead:N0} stored scene result(s), {rebuild.SceneResultsProcessed:N0} parseable. No database changes, AI calls, or image credits."; + } + else + { + TempData["StoryIntelligenceReplayMessage"] = $"Durable Story Memory rebuilt. {rebuild.SceneResultsProcessed:N0} scene result(s) processed, {rebuild.AssignmentsCreatedOrValidated:N0} assignment(s), {rebuild.DemandsCreated:N0} demand record(s), {rebuild.AiCalls:N0} AI calls."; + } + + return RedirectToAction(nameof(StoryIntelligenceExperience), new { importSessionId, mode = "replay" }); + } + + [HttpGet("StoryMemoryDiagnostics")] + public async Task StoryMemoryDiagnostics([FromQuery] int importSessionId) + { + if (!environment.IsDevelopment()) + { + return NotFound(); + } + + if (importSessionId <= 0) + { + return BadRequest("A valid importSessionId is required."); + } + + var diagnostics = await storyMemoryRepository.GetDiagnosticsAsync(importSessionId); + return Json(diagnostics); + } + [HttpGet("StoryIntelligenceIllustrationDiagnostics")] public async Task StoryIntelligenceIllustrationDiagnostics([FromQuery] int importSessionId) { diff --git a/PlotLine/Data/StoryMemoryRepository.cs b/PlotLine/Data/StoryMemoryRepository.cs new file mode 100644 index 0000000..c0eba59 --- /dev/null +++ b/PlotLine/Data/StoryMemoryRepository.cs @@ -0,0 +1,412 @@ +using Dapper; +using PlotLine.Models; + +namespace PlotLine.Data; + +public interface IStoryMemoryRepository +{ + Task ClearDerivedStateAsync(int importSessionId); + Task> ListProcessedSceneResultIdsAsync(int importSessionId, string processorVersion); + Task MarkSceneResultProcessedAsync(int importSessionId, int sceneResultId, string processorVersion, string snapshotHash); + Task UpsertCharacterAsync(StoryMemoryCharacterSave request); + Task AddCharacterAliasAsync(int characterId, string alias, string normalisedAlias, string reason, int sceneResultId, decimal confidence); + Task AddCharacterAttributeAsync(int characterId, string attributeType, string value, decimal confidence, bool isExplicit, string evidenceSummary, int sceneResultId); + Task UpsertLocationAsync(StoryMemoryLocationSave request); + Task UpsertAssetAsync(StoryMemoryAssetSave request); + Task UpsertRelationshipAsync(int importSessionId, string sourceEntityType, int sourceEntityId, string targetEntityType, int targetEntityId, string relationshipType, decimal confidence, bool isExplicit, int sceneResultId); + Task> ListCharactersAsync(int importSessionId); + Task> ListCharacterAttributesAsync(int importSessionId); + Task> ListLocationsAsync(int importSessionId); + Task> ListAssetsAsync(int importSessionId); + Task> ListAssignmentsAsync(int importSessionId); + Task GetAssignmentAsync(int importSessionId, string entityType, int entityId); + Task UpsertAssignmentAsync(StoryMemoryIllustrationAssignmentSave request); + Task InvalidateAssignmentAsync(int assignmentId, string reason); + Task UpsertDemandAsync(StoryMemoryIllustrationDemandSave request); + Task GetAppearancePreferenceAsync(int importSessionId); + Task SetAppearancePreferenceAsync(int importSessionId, string preference); + Task GetDiagnosticsAsync(int importSessionId); +} + +public sealed class StoryMemoryRepository(ISqlConnectionFactory connectionFactory) : IStoryMemoryRepository +{ + public async Task ClearDerivedStateAsync(int importSessionId) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + """ + DELETE FROM dbo.StoryMemoryIllustrationDemands WHERE ImportSessionID = @ImportSessionID; + DELETE FROM dbo.StoryMemoryIllustrationAssignments WHERE ImportSessionID = @ImportSessionID; + DELETE FROM dbo.StoryMemoryRelationships WHERE ImportSessionID = @ImportSessionID; + DELETE a + FROM dbo.StoryMemoryCharacterAttributes a + INNER JOIN dbo.StoryMemoryCharacters c ON c.StoryMemoryCharacterID = a.StoryMemoryCharacterID + WHERE c.ImportSessionID = @ImportSessionID; + DELETE aliasRows + FROM dbo.StoryMemoryCharacterAliases aliasRows + INNER JOIN dbo.StoryMemoryCharacters c ON c.StoryMemoryCharacterID = aliasRows.StoryMemoryCharacterID + WHERE c.ImportSessionID = @ImportSessionID; + DELETE FROM dbo.StoryMemoryAssets WHERE ImportSessionID = @ImportSessionID; + DELETE FROM dbo.StoryMemoryLocations WHERE ImportSessionID = @ImportSessionID; + DELETE FROM dbo.StoryMemoryCharacters WHERE ImportSessionID = @ImportSessionID; + DELETE FROM dbo.StoryMemoryProcessedSceneResults WHERE ImportSessionID = @ImportSessionID; + """, + new { ImportSessionID = importSessionId }); + } + + public async Task> ListProcessedSceneResultIdsAsync(int importSessionId, string processorVersion) + { + using var connection = connectionFactory.CreateConnection(); + var ids = await connection.QueryAsync( + """ + SELECT SceneResultID + FROM dbo.StoryMemoryProcessedSceneResults + WHERE ImportSessionID = @ImportSessionID + AND ProcessorVersion = @ProcessorVersion; + """, + new { ImportSessionID = importSessionId, ProcessorVersion = processorVersion }); + return ids.ToHashSet(); + } + + public async Task MarkSceneResultProcessedAsync(int importSessionId, int sceneResultId, string processorVersion, string snapshotHash) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + """ + MERGE dbo.StoryMemoryProcessedSceneResults WITH (HOLDLOCK) AS target + USING (SELECT @ImportSessionID AS ImportSessionID, @SceneResultID AS SceneResultID, @ProcessorVersion AS ProcessorVersion) AS source + ON target.ImportSessionID = source.ImportSessionID + AND target.SceneResultID = source.SceneResultID + AND target.ProcessorVersion = source.ProcessorVersion + WHEN MATCHED THEN + UPDATE SET SnapshotHash = @SnapshotHash, ProcessedUtc = SYSUTCDATETIME() + WHEN NOT MATCHED THEN + INSERT (ImportSessionID, SceneResultID, ProcessorVersion, SnapshotHash) + VALUES (@ImportSessionID, @SceneResultID, @ProcessorVersion, @SnapshotHash); + """, + new { ImportSessionID = importSessionId, SceneResultID = sceneResultId, ProcessorVersion = processorVersion, SnapshotHash = snapshotHash }); + } + + public async Task UpsertCharacterAsync(StoryMemoryCharacterSave request) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + """ + MERGE dbo.StoryMemoryCharacters WITH (HOLDLOCK) AS target + USING (SELECT @ImportSessionID AS ImportSessionID, @CanonicalIdentityKey AS CanonicalIdentityKey) AS source + ON target.ImportSessionID = source.ImportSessionID + AND target.CanonicalIdentityKey = source.CanonicalIdentityKey + WHEN MATCHED THEN + UPDATE SET + DisplayName = CASE WHEN LEN(@DisplayName) > LEN(target.DisplayName) THEN @DisplayName ELSE target.DisplayName END, + EntityContext = @EntityContext, + SourceSectionType = @SourceSectionType, + IsNamed = CASE WHEN @IsNamed = 1 THEN 1 ELSE target.IsNamed END, + IsGroupEntity = CASE WHEN @IsGroupEntity = 1 THEN 1 ELSE target.IsGroupEntity END, + IsResolved = CASE WHEN @IsResolved = 1 THEN 1 ELSE target.IsResolved END, + IsNarrator = CASE WHEN @IsNarrator = 1 THEN 1 ELSE target.IsNarrator END, + IsPrimaryPOV = CASE WHEN @IsPrimaryPOV = 1 THEN 1 ELSE target.IsPrimaryPOV END, + Significance = CASE WHEN @Significance > target.Significance THEN @Significance ELSE target.Significance END, + LastUpdatedSceneResultID = @SceneResultID, + EvidenceVersion = target.EvidenceVersion + 1, + UpdatedUtc = SYSUTCDATETIME() + WHEN NOT MATCHED THEN + INSERT (ImportSessionID, ProjectID, BookID, CanonicalIdentityKey, DisplayName, EntityContext, SourceSectionType, IsNamed, IsGroupEntity, IsResolved, IsNarrator, IsPrimaryPOV, Significance, CreatedSceneResultID, LastUpdatedSceneResultID) + VALUES (@ImportSessionID, @ProjectID, @BookID, @CanonicalIdentityKey, @DisplayName, @EntityContext, @SourceSectionType, @IsNamed, @IsGroupEntity, @IsResolved, @IsNarrator, @IsPrimaryPOV, @Significance, @SceneResultID, @SceneResultID); + + SELECT * + FROM dbo.StoryMemoryCharacters + WHERE ImportSessionID = @ImportSessionID + AND CanonicalIdentityKey = @CanonicalIdentityKey; + """, + request); + } + + public async Task AddCharacterAliasAsync(int characterId, string alias, string normalisedAlias, string reason, int sceneResultId, decimal confidence) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + """ + MERGE dbo.StoryMemoryCharacterAliases WITH (HOLDLOCK) AS target + USING (SELECT @StoryMemoryCharacterID AS StoryMemoryCharacterID, @NormalisedAlias AS NormalisedAlias, @SourceSceneResultID AS SourceSceneResultID) AS source + ON target.StoryMemoryCharacterID = source.StoryMemoryCharacterID + AND target.NormalisedAlias = source.NormalisedAlias + AND ISNULL(target.SourceSceneResultID, -1) = ISNULL(source.SourceSceneResultID, -1) + WHEN NOT MATCHED THEN + INSERT (StoryMemoryCharacterID, Alias, NormalisedAlias, ResolutionReason, SourceSceneResultID, Confidence) + VALUES (@StoryMemoryCharacterID, @Alias, @NormalisedAlias, @ResolutionReason, @SourceSceneResultID, @Confidence); + """, + new { StoryMemoryCharacterID = characterId, Alias = alias, NormalisedAlias = normalisedAlias, ResolutionReason = reason, SourceSceneResultID = sceneResultId, Confidence = confidence }); + } + + public async Task AddCharacterAttributeAsync(int characterId, string attributeType, string value, decimal confidence, bool isExplicit, string evidenceSummary, int sceneResultId) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + """ + MERGE dbo.StoryMemoryCharacterAttributes WITH (HOLDLOCK) AS target + USING (SELECT @StoryMemoryCharacterID AS StoryMemoryCharacterID, @AttributeType AS AttributeType, @NormalisedValue AS NormalisedValue, @SourceSceneResultID AS SourceSceneResultID) AS source + ON target.StoryMemoryCharacterID = source.StoryMemoryCharacterID + AND target.AttributeType = source.AttributeType + AND target.NormalisedValue = source.NormalisedValue + AND ISNULL(target.SourceSceneResultID, -1) = ISNULL(source.SourceSceneResultID, -1) + AND target.SupersededUtc IS NULL + WHEN MATCHED THEN + UPDATE SET Confidence = CASE WHEN @Confidence > target.Confidence THEN @Confidence ELSE target.Confidence END, + IsExplicit = CASE WHEN @IsExplicit = 1 THEN 1 ELSE target.IsExplicit END, + EvidenceSummary = @EvidenceSummary + WHEN NOT MATCHED THEN + INSERT (StoryMemoryCharacterID, AttributeType, NormalisedValue, Confidence, IsExplicit, EvidenceSummary, SourceSceneResultID) + VALUES (@StoryMemoryCharacterID, @AttributeType, @NormalisedValue, @Confidence, @IsExplicit, @EvidenceSummary, @SourceSceneResultID); + """, + new { StoryMemoryCharacterID = characterId, AttributeType = attributeType, NormalisedValue = value, Confidence = confidence, IsExplicit = isExplicit, EvidenceSummary = evidenceSummary, SourceSceneResultID = sceneResultId }); + } + + public async Task UpsertLocationAsync(StoryMemoryLocationSave request) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + """ + MERGE dbo.StoryMemoryLocations WITH (HOLDLOCK) AS target + USING (SELECT @ImportSessionID AS ImportSessionID, @CanonicalIdentityKey AS CanonicalIdentityKey) AS source + ON target.ImportSessionID = source.ImportSessionID + AND target.CanonicalIdentityKey = source.CanonicalIdentityKey + WHEN MATCHED THEN + UPDATE SET DisplayName = @DisplayName, + SemanticType = @SemanticType, + Subtype = @Subtype, + InteriorExterior = @InteriorExterior, + LastSeenSceneResultID = @SceneResultID, + UpdatedUtc = SYSUTCDATETIME() + WHEN NOT MATCHED THEN + INSERT (ImportSessionID, ProjectID, BookID, CanonicalIdentityKey, DisplayName, SemanticType, Subtype, InteriorExterior, FirstSeenSceneResultID, LastSeenSceneResultID) + VALUES (@ImportSessionID, @ProjectID, @BookID, @CanonicalIdentityKey, @DisplayName, @SemanticType, @Subtype, @InteriorExterior, @SceneResultID, @SceneResultID); + + SELECT * + FROM dbo.StoryMemoryLocations + WHERE ImportSessionID = @ImportSessionID AND CanonicalIdentityKey = @CanonicalIdentityKey; + """, + request); + } + + public async Task UpsertAssetAsync(StoryMemoryAssetSave request) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + """ + MERGE dbo.StoryMemoryAssets WITH (HOLDLOCK) AS target + USING (SELECT @ImportSessionID AS ImportSessionID, @CanonicalIdentityKey AS CanonicalIdentityKey) AS source + ON target.ImportSessionID = source.ImportSessionID + AND target.CanonicalIdentityKey = source.CanonicalIdentityKey + WHEN MATCHED THEN + UPDATE SET DisplayName = @DisplayName, + SemanticType = @SemanticType, + Subtype = @Subtype, + Colour = @Colour, + Era = @Era, + LastSeenSceneResultID = @SceneResultID, + UpdatedUtc = SYSUTCDATETIME() + WHEN NOT MATCHED THEN + INSERT (ImportSessionID, ProjectID, BookID, CanonicalIdentityKey, DisplayName, SemanticType, Subtype, Colour, Era, FirstSeenSceneResultID, LastSeenSceneResultID) + VALUES (@ImportSessionID, @ProjectID, @BookID, @CanonicalIdentityKey, @DisplayName, @SemanticType, @Subtype, @Colour, @Era, @SceneResultID, @SceneResultID); + + SELECT * + FROM dbo.StoryMemoryAssets + WHERE ImportSessionID = @ImportSessionID AND CanonicalIdentityKey = @CanonicalIdentityKey; + """, + request); + } + + public async Task UpsertRelationshipAsync(int importSessionId, string sourceEntityType, int sourceEntityId, string targetEntityType, int targetEntityId, string relationshipType, decimal confidence, bool isExplicit, int sceneResultId) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + """ + MERGE dbo.StoryMemoryRelationships WITH (HOLDLOCK) AS target + USING (SELECT @ImportSessionID AS ImportSessionID, @SourceEntityType AS SourceEntityType, @SourceEntityID AS SourceEntityID, @TargetEntityType AS TargetEntityType, @TargetEntityID AS TargetEntityID, @RelationshipType AS RelationshipType) AS source + ON target.ImportSessionID = source.ImportSessionID + AND target.SourceEntityType = source.SourceEntityType + AND target.SourceEntityID = source.SourceEntityID + AND target.TargetEntityType = source.TargetEntityType + AND target.TargetEntityID = source.TargetEntityID + AND target.RelationshipType = source.RelationshipType + AND target.Active = 1 + WHEN MATCHED THEN + UPDATE SET Confidence = CASE WHEN @Confidence > target.Confidence THEN @Confidence ELSE target.Confidence END, + IsExplicit = CASE WHEN @IsExplicit = 1 THEN 1 ELSE target.IsExplicit END, + LastUpdatedSceneResultID = @SceneResultID, + UpdatedUtc = SYSUTCDATETIME() + WHEN NOT MATCHED THEN + INSERT (ImportSessionID, SourceEntityType, SourceEntityID, TargetEntityType, TargetEntityID, RelationshipType, Confidence, IsExplicit, FirstSeenSceneResultID, LastUpdatedSceneResultID) + VALUES (@ImportSessionID, @SourceEntityType, @SourceEntityID, @TargetEntityType, @TargetEntityID, @RelationshipType, @Confidence, @IsExplicit, @SceneResultID, @SceneResultID); + """, + new { ImportSessionID = importSessionId, SourceEntityType = sourceEntityType, SourceEntityID = sourceEntityId, TargetEntityType = targetEntityType, TargetEntityID = targetEntityId, RelationshipType = relationshipType, Confidence = confidence, IsExplicit = isExplicit, SceneResultID = sceneResultId }); + } + + public async Task> ListCharactersAsync(int importSessionId) + { + using var connection = connectionFactory.CreateConnection(); + return (await connection.QueryAsync("SELECT * FROM dbo.StoryMemoryCharacters WHERE ImportSessionID = @ImportSessionID ORDER BY Significance DESC, DisplayName;", new { ImportSessionID = importSessionId })).ToList(); + } + + public async Task> ListCharacterAttributesAsync(int importSessionId) + { + using var connection = connectionFactory.CreateConnection(); + return (await connection.QueryAsync( + """ + SELECT a.* + FROM dbo.StoryMemoryCharacterAttributes a + INNER JOIN dbo.StoryMemoryCharacters c ON c.StoryMemoryCharacterID = a.StoryMemoryCharacterID + WHERE c.ImportSessionID = @ImportSessionID + AND a.SupersededUtc IS NULL; + """, + new { ImportSessionID = importSessionId })).ToList(); + } + + public async Task> ListLocationsAsync(int importSessionId) + { + using var connection = connectionFactory.CreateConnection(); + return (await connection.QueryAsync("SELECT * FROM dbo.StoryMemoryLocations WHERE ImportSessionID = @ImportSessionID ORDER BY DisplayName;", new { ImportSessionID = importSessionId })).ToList(); + } + + public async Task> ListAssetsAsync(int importSessionId) + { + using var connection = connectionFactory.CreateConnection(); + return (await connection.QueryAsync("SELECT * FROM dbo.StoryMemoryAssets WHERE ImportSessionID = @ImportSessionID ORDER BY DisplayName;", new { ImportSessionID = importSessionId })).ToList(); + } + + public async Task> ListAssignmentsAsync(int importSessionId) + { + using var connection = connectionFactory.CreateConnection(); + return (await connection.QueryAsync("SELECT * FROM dbo.StoryMemoryIllustrationAssignments WHERE ImportSessionID = @ImportSessionID AND InvalidatedUtc IS NULL;", new { ImportSessionID = importSessionId })).ToList(); + } + + public async Task GetAssignmentAsync(int importSessionId, string entityType, int entityId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "SELECT * FROM dbo.StoryMemoryIllustrationAssignments WHERE ImportSessionID = @ImportSessionID AND EntityType = @EntityType AND EntityID = @EntityID AND InvalidatedUtc IS NULL;", + new { ImportSessionID = importSessionId, EntityType = entityType, EntityID = entityId }); + } + + public async Task UpsertAssignmentAsync(StoryMemoryIllustrationAssignmentSave request) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + """ + MERGE dbo.StoryMemoryIllustrationAssignments WITH (HOLDLOCK) AS target + USING (SELECT @ImportSessionID AS ImportSessionID, @EntityType AS EntityType, @EntityID AS EntityID) AS source + ON target.ImportSessionID = source.ImportSessionID + AND target.EntityType = source.EntityType + AND target.EntityID = source.EntityID + AND target.InvalidatedUtc IS NULL + WHEN MATCHED THEN + UPDATE SET IllustrationLibraryItemID = @IllustrationLibraryItemID, + StableCode = @StableCode, + AssignmentStatus = @AssignmentStatus, + IsFallback = @IsFallback, + MatchScore = @MatchScore, + Confidence = @Confidence, + AssignmentReason = @AssignmentReason, + EvidenceVersion = @EvidenceVersion, + LastValidatedUtc = SYSUTCDATETIME(), + UpdatedUtc = SYSUTCDATETIME() + WHEN NOT MATCHED THEN + INSERT (ImportSessionID, EntityType, EntityID, IllustrationLibraryItemID, StableCode, AssignmentStatus, IsFallback, MatchScore, Confidence, AssignmentReason, EvidenceVersion) + VALUES (@ImportSessionID, @EntityType, @EntityID, @IllustrationLibraryItemID, @StableCode, @AssignmentStatus, @IsFallback, @MatchScore, @Confidence, @AssignmentReason, @EvidenceVersion); + """, + request); + } + + public async Task InvalidateAssignmentAsync(int assignmentId, string reason) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + """ + UPDATE dbo.StoryMemoryIllustrationAssignments + SET InvalidatedUtc = SYSUTCDATETIME(), + InvalidationReason = @Reason, + UpdatedUtc = SYSUTCDATETIME() + WHERE StoryMemoryIllustrationAssignmentID = @AssignmentID + AND InvalidatedUtc IS NULL; + """, + new { AssignmentID = assignmentId, Reason = reason }); + } + + public async Task UpsertDemandAsync(StoryMemoryIllustrationDemandSave request) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + """ + MERGE dbo.StoryMemoryIllustrationDemands WITH (HOLDLOCK) AS target + USING (SELECT @ImportSessionID AS ImportSessionID, @EntityType AS EntityType, @ArchetypeKey AS ArchetypeKey) AS source + ON target.ImportSessionID = source.ImportSessionID + AND target.EntityType = source.EntityType + AND target.ArchetypeKey = source.ArchetypeKey + WHEN MATCHED THEN + UPDATE SET DemandCount = DemandCount + 1, + WaitingEntityCount = @WaitingEntityCount, + Status = @Status, + QueuedIllustrationLibraryItemID = COALESCE(@QueuedIllustrationLibraryItemID, target.QueuedIllustrationLibraryItemID), + GeneratedIllustrationLibraryItemID = COALESCE(@GeneratedIllustrationLibraryItemID, target.GeneratedIllustrationLibraryItemID), + UpdatedUtc = SYSUTCDATETIME() + WHEN NOT MATCHED THEN + INSERT (ImportSessionID, ProjectID, EntityType, ArchetypeKey, WaitingEntityCount, Status, QueuedIllustrationLibraryItemID, GeneratedIllustrationLibraryItemID) + VALUES (@ImportSessionID, @ProjectID, @EntityType, @ArchetypeKey, @WaitingEntityCount, @Status, @QueuedIllustrationLibraryItemID, @GeneratedIllustrationLibraryItemID); + + SELECT * + FROM dbo.StoryMemoryIllustrationDemands + WHERE ImportSessionID = @ImportSessionID AND EntityType = @EntityType AND ArchetypeKey = @ArchetypeKey; + """, + request); + } + + public async Task GetAppearancePreferenceAsync(int importSessionId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "SELECT AppearancePreference FROM dbo.StoryMemoryImportPreferences WHERE ImportSessionID = @ImportSessionID;", + new { ImportSessionID = importSessionId }) + ?? StoryMemoryAppearancePreferences.Balanced; + } + + public async Task SetAppearancePreferenceAsync(int importSessionId, string preference) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + """ + MERGE dbo.StoryMemoryImportPreferences WITH (HOLDLOCK) AS target + USING (SELECT @ImportSessionID AS ImportSessionID) AS source + ON target.ImportSessionID = source.ImportSessionID + WHEN MATCHED THEN + UPDATE SET AppearancePreference = @AppearancePreference, UpdatedUtc = SYSUTCDATETIME() + WHEN NOT MATCHED THEN + INSERT (ImportSessionID, AppearancePreference) + VALUES (@ImportSessionID, @AppearancePreference); + """, + new { ImportSessionID = importSessionId, AppearancePreference = preference }); + } + + public async Task GetDiagnosticsAsync(int importSessionId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + """ + SELECT + @ImportSessionID AS ImportSessionID, + (SELECT COUNT(*) FROM dbo.StoryMemoryCharacters WHERE ImportSessionID = @ImportSessionID) AS CharacterCount, + (SELECT COUNT(*) FROM dbo.StoryMemoryLocations WHERE ImportSessionID = @ImportSessionID) AS LocationCount, + (SELECT COUNT(*) FROM dbo.StoryMemoryAssets WHERE ImportSessionID = @ImportSessionID) AS AssetCount, + (SELECT COUNT(*) FROM dbo.StoryMemoryCharacterAliases a INNER JOIN dbo.StoryMemoryCharacters c ON c.StoryMemoryCharacterID = a.StoryMemoryCharacterID WHERE c.ImportSessionID = @ImportSessionID) AS AliasCount, + (SELECT COUNT(*) FROM dbo.StoryMemoryCharacterAttributes a INNER JOIN dbo.StoryMemoryCharacters c ON c.StoryMemoryCharacterID = a.StoryMemoryCharacterID WHERE c.ImportSessionID = @ImportSessionID AND a.SupersededUtc IS NULL) AS AttributeCount, + (SELECT COUNT(*) FROM dbo.StoryMemoryRelationships WHERE ImportSessionID = @ImportSessionID AND Active = 1) AS RelationshipCount, + (SELECT COUNT(*) FROM dbo.StoryMemoryIllustrationAssignments WHERE ImportSessionID = @ImportSessionID AND InvalidatedUtc IS NULL) AS AssignmentCount, + (SELECT COUNT(*) FROM dbo.StoryMemoryIllustrationAssignments WHERE ImportSessionID = @ImportSessionID AND InvalidatedUtc IS NULL AND IsFallback = 1) AS FallbackCount, + (SELECT COUNT(*) FROM dbo.StoryMemoryIllustrationDemands WHERE ImportSessionID = @ImportSessionID) AS DemandCount, + (SELECT COUNT(*) FROM dbo.StoryMemoryProcessedSceneResults WHERE ImportSessionID = @ImportSessionID) AS ProcessedSceneCount, + (SELECT MAX(SceneResultID) FROM dbo.StoryMemoryProcessedSceneResults WHERE ImportSessionID = @ImportSessionID) AS LastProcessedSceneResultID, + COALESCE((SELECT AppearancePreference FROM dbo.StoryMemoryImportPreferences WHERE ImportSessionID = @ImportSessionID), N'Balanced') AS AppearancePreference; + """, + new { ImportSessionID = importSessionId }); + } +} diff --git a/PlotLine/Models/StoryMemoryModels.cs b/PlotLine/Models/StoryMemoryModels.cs new file mode 100644 index 0000000..3d977b1 --- /dev/null +++ b/PlotLine/Models/StoryMemoryModels.cs @@ -0,0 +1,231 @@ +namespace PlotLine.Models; + +public static class StoryMemoryEntityTypes +{ + public const string Character = "Character"; + public const string Location = "Location"; + public const string Asset = "Asset"; +} + +public static class StoryMemoryAppearancePreferences +{ + public const string ExplicitEvidenceOnly = "ExplicitEvidenceOnly"; + public const string Balanced = "Balanced"; + public const string PredominantlyLight = "PredominantlyLight"; + public const string PredominantlyMedium = "PredominantlyMedium"; + public const string PredominantlyDark = "PredominantlyDark"; + public const string CustomMix = "CustomMix"; +} + +public sealed class StoryMemoryCharacter +{ + public int StoryMemoryCharacterID { get; init; } + public int ImportSessionID { get; init; } + public int ProjectID { get; init; } + public int BookID { get; init; } + public string CanonicalIdentityKey { get; init; } = string.Empty; + public string DisplayName { get; init; } = string.Empty; + public string EntityContext { get; init; } = "Story"; + public string SourceSectionType { get; init; } = "Story"; + public bool IsNamed { get; init; } + public bool IsGroupEntity { get; init; } + public bool IsResolved { get; init; } + public bool IsNarrator { get; init; } + public bool IsPrimaryPOV { get; init; } + public int Significance { get; init; } + public int EvidenceVersion { get; init; } + public int? CreatedSceneResultID { get; init; } + public int? LastUpdatedSceneResultID { get; init; } + public DateTime CreatedUtc { get; init; } + public DateTime UpdatedUtc { get; init; } +} + +public sealed record StoryMemoryCharacterSave( + int ImportSessionID, + int ProjectID, + int BookID, + string CanonicalIdentityKey, + string DisplayName, + string EntityContext, + string SourceSectionType, + bool IsNamed, + bool IsGroupEntity, + bool IsResolved, + bool IsNarrator, + bool IsPrimaryPOV, + int Significance, + int SceneResultID); + +public sealed class StoryMemoryCharacterAttribute +{ + public int StoryMemoryCharacterAttributeID { get; init; } + public int StoryMemoryCharacterID { get; init; } + public string AttributeType { get; init; } = string.Empty; + public string NormalisedValue { get; init; } = string.Empty; + public decimal Confidence { get; init; } + public bool IsExplicit { get; init; } + public string? EvidenceSummary { get; init; } + public int? SourceSceneResultID { get; init; } +} + +public sealed class StoryMemoryLocation +{ + public int StoryMemoryLocationID { get; init; } + public int ImportSessionID { get; init; } + public int ProjectID { get; init; } + public int BookID { get; init; } + public string CanonicalIdentityKey { get; init; } = string.Empty; + public string DisplayName { get; init; } = string.Empty; + public string SemanticType { get; init; } = string.Empty; + public string? Subtype { get; init; } + public string? InteriorExterior { get; init; } + public int? ParentLocationID { get; init; } + public int? FirstSeenSceneResultID { get; init; } + public int? LastSeenSceneResultID { get; init; } +} + +public sealed record StoryMemoryLocationSave( + int ImportSessionID, + int ProjectID, + int BookID, + string CanonicalIdentityKey, + string DisplayName, + string SemanticType, + string? Subtype, + string? InteriorExterior, + int SceneResultID); + +public sealed class StoryMemoryAsset +{ + public int StoryMemoryAssetID { get; init; } + public int ImportSessionID { get; init; } + public int ProjectID { get; init; } + public int BookID { get; init; } + public string CanonicalIdentityKey { get; init; } = string.Empty; + public string DisplayName { get; init; } = string.Empty; + public string SemanticType { get; init; } = string.Empty; + public string? Subtype { get; init; } + public string? Colour { get; init; } + public string? Era { get; init; } + public int? FirstSeenSceneResultID { get; init; } + public int? LastSeenSceneResultID { get; init; } +} + +public sealed record StoryMemoryAssetSave( + int ImportSessionID, + int ProjectID, + int BookID, + string CanonicalIdentityKey, + string DisplayName, + string SemanticType, + string? Subtype, + string? Colour, + string? Era, + int SceneResultID); + +public sealed class StoryMemoryIllustrationAssignment +{ + public int StoryMemoryIllustrationAssignmentID { get; init; } + public int ImportSessionID { get; init; } + public string EntityType { get; init; } = string.Empty; + public int EntityID { get; init; } + public int? IllustrationLibraryItemID { get; init; } + public string? StableCode { get; init; } + public string AssignmentStatus { get; init; } = string.Empty; + public bool IsFallback { get; init; } + public int MatchScore { get; init; } + public decimal Confidence { get; init; } + public string? AssignmentReason { get; init; } + public string EvidenceVersion { get; init; } = "21S"; + public DateTime AssignedUtc { get; init; } + public DateTime LastValidatedUtc { get; init; } + public DateTime? InvalidatedUtc { get; init; } + public string? InvalidationReason { get; init; } +} + +public sealed record StoryMemoryIllustrationAssignmentSave( + int ImportSessionID, + string EntityType, + int EntityID, + int? IllustrationLibraryItemID, + string? StableCode, + string AssignmentStatus, + bool IsFallback, + int MatchScore, + decimal Confidence, + string AssignmentReason, + string EvidenceVersion); + +public sealed class StoryMemoryIllustrationDemand +{ + public int StoryMemoryIllustrationDemandID { get; init; } + public int ImportSessionID { get; init; } + public int ProjectID { get; init; } + public string EntityType { get; init; } = string.Empty; + public string ArchetypeKey { get; init; } = string.Empty; + public int DemandCount { get; init; } + public int WaitingEntityCount { get; init; } + public string Status { get; init; } = string.Empty; + public int? QueuedIllustrationLibraryItemID { get; init; } + public int? GeneratedIllustrationLibraryItemID { get; init; } +} + +public sealed record StoryMemoryIllustrationDemandSave( + int ImportSessionID, + int ProjectID, + string EntityType, + string ArchetypeKey, + int WaitingEntityCount, + string Status, + int? QueuedIllustrationLibraryItemID, + int? GeneratedIllustrationLibraryItemID); + +public sealed class StoryMemoryDiagnostics +{ + public int ImportSessionID { get; init; } + public int CharacterCount { get; init; } + public int LocationCount { get; init; } + public int AssetCount { get; init; } + public int AliasCount { get; init; } + public int AttributeCount { get; init; } + public int RelationshipCount { get; init; } + public int AssignmentCount { get; init; } + public int FallbackCount { get; init; } + public int DemandCount { get; init; } + public int ProcessedSceneCount { get; init; } + public int? LastProcessedSceneResultID { get; init; } + public string AppearancePreference { get; init; } = StoryMemoryAppearancePreferences.Balanced; +} + +public sealed class StoryMemoryRebuildResult +{ + public int ImportSessionID { get; init; } + public bool DryRun { get; init; } + public int SceneResultsRead { get; init; } + public int SceneResultsProcessed { get; init; } + public int CharactersDiscovered { get; init; } + public int LocationsDiscovered { get; init; } + public int AssetsDiscovered { get; init; } + public int RelationshipsDiscovered { get; init; } + public int AssignmentsCreatedOrValidated { get; init; } + public int DemandsCreated { get; init; } + public int FallbacksRemaining { get; init; } + public int AiCalls { get; init; } + public IReadOnlyList Warnings { get; init; } = []; +} + +public sealed class StoryMemoryRebuildOptions +{ + public bool DryRun { get; init; } + public bool RebuildAll { get; init; } + public bool QueueDemand { get; init; } + public int? MaxDemandToQueue { get; init; } + public string AppearancePreference { get; init; } = StoryMemoryAppearancePreferences.Balanced; +} + +public sealed class StoryMemoryResolveOptions +{ + public bool QueueDemand { get; init; } + public int? MaxDemandToQueue { get; init; } + public string AppearancePreference { get; init; } = StoryMemoryAppearancePreferences.Balanced; +} diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index 0724357..ba1f5f6 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -149,6 +149,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -219,6 +220,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/PlotLine/Services/PersistedStoryIntelligenceRunner.cs b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs index c1c69f2..cfd9910 100644 --- a/PlotLine/Services/PersistedStoryIntelligenceRunner.cs +++ b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs @@ -23,6 +23,7 @@ public sealed class PersistedStoryIntelligenceRunner( IOptions options, IOptions pricingOptions, IStoryIntelligenceProgressNotifier notifier, + IStoryMemoryService storyMemory, ILogger logger) : IPersistedStoryIntelligenceRunner { private const string ChapterPromptFile = "Chapter-Structure-Prompt-V2.md"; @@ -338,6 +339,22 @@ public sealed class PersistedStoryIntelligenceRunner( run.ChapterNumber, block.TemporarySceneNumber, sceneResultId); + if (importSessionId.HasValue) + { + try + { + await storyMemory.ProcessSceneResultAsync( + importSessionId.Value, + run.UserID, + sceneResultId, + new StoryMemoryResolveOptions { QueueDemand = false }, + cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException && !IsFatal(ex)) + { + logger.LogError(ex, "Story Memory processing failed for import session {ImportSessionId}, scene result {SceneResultId}.", importSessionId, sceneResultId); + } + } completedScenes++; await PublishAsync( diff --git a/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs b/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs index 56999b9..3adc998 100644 --- a/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs +++ b/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs @@ -12,6 +12,7 @@ public interface IStoryIntelligenceVisualisationSnapshotService { Task BuildSnapshotAsync(int runId, int userId); Task BuildImportSessionSnapshotAsync(int importSessionId, int userId); + Task BuildReplaySnapshotAsync(int importSessionId, int userId); Task> ListRunOptionsAsync(int userId); Task> ListImportSessionOptionsAsync(int userId); } @@ -21,7 +22,9 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( IStoryIntelligencePipelineRepository pipelines, IStoryIntelligenceSourceRepository sources, IIllustrationLibraryService illustrationLibrary, - IStoryIntelligenceIllustrationMatchingService illustrationMatcher, + IStoryMemoryRepository storyMemoryRepository, + IIllustrationLibraryRepository illustrationRepository, + IUploadStorageService uploadStorage, ILogger logger) : IStoryIntelligenceVisualisationSnapshotService { private const string FallbackRoot = "/images/story-intelligence/prototype"; @@ -144,18 +147,19 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( StabiliseCharacterIllustrations(model); await illustrationLibrary.ApplyApprovedIllustrationsAsync(model); - if (run.ProjectID.HasValue) - { - await illustrationMatcher.ApplyCharacterIllustrationsAsync(run.ProjectID.Value, model); - await illustrationMatcher.ApplySupportingIllustrationDemandAsync(run.ProjectID.Value, model); - ApplyIllustrationDiagnostics(model); - } + ApplyIllustrationDiagnostics(model); StripLiveDiagnostics(model); LogPayload(model, $"run {runId}", stopwatch.ElapsedMilliseconds); return model; } public async Task BuildImportSessionSnapshotAsync(int importSessionId, int userId) + => await BuildImportSessionSnapshotAsync(importSessionId, userId, replayMode: false); + + public async Task BuildReplaySnapshotAsync(int importSessionId, int userId) + => await BuildImportSessionSnapshotAsync(importSessionId, userId, replayMode: true); + + private async Task BuildImportSessionSnapshotAsync(int importSessionId, int userId, bool replayMode) { var stopwatch = Stopwatch.StartNew(); var session = await pipelines.GetByIdForUserAsync(importSessionId, userId); @@ -189,7 +193,15 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( } var activeSnapshot = PickActiveRunSnapshot(runSnapshots) ?? runSnapshots.Last(); - var current = PickAuthoritativeCurrentScene(runSnapshots); + var allCompleted = runSnapshots + .SelectMany(snapshot => snapshot.Completed) + .OrderBy(item => SceneSortChapter(item)) + .ThenBy(item => SceneSortNumber(item)) + .ThenBy(item => item.Result.SceneResultID) + .ToList(); + var current = replayMode + ? allCompleted.FirstOrDefault() + : PickAuthoritativeCurrentScene(runSnapshots); var currentSnapshot = OwnerOf(runSnapshots, current) ?? activeSnapshot; var totalPersistedResults = runSnapshots.Sum(snapshot => snapshot.Results.Count); var parsedScenes = runSnapshots.Sum(snapshot => snapshot.Completed.Count); @@ -217,12 +229,9 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( var overallStage = isSuccessfullyComplete ? "Analysis Complete" : isTerminal ? "Analysis stopped" : BackendStageDisplay(activeSnapshot.Run); var scenes = new List(); var storyMemory = new StoryMemory(); - var allCompleted = runSnapshots - .SelectMany(snapshot => snapshot.Completed) - .OrderBy(item => item.Result.CreatedUtc) - .ThenBy(item => item.Result.SceneResultID) - .ToList(); - var visibleSceneIds = VisibleSceneResultIds(allCompleted, current, SceneWindowPrevious, followingCount: 0); + var visibleSceneIds = replayMode + ? allCompleted.Select(item => item.Result.SceneResultID).ToHashSet() + : VisibleSceneResultIds(allCompleted, current, SceneWindowPrevious, followingCount: 0); foreach (var snapshot in runSnapshots) { var runScenes = BuildSceneStates( @@ -245,6 +254,16 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( scenes.AddRange(runScenes); } + if (replayMode) + { + scenes = scenes + .OrderBy(scene => scene.ChapterNumber ?? decimal.MaxValue) + .ThenBy(scene => scene.SceneNumber) + .ThenBy(scene => scene.SceneResultId ?? int.MaxValue) + .Select((scene, index) => ReplayScene(scene, index, scenes.Count)) + .ToList(); + } + if (scenes.Count == 0) { scenes.Add(BuildEmptyScene(activeSnapshot.Run)); @@ -252,15 +271,17 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( var model = new StoryIntelligenceExperiencePrototypeViewModel { - Mode = "real", + Mode = replayMode ? "replay" : "real", ImportSessionId = session.StoryIntelligenceBookPipelineID, RunId = currentSnapshot.Run.StoryIntelligenceRunID, BookId = session.BookID, RunStatus = SessionStatus(session, runSnapshots), GeneratedUtc = DateTime.UtcNow, ChangeToken = SessionChangeToken(session, runSnapshots), - IsTerminal = isTerminal, - SnapshotMessage = SessionSnapshotMessage(session, runSnapshots), + IsTerminal = replayMode || isTerminal, + SnapshotMessage = replayMode + ? ReplaySnapshotMessage(session, runSnapshots, parsedScenes) + : SessionSnapshotMessage(session, runSnapshots), Diagnostics = BuildSessionDiagnostics( session, runSnapshots, @@ -275,16 +296,15 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( bookChapterTotal, completedChapterRuns, displayedProgressReason, - storyMemory), + storyMemory, + replayMode), ProjectName = DisplayTitle(session.ProjectName, "Untitled project"), BookTitle = DisplayTitle(session.BookDisplayTitle, "Untitled book"), Scenes = scenes }; StabiliseCharacterIllustrations(model); - await illustrationLibrary.ApplyApprovedIllustrationsAsync(model); - await illustrationMatcher.ApplyCharacterIllustrationsAsync(session.ProjectID, model); - await illustrationMatcher.ApplySupportingIllustrationDemandAsync(session.ProjectID, model); + await ApplyPersistedStoryMemoryAssignmentsAsync(importSessionId, model); ApplyIllustrationDiagnostics(model); StripLiveDiagnostics(model); LogSnapshotSynchronisation(importSessionId, currentSnapshot, current, scenes, model.ChangeToken); @@ -331,6 +351,9 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( { Id = $"scene-result-{item.Result.SceneResultID}", IsCurrent = isCurrent, + ChapterRunId = run.StoryIntelligenceRunID, + ChapterNumber = scene.SceneReference?.ChapterNumber ?? run.ChapterNumber, + SceneResultId = item.Result.SceneResultID, ChapterLabel = ChapterLabel(run, item), SceneNumber = sceneNumber, Title = SceneTitle(scene, item.Result, sceneNumber), @@ -361,6 +384,44 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( } } + private static StoryIntelligenceExperienceSceneState ReplayScene(StoryIntelligenceExperienceSceneState scene, int index, int total) + { + var replayProgress = total <= 1 ? 100 : Math.Clamp((int)Math.Round((index + 1) * 100m / total), 1, 100); + return new() + { + Id = scene.Id, + IsCurrent = index == 0, + ChapterRunId = scene.ChapterRunId, + ChapterNumber = scene.ChapterNumber, + SceneResultId = scene.SceneResultId, + ReplayIndex = index, + GlobalSceneIndex = index + 1, + ChapterLabel = scene.ChapterLabel, + SceneNumber = scene.SceneNumber, + Title = scene.Title, + Summary = scene.Summary, + Stage = $"Replay saved analysis - {scene.ChapterLabel} Scene {scene.SceneNumber:N0}", + TimelineLabel = scene.TimelineLabel, + TimelineEvent = scene.TimelineEvent, + ProgressPercent = replayProgress, + ChaptersAnalysed = scene.ChaptersAnalysed, + ChapterTotal = scene.ChapterTotal, + ScenesAnalysed = index + 1, + SceneTotal = total, + ElapsedTime = $"Scene {index + 1:N0} of {total:N0}", + EstimatedRemaining = index >= total - 1 ? "Replay complete" : "Replay mode", + PovCharacterId = scene.PovCharacterId, + Location = scene.Location, + Characters = scene.Characters, + Assets = scene.Assets, + Relationships = scene.Relationships, + KnowledgeThreads = scene.KnowledgeThreads, + Observations = scene.Observations, + LatestDiscoveries = scene.LatestDiscoveries, + ActivityFeed = scene.ActivityFeed + }; + } + private static StoryIntelligenceExperienceSceneState BuildEmptyScene(StoryIntelligenceSavedRun run) => new() { @@ -1379,13 +1440,21 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( int totalChapters, int completedChapters, string displayedProgressReason, - StoryMemory storyMemory) + StoryMemory storyMemory, + bool replayMode = false) { var failed = snapshots.Sum(snapshot => snapshot.Parsed.Count(item => item.Scene is null)); var warning = failed == 0 ? null : $"{failed:N0} scene result(s) skipped because they could not be parsed."; + var failedChapterSnapshots = snapshots + .Where(snapshot => string.Equals(snapshot.Run.Status, StoryIntelligenceRunStatuses.Failed, StringComparison.OrdinalIgnoreCase)) + .OrderBy(snapshot => snapshot.Run.ChapterNumber ?? decimal.MaxValue) + .ThenBy(snapshot => snapshot.Run.StoryIntelligenceRunID) + .ToList(); + var failedChapterSummary = string.Join("; ", failedChapterSnapshots.Select(snapshot => + $"Chapter {snapshot.Run.ChapterNumber:0.##} - {FirstConfigured(snapshot.Run.ErrorMessage, snapshot.Run.FailureStage, "analysis failed")}")); return new() { - RequestedMode = "real", + RequestedMode = replayMode ? "replay" : "real", CurrentImportId = session.StoryIntelligenceBookPipelineID, RequestedImportSessionId = session.StoryIntelligenceBookPipelineID, ResolvedImportSessionId = session.StoryIntelligenceBookPipelineID, @@ -1402,9 +1471,9 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( TotalScenes = totalScenes, ParsedScenes = parsedScenes, TerminalRunSessionStatus = $"{session.Status}/{SessionStatus(session, snapshots)}", - DisplayedProgressReason = displayedProgressReason, - ProgressSource = "ImportSession", - CurrentBackendStage = BackendStageDisplay(activeSnapshot.Run), + DisplayedProgressReason = replayMode ? "Replay position through available saved scenes" : displayedProgressReason, + ProgressSource = replayMode ? "ReplayCursor" : "ImportSession", + CurrentBackendStage = replayMode ? "Replay saved analysis" : BackendStageDisplay(activeSnapshot.Run), StoryMemoryCharacterCount = storyMemory.Characters.Count, ResolvedCharacterCount = storyMemory.ResolvedCharacterCount, UnresolvedCharacterCount = storyMemory.UnresolvedCharacterCount, @@ -1415,7 +1484,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( PovResolutionReason = storyMemory.PovResolutionReason, PovConfidence = storyMemory.PovConfidence, PovChangeDetected = storyMemory.PovChangeDetected, - SnapshotSource = "Real", + SnapshotSource = replayMode ? "Replay" : "Real", RunStatus = SessionStatus(session, snapshots), PersistedCompletedSceneCount = snapshots.Sum(snapshot => snapshot.Run.CompletedScenes ?? snapshot.Completed.Count), TotalPersistedSceneResultCount = totalPersistedResults, @@ -1427,10 +1496,30 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( SelectedChapterAndSceneOrder = current is null ? "none" : $"Chapter {activeSnapshot.Run.ChapterNumber:0.##}, Scene {SceneSortNumber(current):0.##}", CurrentChangeToken = SessionChangeToken(session, snapshots), AnalysisCursor = FirstConfigured(activeSnapshot.Run.CurrentMessage, activeSnapshot.Run.CurrentStage, session.CurrentStage), - Warning = warning + Warning = replayMode && failedChapterSnapshots.Count > 0 + ? $"{failedChapterSnapshots.Count:N0} failed chapter(s) skipped during replay. {failedChapterSummary}" + : warning, + ReplayIndex = replayMode ? 1 : 0, + TotalReplayableScenes = replayMode ? parsedScenes : 0, + ReplayMilestone = replayMode ? "Scene completed snapshot" : string.Empty, + ReplayState = replayMode ? "Paused at first available scene" : string.Empty, + SnapshotCacheStatus = replayMode ? "Reconstructed from saved analysis" : string.Empty, + RebuildVersion = replayMode ? "21R-replay-v1" : string.Empty, + FailedChaptersSkipped = failedChapterSnapshots.Count, + FailedChapterSummary = failedChapterSummary, + PanelVisibleCounts = replayMode ? "relationships:2; knowledge:3; observations:2; discoveries:4; activity:4" : string.Empty, + PanelHiddenCounts = replayMode ? "computed in browser per replay cursor" : string.Empty, + AiCallsDuringReplay = 0 }; } + private static string ReplaySnapshotMessage(StoryIntelligenceBookPipelineState session, IReadOnlyList snapshots, int replayableScenes) + { + var failedChapters = snapshots.Count(snapshot => string.Equals(snapshot.Run.Status, StoryIntelligenceRunStatuses.Failed, StringComparison.OrdinalIgnoreCase)); + var completedChapters = snapshots.Count(snapshot => IsSuccessfulTerminal(snapshot.Run.Status)); + return $"Saved analysis replay from Import Session {session.StoryIntelligenceBookPipelineID:N0}. {completedChapters:N0} of {snapshots.Count:N0} chapters analysed, {failedChapters:N0} failed chapter(s), {replayableScenes:N0} replayable scene(s). No AI calls are made in replay."; + } + private static StoryIntelligenceExperienceSnapshotDiagnostics BuildDiagnostics( StoryIntelligenceSavedRun run, IReadOnlyList results, @@ -1505,6 +1594,131 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( _ => FirstConfigured(run.CurrentMessage, "Analysis snapshot is updating.") }; + private async Task ApplyPersistedStoryMemoryAssignmentsAsync(int importSessionId, StoryIntelligenceExperiencePrototypeViewModel model) + { + var assignments = await storyMemoryRepository.ListAssignmentsAsync(importSessionId); + if (assignments.Count == 0) + { + return; + } + + var characters = await storyMemoryRepository.ListCharactersAsync(importSessionId); + var locations = await storyMemoryRepository.ListLocationsAsync(importSessionId); + var assets = await storyMemoryRepository.ListAssetsAsync(importSessionId); + var characterIds = characters.ToDictionary(item => item.StoryMemoryCharacterID, item => item.CanonicalIdentityKey); + var locationIds = locations.ToDictionary(item => item.StoryMemoryLocationID, item => item.CanonicalIdentityKey); + var assetIds = assets.ToDictionary(item => item.StoryMemoryAssetID, item => item.CanonicalIdentityKey); + var catalogue = (await illustrationRepository.ListAsync(new IllustrationLibraryFilter())) + .ToDictionary(item => item.IllustrationLibraryItemID); + + var characterAssignments = BuildAssignmentLookup(assignments, StoryMemoryEntityTypes.Character, characterIds, catalogue); + var locationAssignments = BuildAssignmentLookup(assignments, StoryMemoryEntityTypes.Location, locationIds, catalogue); + var assetAssignments = BuildAssignmentLookup(assignments, StoryMemoryEntityTypes.Asset, assetIds, catalogue); + + foreach (var scene in model.Scenes) + { + ApplyLocationAssignment(scene.Location, locationAssignments); + foreach (var character in scene.Characters) + { + ApplyCharacterAssignment(character, characterAssignments); + } + + foreach (var asset in scene.Assets) + { + ApplyAssetAssignment(asset, assetAssignments); + } + } + } + + private Dictionary BuildAssignmentLookup( + IReadOnlyList assignments, + string entityType, + IReadOnlyDictionary entityKeys, + IReadOnlyDictionary catalogue) + { + var lookup = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var assignment in assignments.Where(item => item.EntityType == entityType && item.IllustrationLibraryItemID.HasValue)) + { + if (!entityKeys.TryGetValue(assignment.EntityID, out var entityKey) + || !catalogue.TryGetValue(assignment.IllustrationLibraryItemID!.Value, out var item)) + { + continue; + } + + var publicUrl = ResolveExistingPublicUploadUrl(item.ThumbnailPath) ?? ResolveExistingPublicUploadUrl(item.FilePath); + if (string.IsNullOrWhiteSpace(publicUrl)) + { + continue; + } + + lookup[entityKey] = new AssignmentImage(item.IllustrationLibraryItemID, item.StableCode, item.Status, publicUrl, assignment.Confidence, assignment.MatchScore, assignment.AssignmentReason); + } + + return lookup; + } + + private void ApplyCharacterAssignment(StoryIntelligenceExperienceCharacterState character, IReadOnlyDictionary assignments) + { + if (!assignments.TryGetValue(character.Id, out var assignment)) + { + return; + } + + var fallback = string.IsNullOrWhiteSpace(character.ImageResolution.FallbackImageUrl) ? character.ImagePath : character.ImageResolution.FallbackImageUrl; + character.LibraryCode = assignment.StableCode; + character.ImagePath = assignment.PublicUrl; + character.ImageResolution = BuildAssignmentResolution(character.Id, assignment, fallback, "Persisted Story Memory character assignment"); + } + + private void ApplyLocationAssignment(StoryIntelligenceExperienceLocationState location, IReadOnlyDictionary assignments) + { + if (!assignments.TryGetValue(location.Id, out var assignment)) + { + return; + } + + var fallback = string.IsNullOrWhiteSpace(location.ImageResolution.FallbackImageUrl) ? location.ImagePath : location.ImageResolution.FallbackImageUrl; + location.LibraryCode = assignment.StableCode; + location.ImagePath = assignment.PublicUrl; + location.ImageResolution = BuildAssignmentResolution(location.Id, assignment, fallback, "Persisted Story Memory location assignment"); + } + + private void ApplyAssetAssignment(StoryIntelligenceExperienceAssetState asset, IReadOnlyDictionary assignments) + { + if (!assignments.TryGetValue(asset.Id, out var assignment)) + { + return; + } + + var fallback = string.IsNullOrWhiteSpace(asset.ImageResolution.FallbackImageUrl) ? asset.ImagePath : asset.ImageResolution.FallbackImageUrl; + asset.LibraryCode = assignment.StableCode; + asset.ImagePath = assignment.PublicUrl; + asset.ImageResolution = BuildAssignmentResolution(asset.Id, assignment, fallback, "Persisted Story Memory asset assignment"); + } + + private static StoryIntelligenceExperienceImageResolution BuildAssignmentResolution(string entityId, AssignmentImage assignment, string fallback, string status) + => new() + { + EntityId = entityId, + StableCode = assignment.StableCode, + IllustrationLibraryItemId = assignment.IllustrationLibraryItemId, + Status = assignment.Status, + FinalImageUrl = assignment.PublicUrl, + FallbackImageUrl = fallback, + FallbackUsed = false, + AssignmentConfidence = assignment.Confidence, + MatchingScore = assignment.MatchScore, + ReasonChosen = assignment.Reason, + DemandStatus = "Read from durable Story Memory", + IllustrationAllocationStatus = status + }; + + 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 static void StabiliseCharacterIllustrations(StoryIntelligenceExperiencePrototypeViewModel model) { var firstAssignments = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -2091,6 +2305,15 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( private sealed record ParsedSceneResult(StoryIntelligenceSavedSceneResult Result, SceneIntelligenceScene? Scene, string? ParseError); + private sealed record AssignmentImage( + int IllustrationLibraryItemId, + string StableCode, + string Status, + string PublicUrl, + decimal Confidence, + int MatchScore, + string? Reason); + private sealed record CharacterCodeProfile( string Code, string AgeBand, diff --git a/PlotLine/Services/StoryMemoryServices.cs b/PlotLine/Services/StoryMemoryServices.cs new file mode 100644 index 0000000..afe6dc2 --- /dev/null +++ b/PlotLine/Services/StoryMemoryServices.cs @@ -0,0 +1,513 @@ +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); +} diff --git a/PlotLine/Sql/142_Phase21S_DurableStoryMemory.sql b/PlotLine/Sql/142_Phase21S_DurableStoryMemory.sql new file mode 100644 index 0000000..d879f54 --- /dev/null +++ b/PlotLine/Sql/142_Phase21S_DurableStoryMemory.sql @@ -0,0 +1,256 @@ +SET ANSI_NULLS ON; +SET QUOTED_IDENTIFIER ON; +GO + +IF OBJECT_ID(N'dbo.StoryMemoryImportPreferences', N'U') IS NULL +BEGIN + CREATE TABLE dbo.StoryMemoryImportPreferences + ( + StoryMemoryImportPreferenceID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryMemoryImportPreferences PRIMARY KEY, + ImportSessionID int NOT NULL, + AppearancePreference nvarchar(60) NOT NULL CONSTRAINT DF_StoryMemoryImportPreferences_AppearancePreference DEFAULT N'Balanced', + ResolverVersion nvarchar(40) NOT NULL CONSTRAINT DF_StoryMemoryImportPreferences_ResolverVersion DEFAULT N'21S', + CreatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryMemoryImportPreferences_CreatedUtc DEFAULT SYSUTCDATETIME(), + UpdatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryMemoryImportPreferences_UpdatedUtc DEFAULT SYSUTCDATETIME(), + CONSTRAINT FK_StoryMemoryImportPreferences_ImportSession FOREIGN KEY (ImportSessionID) REFERENCES dbo.StoryIntelligenceBookPipelines(StoryIntelligenceBookPipelineID) + ); +END; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_StoryMemoryImportPreferences_ImportSession' AND object_id = OBJECT_ID(N'dbo.StoryMemoryImportPreferences')) + CREATE UNIQUE INDEX UX_StoryMemoryImportPreferences_ImportSession ON dbo.StoryMemoryImportPreferences(ImportSessionID); +GO + +IF OBJECT_ID(N'dbo.StoryMemoryProcessedSceneResults', N'U') IS NULL +BEGIN + CREATE TABLE dbo.StoryMemoryProcessedSceneResults + ( + StoryMemoryProcessedSceneResultID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryMemoryProcessedSceneResults PRIMARY KEY, + ImportSessionID int NOT NULL, + SceneResultID int NOT NULL, + ProcessorVersion nvarchar(40) NOT NULL, + SnapshotHash nvarchar(64) NULL, + ProcessedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryMemoryProcessedSceneResults_ProcessedUtc DEFAULT SYSUTCDATETIME(), + CONSTRAINT FK_StoryMemoryProcessedSceneResults_ImportSession FOREIGN KEY (ImportSessionID) REFERENCES dbo.StoryIntelligenceBookPipelines(StoryIntelligenceBookPipelineID), + CONSTRAINT FK_StoryMemoryProcessedSceneResults_SceneResult FOREIGN KEY (SceneResultID) REFERENCES dbo.StoryIntelligenceSceneResults(SceneResultID) + ); +END; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_StoryMemoryProcessedSceneResults_SceneVersion' AND object_id = OBJECT_ID(N'dbo.StoryMemoryProcessedSceneResults')) + CREATE UNIQUE INDEX UX_StoryMemoryProcessedSceneResults_SceneVersion ON dbo.StoryMemoryProcessedSceneResults(ImportSessionID, SceneResultID, ProcessorVersion); +GO + +IF OBJECT_ID(N'dbo.StoryMemoryCharacters', N'U') IS NULL +BEGIN + CREATE TABLE dbo.StoryMemoryCharacters + ( + StoryMemoryCharacterID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryMemoryCharacters PRIMARY KEY, + ImportSessionID int NOT NULL, + ProjectID int NOT NULL, + BookID int NOT NULL, + CanonicalIdentityKey nvarchar(180) NOT NULL, + DisplayName nvarchar(220) NOT NULL, + EntityContext nvarchar(40) NOT NULL CONSTRAINT DF_StoryMemoryCharacters_EntityContext DEFAULT N'Story', + SourceSectionType nvarchar(40) NOT NULL CONSTRAINT DF_StoryMemoryCharacters_SourceSectionType DEFAULT N'Story', + IsNamed bit NOT NULL CONSTRAINT DF_StoryMemoryCharacters_IsNamed DEFAULT 0, + IsGroupEntity bit NOT NULL CONSTRAINT DF_StoryMemoryCharacters_IsGroupEntity DEFAULT 0, + IsResolved bit NOT NULL CONSTRAINT DF_StoryMemoryCharacters_IsResolved DEFAULT 0, + IsNarrator bit NOT NULL CONSTRAINT DF_StoryMemoryCharacters_IsNarrator DEFAULT 0, + IsPrimaryPOV bit NOT NULL CONSTRAINT DF_StoryMemoryCharacters_IsPrimaryPOV DEFAULT 0, + Significance int NOT NULL CONSTRAINT DF_StoryMemoryCharacters_Significance DEFAULT 0, + EvidenceVersion int NOT NULL CONSTRAINT DF_StoryMemoryCharacters_EvidenceVersion DEFAULT 1, + CreatedSceneResultID int NULL, + LastUpdatedSceneResultID int NULL, + CreatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryMemoryCharacters_CreatedUtc DEFAULT SYSUTCDATETIME(), + UpdatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryMemoryCharacters_UpdatedUtc DEFAULT SYSUTCDATETIME(), + CONSTRAINT FK_StoryMemoryCharacters_ImportSession FOREIGN KEY (ImportSessionID) REFERENCES dbo.StoryIntelligenceBookPipelines(StoryIntelligenceBookPipelineID) + ); +END; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_StoryMemoryCharacters_ImportCanonical' AND object_id = OBJECT_ID(N'dbo.StoryMemoryCharacters')) + CREATE UNIQUE INDEX UX_StoryMemoryCharacters_ImportCanonical ON dbo.StoryMemoryCharacters(ImportSessionID, CanonicalIdentityKey); +GO + +IF OBJECT_ID(N'dbo.StoryMemoryCharacterAliases', N'U') IS NULL +BEGIN + CREATE TABLE dbo.StoryMemoryCharacterAliases + ( + StoryMemoryCharacterAliasID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryMemoryCharacterAliases PRIMARY KEY, + StoryMemoryCharacterID int NOT NULL, + Alias nvarchar(220) NOT NULL, + NormalisedAlias nvarchar(220) NOT NULL, + ResolutionReason nvarchar(400) NULL, + SourceSceneResultID int NULL, + Confidence decimal(5,2) NOT NULL CONSTRAINT DF_StoryMemoryCharacterAliases_Confidence DEFAULT 0, + CreatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryMemoryCharacterAliases_CreatedUtc DEFAULT SYSUTCDATETIME(), + CONSTRAINT FK_StoryMemoryCharacterAliases_Character FOREIGN KEY (StoryMemoryCharacterID) REFERENCES dbo.StoryMemoryCharacters(StoryMemoryCharacterID) + ); +END; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_StoryMemoryCharacterAliases_CharacterAliasScene' AND object_id = OBJECT_ID(N'dbo.StoryMemoryCharacterAliases')) + CREATE UNIQUE INDEX UX_StoryMemoryCharacterAliases_CharacterAliasScene ON dbo.StoryMemoryCharacterAliases(StoryMemoryCharacterID, NormalisedAlias, SourceSceneResultID); +GO + +IF OBJECT_ID(N'dbo.StoryMemoryCharacterAttributes', N'U') IS NULL +BEGIN + CREATE TABLE dbo.StoryMemoryCharacterAttributes + ( + StoryMemoryCharacterAttributeID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryMemoryCharacterAttributes PRIMARY KEY, + StoryMemoryCharacterID int NOT NULL, + AttributeType nvarchar(60) NOT NULL, + NormalisedValue nvarchar(160) NOT NULL, + Confidence decimal(5,2) NOT NULL CONSTRAINT DF_StoryMemoryCharacterAttributes_Confidence DEFAULT 0, + IsExplicit bit NOT NULL CONSTRAINT DF_StoryMemoryCharacterAttributes_IsExplicit DEFAULT 0, + EvidenceSummary nvarchar(500) NULL, + SourceSceneResultID int NULL, + SupersededUtc datetime2(0) NULL, + CreatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryMemoryCharacterAttributes_CreatedUtc DEFAULT SYSUTCDATETIME(), + CONSTRAINT FK_StoryMemoryCharacterAttributes_Character FOREIGN KEY (StoryMemoryCharacterID) REFERENCES dbo.StoryMemoryCharacters(StoryMemoryCharacterID) + ); +END; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_StoryMemoryCharacterAttributes_Active' AND object_id = OBJECT_ID(N'dbo.StoryMemoryCharacterAttributes')) + CREATE UNIQUE INDEX UX_StoryMemoryCharacterAttributes_Active ON dbo.StoryMemoryCharacterAttributes(StoryMemoryCharacterID, AttributeType, NormalisedValue, SourceSceneResultID) WHERE SupersededUtc IS NULL; +GO + +IF OBJECT_ID(N'dbo.StoryMemoryLocations', N'U') IS NULL +BEGIN + CREATE TABLE dbo.StoryMemoryLocations + ( + StoryMemoryLocationID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryMemoryLocations PRIMARY KEY, + ImportSessionID int NOT NULL, + ProjectID int NOT NULL, + BookID int NOT NULL, + CanonicalIdentityKey nvarchar(180) NOT NULL, + DisplayName nvarchar(220) NOT NULL, + SemanticType nvarchar(80) NOT NULL, + Subtype nvarchar(80) NULL, + InteriorExterior nvarchar(40) NULL, + ParentLocationID int NULL, + FirstSeenSceneResultID int NULL, + LastSeenSceneResultID int NULL, + CreatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryMemoryLocations_CreatedUtc DEFAULT SYSUTCDATETIME(), + UpdatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryMemoryLocations_UpdatedUtc DEFAULT SYSUTCDATETIME(), + CONSTRAINT FK_StoryMemoryLocations_ImportSession FOREIGN KEY (ImportSessionID) REFERENCES dbo.StoryIntelligenceBookPipelines(StoryIntelligenceBookPipelineID) + ); +END; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_StoryMemoryLocations_ImportCanonical' AND object_id = OBJECT_ID(N'dbo.StoryMemoryLocations')) + CREATE UNIQUE INDEX UX_StoryMemoryLocations_ImportCanonical ON dbo.StoryMemoryLocations(ImportSessionID, CanonicalIdentityKey); +GO + +IF OBJECT_ID(N'dbo.StoryMemoryAssets', N'U') IS NULL +BEGIN + CREATE TABLE dbo.StoryMemoryAssets + ( + StoryMemoryAssetID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryMemoryAssets PRIMARY KEY, + ImportSessionID int NOT NULL, + ProjectID int NOT NULL, + BookID int NOT NULL, + CanonicalIdentityKey nvarchar(180) NOT NULL, + DisplayName nvarchar(220) NOT NULL, + SemanticType nvarchar(80) NOT NULL, + Subtype nvarchar(80) NULL, + Colour nvarchar(40) NULL, + Era nvarchar(60) NULL, + FirstSeenSceneResultID int NULL, + LastSeenSceneResultID int NULL, + CreatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryMemoryAssets_CreatedUtc DEFAULT SYSUTCDATETIME(), + UpdatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryMemoryAssets_UpdatedUtc DEFAULT SYSUTCDATETIME(), + CONSTRAINT FK_StoryMemoryAssets_ImportSession FOREIGN KEY (ImportSessionID) REFERENCES dbo.StoryIntelligenceBookPipelines(StoryIntelligenceBookPipelineID) + ); +END; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_StoryMemoryAssets_ImportCanonical' AND object_id = OBJECT_ID(N'dbo.StoryMemoryAssets')) + CREATE UNIQUE INDEX UX_StoryMemoryAssets_ImportCanonical ON dbo.StoryMemoryAssets(ImportSessionID, CanonicalIdentityKey); +GO + +IF OBJECT_ID(N'dbo.StoryMemoryRelationships', N'U') IS NULL +BEGIN + CREATE TABLE dbo.StoryMemoryRelationships + ( + StoryMemoryRelationshipID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryMemoryRelationships PRIMARY KEY, + ImportSessionID int NOT NULL, + SourceEntityType nvarchar(40) NOT NULL, + SourceEntityID int NOT NULL, + TargetEntityType nvarchar(40) NOT NULL, + TargetEntityID int NOT NULL, + RelationshipType nvarchar(120) NOT NULL, + Confidence decimal(5,2) NOT NULL CONSTRAINT DF_StoryMemoryRelationships_Confidence DEFAULT 0, + IsExplicit bit NOT NULL CONSTRAINT DF_StoryMemoryRelationships_IsExplicit DEFAULT 0, + FirstSeenSceneResultID int NULL, + LastUpdatedSceneResultID int NULL, + Active bit NOT NULL CONSTRAINT DF_StoryMemoryRelationships_Active DEFAULT 1, + CreatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryMemoryRelationships_CreatedUtc DEFAULT SYSUTCDATETIME(), + UpdatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryMemoryRelationships_UpdatedUtc DEFAULT SYSUTCDATETIME(), + CONSTRAINT FK_StoryMemoryRelationships_ImportSession FOREIGN KEY (ImportSessionID) REFERENCES dbo.StoryIntelligenceBookPipelines(StoryIntelligenceBookPipelineID) + ); +END; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_StoryMemoryRelationships_Active' AND object_id = OBJECT_ID(N'dbo.StoryMemoryRelationships')) + CREATE UNIQUE INDEX UX_StoryMemoryRelationships_Active ON dbo.StoryMemoryRelationships(ImportSessionID, SourceEntityType, SourceEntityID, TargetEntityType, TargetEntityID, RelationshipType) WHERE Active = 1; +GO + +IF OBJECT_ID(N'dbo.StoryMemoryIllustrationAssignments', N'U') IS NULL +BEGIN + CREATE TABLE dbo.StoryMemoryIllustrationAssignments + ( + StoryMemoryIllustrationAssignmentID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryMemoryIllustrationAssignments PRIMARY KEY, + ImportSessionID int NOT NULL, + EntityType nvarchar(40) NOT NULL, + EntityID int NOT NULL, + IllustrationLibraryItemID int NULL, + StableCode nvarchar(120) NULL, + AssignmentStatus nvarchar(40) NOT NULL, + IsFallback bit NOT NULL CONSTRAINT DF_StoryMemoryIllustrationAssignments_IsFallback DEFAULT 0, + MatchScore int NOT NULL CONSTRAINT DF_StoryMemoryIllustrationAssignments_MatchScore DEFAULT 0, + Confidence decimal(5,2) NOT NULL CONSTRAINT DF_StoryMemoryIllustrationAssignments_Confidence DEFAULT 0, + AssignmentReason nvarchar(700) NULL, + EvidenceVersion nvarchar(40) NOT NULL CONSTRAINT DF_StoryMemoryIllustrationAssignments_EvidenceVersion DEFAULT N'21S', + AssignedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryMemoryIllustrationAssignments_AssignedUtc DEFAULT SYSUTCDATETIME(), + LastValidatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryMemoryIllustrationAssignments_LastValidatedUtc DEFAULT SYSUTCDATETIME(), + InvalidatedUtc datetime2(0) NULL, + InvalidationReason nvarchar(700) NULL, + CreatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryMemoryIllustrationAssignments_CreatedUtc DEFAULT SYSUTCDATETIME(), + UpdatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryMemoryIllustrationAssignments_UpdatedUtc DEFAULT SYSUTCDATETIME(), + CONSTRAINT FK_StoryMemoryIllustrationAssignments_ImportSession FOREIGN KEY (ImportSessionID) REFERENCES dbo.StoryIntelligenceBookPipelines(StoryIntelligenceBookPipelineID), + CONSTRAINT FK_StoryMemoryIllustrationAssignments_Illustration FOREIGN KEY (IllustrationLibraryItemID) REFERENCES dbo.IllustrationLibraryItems(IllustrationLibraryItemID) + ); +END; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_StoryMemoryIllustrationAssignments_Active' AND object_id = OBJECT_ID(N'dbo.StoryMemoryIllustrationAssignments')) + CREATE UNIQUE INDEX UX_StoryMemoryIllustrationAssignments_Active ON dbo.StoryMemoryIllustrationAssignments(ImportSessionID, EntityType, EntityID) WHERE InvalidatedUtc IS NULL; +GO + +IF OBJECT_ID(N'dbo.StoryMemoryIllustrationDemands', N'U') IS NULL +BEGIN + CREATE TABLE dbo.StoryMemoryIllustrationDemands + ( + StoryMemoryIllustrationDemandID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryMemoryIllustrationDemands PRIMARY KEY, + ImportSessionID int NOT NULL, + ProjectID int NOT NULL, + EntityType nvarchar(40) NOT NULL, + ArchetypeKey nvarchar(240) NOT NULL, + DemandCount int NOT NULL CONSTRAINT DF_StoryMemoryIllustrationDemands_DemandCount DEFAULT 1, + WaitingEntityCount int NOT NULL CONSTRAINT DF_StoryMemoryIllustrationDemands_WaitingEntityCount DEFAULT 1, + Status nvarchar(40) NOT NULL CONSTRAINT DF_StoryMemoryIllustrationDemands_Status DEFAULT N'Recorded', + QueuedIllustrationLibraryItemID int NULL, + GeneratedIllustrationLibraryItemID int NULL, + CreatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryMemoryIllustrationDemands_CreatedUtc DEFAULT SYSUTCDATETIME(), + UpdatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryMemoryIllustrationDemands_UpdatedUtc DEFAULT SYSUTCDATETIME(), + CONSTRAINT FK_StoryMemoryIllustrationDemands_ImportSession FOREIGN KEY (ImportSessionID) REFERENCES dbo.StoryIntelligenceBookPipelines(StoryIntelligenceBookPipelineID) + ); +END; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_StoryMemoryIllustrationDemands_ImportEntityArchetype' AND object_id = OBJECT_ID(N'dbo.StoryMemoryIllustrationDemands')) + CREATE UNIQUE INDEX UX_StoryMemoryIllustrationDemands_ImportEntityArchetype ON dbo.StoryMemoryIllustrationDemands(ImportSessionID, EntityType, ArchetypeKey); +GO