Phase 21S - Durable Story Memory Illustration Pipeline

This commit is contained in:
Nick Beckley 2026-07-15 22:08:40 +00:00
parent 4e31784f46
commit f8e320d867
9 changed files with 1824 additions and 32 deletions

View File

@ -56,6 +56,9 @@ var tests = new (string Name, Action Test)[]
("Phase 21P uses one authoritative current visualisation scene", Phase21PUsesOneAuthoritativeCurrentVisualisationScene), ("Phase 21P uses one authoritative current visualisation scene", Phase21PUsesOneAuthoritativeCurrentVisualisationScene),
("Phase 21Q repairs production visualisation regressions", Phase21QRepairsProductionVisualisationRegressions), ("Phase 21Q repairs production visualisation regressions", Phase21QRepairsProductionVisualisationRegressions),
("Phase 21Q view keeps panels bounded and real controls hidden", Phase21QViewKeepsPanelsBoundedAndRealControlsHidden), ("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), ("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions),
("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel), ("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel),
("Live visualisation strips illustration diagnostics", LiveVisualisationStripsIllustrationDiagnostics), ("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")); 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("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() 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.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.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("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(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("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."); 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() static JsonSerializerOptions JsonOptions()
=> new() => new()
{ {

View File

@ -1,5 +1,7 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.Services; using PlotLine.Services;
using PlotLine.ViewModels; using PlotLine.ViewModels;
@ -11,6 +13,8 @@ public sealed class DevelopmentController(
IWebHostEnvironment environment, IWebHostEnvironment environment,
ICurrentUserService currentUser, ICurrentUserService currentUser,
IIllustrationLibraryService illustrationLibrary, IIllustrationLibraryService illustrationLibrary,
IStoryMemoryService storyMemory,
IStoryMemoryRepository storyMemoryRepository,
IStoryIntelligenceVisualisationSnapshotService snapshots) : Controller IStoryIntelligenceVisualisationSnapshotService snapshots) : Controller
{ {
[HttpGet("StoryIntelligenceExperience")] [HttpGet("StoryIntelligenceExperience")]
@ -23,10 +27,13 @@ public sealed class DevelopmentController(
var requestedMode = string.IsNullOrWhiteSpace(mode) ? "real" : mode.Trim(); var requestedMode = string.IsNullOrWhiteSpace(mode) ? "real" : mode.Trim();
var simulationRequested = string.Equals(requestedMode, "simulation", StringComparison.OrdinalIgnoreCase); 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) 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) if (realModel is null)
{ {
return View(ErrorModel( return View(ErrorModel(
@ -83,6 +90,69 @@ public sealed class DevelopmentController(
return View(ErrorModel(requestedMode, importSessionId, "No development user is signed in.")); return View(ErrorModel(requestedMode, importSessionId, "No development user is signed in."));
} }
[HttpPost("StoryIntelligenceExperience/Rebuild")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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<IActionResult> 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")] [HttpGet("StoryIntelligenceIllustrationDiagnostics")]
public async Task<IActionResult> StoryIntelligenceIllustrationDiagnostics([FromQuery] int importSessionId) public async Task<IActionResult> StoryIntelligenceIllustrationDiagnostics([FromQuery] int importSessionId)
{ {

View File

@ -0,0 +1,412 @@
using Dapper;
using PlotLine.Models;
namespace PlotLine.Data;
public interface IStoryMemoryRepository
{
Task ClearDerivedStateAsync(int importSessionId);
Task<IReadOnlySet<int>> ListProcessedSceneResultIdsAsync(int importSessionId, string processorVersion);
Task MarkSceneResultProcessedAsync(int importSessionId, int sceneResultId, string processorVersion, string snapshotHash);
Task<StoryMemoryCharacter> 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<StoryMemoryLocation> UpsertLocationAsync(StoryMemoryLocationSave request);
Task<StoryMemoryAsset> UpsertAssetAsync(StoryMemoryAssetSave request);
Task UpsertRelationshipAsync(int importSessionId, string sourceEntityType, int sourceEntityId, string targetEntityType, int targetEntityId, string relationshipType, decimal confidence, bool isExplicit, int sceneResultId);
Task<IReadOnlyList<StoryMemoryCharacter>> ListCharactersAsync(int importSessionId);
Task<IReadOnlyList<StoryMemoryCharacterAttribute>> ListCharacterAttributesAsync(int importSessionId);
Task<IReadOnlyList<StoryMemoryLocation>> ListLocationsAsync(int importSessionId);
Task<IReadOnlyList<StoryMemoryAsset>> ListAssetsAsync(int importSessionId);
Task<IReadOnlyList<StoryMemoryIllustrationAssignment>> ListAssignmentsAsync(int importSessionId);
Task<StoryMemoryIllustrationAssignment?> GetAssignmentAsync(int importSessionId, string entityType, int entityId);
Task UpsertAssignmentAsync(StoryMemoryIllustrationAssignmentSave request);
Task InvalidateAssignmentAsync(int assignmentId, string reason);
Task<StoryMemoryIllustrationDemand> UpsertDemandAsync(StoryMemoryIllustrationDemandSave request);
Task<string> GetAppearancePreferenceAsync(int importSessionId);
Task SetAppearancePreferenceAsync(int importSessionId, string preference);
Task<StoryMemoryDiagnostics> 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<IReadOnlySet<int>> ListProcessedSceneResultIdsAsync(int importSessionId, string processorVersion)
{
using var connection = connectionFactory.CreateConnection();
var ids = await connection.QueryAsync<int>(
"""
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<StoryMemoryCharacter> UpsertCharacterAsync(StoryMemoryCharacterSave request)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<StoryMemoryCharacter>(
"""
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<StoryMemoryLocation> UpsertLocationAsync(StoryMemoryLocationSave request)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<StoryMemoryLocation>(
"""
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<StoryMemoryAsset> UpsertAssetAsync(StoryMemoryAssetSave request)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<StoryMemoryAsset>(
"""
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<IReadOnlyList<StoryMemoryCharacter>> ListCharactersAsync(int importSessionId)
{
using var connection = connectionFactory.CreateConnection();
return (await connection.QueryAsync<StoryMemoryCharacter>("SELECT * FROM dbo.StoryMemoryCharacters WHERE ImportSessionID = @ImportSessionID ORDER BY Significance DESC, DisplayName;", new { ImportSessionID = importSessionId })).ToList();
}
public async Task<IReadOnlyList<StoryMemoryCharacterAttribute>> ListCharacterAttributesAsync(int importSessionId)
{
using var connection = connectionFactory.CreateConnection();
return (await connection.QueryAsync<StoryMemoryCharacterAttribute>(
"""
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<IReadOnlyList<StoryMemoryLocation>> ListLocationsAsync(int importSessionId)
{
using var connection = connectionFactory.CreateConnection();
return (await connection.QueryAsync<StoryMemoryLocation>("SELECT * FROM dbo.StoryMemoryLocations WHERE ImportSessionID = @ImportSessionID ORDER BY DisplayName;", new { ImportSessionID = importSessionId })).ToList();
}
public async Task<IReadOnlyList<StoryMemoryAsset>> ListAssetsAsync(int importSessionId)
{
using var connection = connectionFactory.CreateConnection();
return (await connection.QueryAsync<StoryMemoryAsset>("SELECT * FROM dbo.StoryMemoryAssets WHERE ImportSessionID = @ImportSessionID ORDER BY DisplayName;", new { ImportSessionID = importSessionId })).ToList();
}
public async Task<IReadOnlyList<StoryMemoryIllustrationAssignment>> ListAssignmentsAsync(int importSessionId)
{
using var connection = connectionFactory.CreateConnection();
return (await connection.QueryAsync<StoryMemoryIllustrationAssignment>("SELECT * FROM dbo.StoryMemoryIllustrationAssignments WHERE ImportSessionID = @ImportSessionID AND InvalidatedUtc IS NULL;", new { ImportSessionID = importSessionId })).ToList();
}
public async Task<StoryMemoryIllustrationAssignment?> GetAssignmentAsync(int importSessionId, string entityType, int entityId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<StoryMemoryIllustrationAssignment>(
"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<StoryMemoryIllustrationDemand> UpsertDemandAsync(StoryMemoryIllustrationDemandSave request)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<StoryMemoryIllustrationDemand>(
"""
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<string> GetAppearancePreferenceAsync(int importSessionId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<string>(
"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<StoryMemoryDiagnostics> GetDiagnosticsAsync(int importSessionId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<StoryMemoryDiagnostics>(
"""
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 });
}
}

View File

@ -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<string> 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;
}

View File

@ -149,6 +149,7 @@ public class Program
builder.Services.AddScoped<IStoryIntelligenceSourceRepository, StoryIntelligenceSourceRepository>(); builder.Services.AddScoped<IStoryIntelligenceSourceRepository, StoryIntelligenceSourceRepository>();
builder.Services.AddScoped<IStoryIntelligencePipelineRepository, StoryIntelligencePipelineRepository>(); builder.Services.AddScoped<IStoryIntelligencePipelineRepository, StoryIntelligencePipelineRepository>();
builder.Services.AddScoped<IStoryIntelligenceIllustrationMatchingRepository, StoryIntelligenceIllustrationMatchingRepository>(); builder.Services.AddScoped<IStoryIntelligenceIllustrationMatchingRepository, StoryIntelligenceIllustrationMatchingRepository>();
builder.Services.AddScoped<IStoryMemoryRepository, StoryMemoryRepository>();
builder.Services.AddScoped<IIllustrationLibraryRepository, IllustrationLibraryRepository>(); builder.Services.AddScoped<IIllustrationLibraryRepository, IllustrationLibraryRepository>();
builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>(); builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>();
builder.Services.AddScoped<IProjectCollaborationRepository, ProjectCollaborationRepository>(); builder.Services.AddScoped<IProjectCollaborationRepository, ProjectCollaborationRepository>();
@ -219,6 +220,7 @@ public class Program
builder.Services.AddScoped<IStoryIntelligenceRelationshipImportService, StoryIntelligenceRelationshipImportService>(); builder.Services.AddScoped<IStoryIntelligenceRelationshipImportService, StoryIntelligenceRelationshipImportService>();
builder.Services.AddScoped<IStoryIntelligenceKnowledgeImportService, StoryIntelligenceKnowledgeImportService>(); builder.Services.AddScoped<IStoryIntelligenceKnowledgeImportService, StoryIntelligenceKnowledgeImportService>();
builder.Services.AddScoped<IStoryIntelligenceVisualisationSnapshotService, StoryIntelligenceVisualisationSnapshotService>(); builder.Services.AddScoped<IStoryIntelligenceVisualisationSnapshotService, StoryIntelligenceVisualisationSnapshotService>();
builder.Services.AddScoped<IStoryMemoryService, StoryMemoryService>();
builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>(); builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>();
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>(); builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>(); builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();

View File

@ -23,6 +23,7 @@ public sealed class PersistedStoryIntelligenceRunner(
IOptions<StoryIntelligenceOptions> options, IOptions<StoryIntelligenceOptions> options,
IOptions<StoryIntelligencePricingOptions> pricingOptions, IOptions<StoryIntelligencePricingOptions> pricingOptions,
IStoryIntelligenceProgressNotifier notifier, IStoryIntelligenceProgressNotifier notifier,
IStoryMemoryService storyMemory,
ILogger<PersistedStoryIntelligenceRunner> logger) : IPersistedStoryIntelligenceRunner ILogger<PersistedStoryIntelligenceRunner> logger) : IPersistedStoryIntelligenceRunner
{ {
private const string ChapterPromptFile = "Chapter-Structure-Prompt-V2.md"; private const string ChapterPromptFile = "Chapter-Structure-Prompt-V2.md";
@ -338,6 +339,22 @@ public sealed class PersistedStoryIntelligenceRunner(
run.ChapterNumber, run.ChapterNumber,
block.TemporarySceneNumber, block.TemporarySceneNumber,
sceneResultId); 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++; completedScenes++;
await PublishAsync( await PublishAsync(

View File

@ -12,6 +12,7 @@ public interface IStoryIntelligenceVisualisationSnapshotService
{ {
Task<StoryIntelligenceExperiencePrototypeViewModel?> BuildSnapshotAsync(int runId, int userId); Task<StoryIntelligenceExperiencePrototypeViewModel?> BuildSnapshotAsync(int runId, int userId);
Task<StoryIntelligenceExperiencePrototypeViewModel?> BuildImportSessionSnapshotAsync(int importSessionId, int userId); Task<StoryIntelligenceExperiencePrototypeViewModel?> BuildImportSessionSnapshotAsync(int importSessionId, int userId);
Task<StoryIntelligenceExperiencePrototypeViewModel?> BuildReplaySnapshotAsync(int importSessionId, int userId);
Task<IReadOnlyList<StoryIntelligenceExperienceRunOption>> ListRunOptionsAsync(int userId); Task<IReadOnlyList<StoryIntelligenceExperienceRunOption>> ListRunOptionsAsync(int userId);
Task<IReadOnlyList<StoryIntelligenceExperienceRunOption>> ListImportSessionOptionsAsync(int userId); Task<IReadOnlyList<StoryIntelligenceExperienceRunOption>> ListImportSessionOptionsAsync(int userId);
} }
@ -21,7 +22,9 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
IStoryIntelligencePipelineRepository pipelines, IStoryIntelligencePipelineRepository pipelines,
IStoryIntelligenceSourceRepository sources, IStoryIntelligenceSourceRepository sources,
IIllustrationLibraryService illustrationLibrary, IIllustrationLibraryService illustrationLibrary,
IStoryIntelligenceIllustrationMatchingService illustrationMatcher, IStoryMemoryRepository storyMemoryRepository,
IIllustrationLibraryRepository illustrationRepository,
IUploadStorageService uploadStorage,
ILogger<StoryIntelligenceVisualisationSnapshotService> logger) : IStoryIntelligenceVisualisationSnapshotService ILogger<StoryIntelligenceVisualisationSnapshotService> logger) : IStoryIntelligenceVisualisationSnapshotService
{ {
private const string FallbackRoot = "/images/story-intelligence/prototype"; private const string FallbackRoot = "/images/story-intelligence/prototype";
@ -144,18 +147,19 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
StabiliseCharacterIllustrations(model); StabiliseCharacterIllustrations(model);
await illustrationLibrary.ApplyApprovedIllustrationsAsync(model); await illustrationLibrary.ApplyApprovedIllustrationsAsync(model);
if (run.ProjectID.HasValue) ApplyIllustrationDiagnostics(model);
{
await illustrationMatcher.ApplyCharacterIllustrationsAsync(run.ProjectID.Value, model);
await illustrationMatcher.ApplySupportingIllustrationDemandAsync(run.ProjectID.Value, model);
ApplyIllustrationDiagnostics(model);
}
StripLiveDiagnostics(model); StripLiveDiagnostics(model);
LogPayload(model, $"run {runId}", stopwatch.ElapsedMilliseconds); LogPayload(model, $"run {runId}", stopwatch.ElapsedMilliseconds);
return model; return model;
} }
public async Task<StoryIntelligenceExperiencePrototypeViewModel?> BuildImportSessionSnapshotAsync(int importSessionId, int userId) public async Task<StoryIntelligenceExperiencePrototypeViewModel?> BuildImportSessionSnapshotAsync(int importSessionId, int userId)
=> await BuildImportSessionSnapshotAsync(importSessionId, userId, replayMode: false);
public async Task<StoryIntelligenceExperiencePrototypeViewModel?> BuildReplaySnapshotAsync(int importSessionId, int userId)
=> await BuildImportSessionSnapshotAsync(importSessionId, userId, replayMode: true);
private async Task<StoryIntelligenceExperiencePrototypeViewModel?> BuildImportSessionSnapshotAsync(int importSessionId, int userId, bool replayMode)
{ {
var stopwatch = Stopwatch.StartNew(); var stopwatch = Stopwatch.StartNew();
var session = await pipelines.GetByIdForUserAsync(importSessionId, userId); var session = await pipelines.GetByIdForUserAsync(importSessionId, userId);
@ -189,7 +193,15 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
} }
var activeSnapshot = PickActiveRunSnapshot(runSnapshots) ?? runSnapshots.Last(); 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 currentSnapshot = OwnerOf(runSnapshots, current) ?? activeSnapshot;
var totalPersistedResults = runSnapshots.Sum(snapshot => snapshot.Results.Count); var totalPersistedResults = runSnapshots.Sum(snapshot => snapshot.Results.Count);
var parsedScenes = runSnapshots.Sum(snapshot => snapshot.Completed.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 overallStage = isSuccessfullyComplete ? "Analysis Complete" : isTerminal ? "Analysis stopped" : BackendStageDisplay(activeSnapshot.Run);
var scenes = new List<StoryIntelligenceExperienceSceneState>(); var scenes = new List<StoryIntelligenceExperienceSceneState>();
var storyMemory = new StoryMemory(); var storyMemory = new StoryMemory();
var allCompleted = runSnapshots var visibleSceneIds = replayMode
.SelectMany(snapshot => snapshot.Completed) ? allCompleted.Select(item => item.Result.SceneResultID).ToHashSet()
.OrderBy(item => item.Result.CreatedUtc) : VisibleSceneResultIds(allCompleted, current, SceneWindowPrevious, followingCount: 0);
.ThenBy(item => item.Result.SceneResultID)
.ToList();
var visibleSceneIds = VisibleSceneResultIds(allCompleted, current, SceneWindowPrevious, followingCount: 0);
foreach (var snapshot in runSnapshots) foreach (var snapshot in runSnapshots)
{ {
var runScenes = BuildSceneStates( var runScenes = BuildSceneStates(
@ -245,6 +254,16 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
scenes.AddRange(runScenes); 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) if (scenes.Count == 0)
{ {
scenes.Add(BuildEmptyScene(activeSnapshot.Run)); scenes.Add(BuildEmptyScene(activeSnapshot.Run));
@ -252,15 +271,17 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
var model = new StoryIntelligenceExperiencePrototypeViewModel var model = new StoryIntelligenceExperiencePrototypeViewModel
{ {
Mode = "real", Mode = replayMode ? "replay" : "real",
ImportSessionId = session.StoryIntelligenceBookPipelineID, ImportSessionId = session.StoryIntelligenceBookPipelineID,
RunId = currentSnapshot.Run.StoryIntelligenceRunID, RunId = currentSnapshot.Run.StoryIntelligenceRunID,
BookId = session.BookID, BookId = session.BookID,
RunStatus = SessionStatus(session, runSnapshots), RunStatus = SessionStatus(session, runSnapshots),
GeneratedUtc = DateTime.UtcNow, GeneratedUtc = DateTime.UtcNow,
ChangeToken = SessionChangeToken(session, runSnapshots), ChangeToken = SessionChangeToken(session, runSnapshots),
IsTerminal = isTerminal, IsTerminal = replayMode || isTerminal,
SnapshotMessage = SessionSnapshotMessage(session, runSnapshots), SnapshotMessage = replayMode
? ReplaySnapshotMessage(session, runSnapshots, parsedScenes)
: SessionSnapshotMessage(session, runSnapshots),
Diagnostics = BuildSessionDiagnostics( Diagnostics = BuildSessionDiagnostics(
session, session,
runSnapshots, runSnapshots,
@ -275,16 +296,15 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
bookChapterTotal, bookChapterTotal,
completedChapterRuns, completedChapterRuns,
displayedProgressReason, displayedProgressReason,
storyMemory), storyMemory,
replayMode),
ProjectName = DisplayTitle(session.ProjectName, "Untitled project"), ProjectName = DisplayTitle(session.ProjectName, "Untitled project"),
BookTitle = DisplayTitle(session.BookDisplayTitle, "Untitled book"), BookTitle = DisplayTitle(session.BookDisplayTitle, "Untitled book"),
Scenes = scenes Scenes = scenes
}; };
StabiliseCharacterIllustrations(model); StabiliseCharacterIllustrations(model);
await illustrationLibrary.ApplyApprovedIllustrationsAsync(model); await ApplyPersistedStoryMemoryAssignmentsAsync(importSessionId, model);
await illustrationMatcher.ApplyCharacterIllustrationsAsync(session.ProjectID, model);
await illustrationMatcher.ApplySupportingIllustrationDemandAsync(session.ProjectID, model);
ApplyIllustrationDiagnostics(model); ApplyIllustrationDiagnostics(model);
StripLiveDiagnostics(model); StripLiveDiagnostics(model);
LogSnapshotSynchronisation(importSessionId, currentSnapshot, current, scenes, model.ChangeToken); LogSnapshotSynchronisation(importSessionId, currentSnapshot, current, scenes, model.ChangeToken);
@ -331,6 +351,9 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
{ {
Id = $"scene-result-{item.Result.SceneResultID}", Id = $"scene-result-{item.Result.SceneResultID}",
IsCurrent = isCurrent, IsCurrent = isCurrent,
ChapterRunId = run.StoryIntelligenceRunID,
ChapterNumber = scene.SceneReference?.ChapterNumber ?? run.ChapterNumber,
SceneResultId = item.Result.SceneResultID,
ChapterLabel = ChapterLabel(run, item), ChapterLabel = ChapterLabel(run, item),
SceneNumber = sceneNumber, SceneNumber = sceneNumber,
Title = SceneTitle(scene, item.Result, 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) private static StoryIntelligenceExperienceSceneState BuildEmptyScene(StoryIntelligenceSavedRun run)
=> new() => new()
{ {
@ -1379,13 +1440,21 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
int totalChapters, int totalChapters,
int completedChapters, int completedChapters,
string displayedProgressReason, string displayedProgressReason,
StoryMemory storyMemory) StoryMemory storyMemory,
bool replayMode = false)
{ {
var failed = snapshots.Sum(snapshot => snapshot.Parsed.Count(item => item.Scene is null)); 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 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() return new()
{ {
RequestedMode = "real", RequestedMode = replayMode ? "replay" : "real",
CurrentImportId = session.StoryIntelligenceBookPipelineID, CurrentImportId = session.StoryIntelligenceBookPipelineID,
RequestedImportSessionId = session.StoryIntelligenceBookPipelineID, RequestedImportSessionId = session.StoryIntelligenceBookPipelineID,
ResolvedImportSessionId = session.StoryIntelligenceBookPipelineID, ResolvedImportSessionId = session.StoryIntelligenceBookPipelineID,
@ -1402,9 +1471,9 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
TotalScenes = totalScenes, TotalScenes = totalScenes,
ParsedScenes = parsedScenes, ParsedScenes = parsedScenes,
TerminalRunSessionStatus = $"{session.Status}/{SessionStatus(session, snapshots)}", TerminalRunSessionStatus = $"{session.Status}/{SessionStatus(session, snapshots)}",
DisplayedProgressReason = displayedProgressReason, DisplayedProgressReason = replayMode ? "Replay position through available saved scenes" : displayedProgressReason,
ProgressSource = "ImportSession", ProgressSource = replayMode ? "ReplayCursor" : "ImportSession",
CurrentBackendStage = BackendStageDisplay(activeSnapshot.Run), CurrentBackendStage = replayMode ? "Replay saved analysis" : BackendStageDisplay(activeSnapshot.Run),
StoryMemoryCharacterCount = storyMemory.Characters.Count, StoryMemoryCharacterCount = storyMemory.Characters.Count,
ResolvedCharacterCount = storyMemory.ResolvedCharacterCount, ResolvedCharacterCount = storyMemory.ResolvedCharacterCount,
UnresolvedCharacterCount = storyMemory.UnresolvedCharacterCount, UnresolvedCharacterCount = storyMemory.UnresolvedCharacterCount,
@ -1415,7 +1484,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
PovResolutionReason = storyMemory.PovResolutionReason, PovResolutionReason = storyMemory.PovResolutionReason,
PovConfidence = storyMemory.PovConfidence, PovConfidence = storyMemory.PovConfidence,
PovChangeDetected = storyMemory.PovChangeDetected, PovChangeDetected = storyMemory.PovChangeDetected,
SnapshotSource = "Real", SnapshotSource = replayMode ? "Replay" : "Real",
RunStatus = SessionStatus(session, snapshots), RunStatus = SessionStatus(session, snapshots),
PersistedCompletedSceneCount = snapshots.Sum(snapshot => snapshot.Run.CompletedScenes ?? snapshot.Completed.Count), PersistedCompletedSceneCount = snapshots.Sum(snapshot => snapshot.Run.CompletedScenes ?? snapshot.Completed.Count),
TotalPersistedSceneResultCount = totalPersistedResults, TotalPersistedSceneResultCount = totalPersistedResults,
@ -1427,10 +1496,30 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
SelectedChapterAndSceneOrder = current is null ? "none" : $"Chapter {activeSnapshot.Run.ChapterNumber:0.##}, Scene {SceneSortNumber(current):0.##}", SelectedChapterAndSceneOrder = current is null ? "none" : $"Chapter {activeSnapshot.Run.ChapterNumber:0.##}, Scene {SceneSortNumber(current):0.##}",
CurrentChangeToken = SessionChangeToken(session, snapshots), CurrentChangeToken = SessionChangeToken(session, snapshots),
AnalysisCursor = FirstConfigured(activeSnapshot.Run.CurrentMessage, activeSnapshot.Run.CurrentStage, session.CurrentStage), 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<RunSnapshot> 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( private static StoryIntelligenceExperienceSnapshotDiagnostics BuildDiagnostics(
StoryIntelligenceSavedRun run, StoryIntelligenceSavedRun run,
IReadOnlyList<StoryIntelligenceSavedSceneResult> results, IReadOnlyList<StoryIntelligenceSavedSceneResult> results,
@ -1505,6 +1594,131 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
_ => FirstConfigured(run.CurrentMessage, "Analysis snapshot is updating.") _ => 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<string, AssignmentImage> BuildAssignmentLookup(
IReadOnlyList<StoryMemoryIllustrationAssignment> assignments,
string entityType,
IReadOnlyDictionary<int, string> entityKeys,
IReadOnlyDictionary<int, IllustrationLibraryItem> catalogue)
{
var lookup = new Dictionary<string, AssignmentImage>(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<string, AssignmentImage> 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<string, AssignmentImage> 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<string, AssignmentImage> 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) private static void StabiliseCharacterIllustrations(StoryIntelligenceExperiencePrototypeViewModel model)
{ {
var firstAssignments = new Dictionary<string, (string LibraryCode, string ImagePath)>(StringComparer.OrdinalIgnoreCase); var firstAssignments = new Dictionary<string, (string LibraryCode, string ImagePath)>(StringComparer.OrdinalIgnoreCase);
@ -2091,6 +2305,15 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
private sealed record ParsedSceneResult(StoryIntelligenceSavedSceneResult Result, SceneIntelligenceScene? Scene, string? ParseError); 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( private sealed record CharacterCodeProfile(
string Code, string Code,
string AgeBand, string AgeBand,

View File

@ -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<StoryMemoryRebuildResult> RebuildImportSessionAsync(int importSessionId, int userId, StoryMemoryRebuildOptions options, CancellationToken cancellationToken = default);
Task ProcessSceneResultAsync(int importSessionId, int userId, int sceneResultId, StoryMemoryResolveOptions options, CancellationToken cancellationToken = default);
Task ResolveIllustrationsAsync(int importSessionId, StoryMemoryResolveOptions options, CancellationToken cancellationToken = default);
}
public sealed class StoryMemoryService(
IStoryIntelligenceResultRepository results,
IStoryIntelligencePipelineRepository pipelines,
IStoryMemoryRepository memory,
IIllustrationLibraryRepository illustrationLibrary,
IUploadStorageService uploadStorage,
ILogger<StoryMemoryService> logger) : IStoryMemoryService
{
private const string ProcessorVersion = "21S";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
WriteIndented = false
};
public async Task<StoryMemoryRebuildResult> RebuildImportSessionAsync(int importSessionId, int userId, StoryMemoryRebuildOptions options, CancellationToken cancellationToken = default)
{
var session = await pipelines.GetByIdForUserAsync(importSessionId, userId)
?? throw new InvalidOperationException($"Import Session {importSessionId} could not be found for this user.");
var runScenes = await LoadImportScenesAsync(session, cancellationToken);
var completedScenes = runScenes.Where(item => item.Scene is not null).ToList();
if (options.DryRun)
{
return new StoryMemoryRebuildResult
{
ImportSessionID = importSessionId,
DryRun = true,
SceneResultsRead = runScenes.Count,
SceneResultsProcessed = completedScenes.Count,
CharactersDiscovered = completedScenes.Sum(item => item.Scene!.Characters?.Count ?? 0),
LocationsDiscovered = completedScenes.Sum(item => (item.Scene!.Locations?.Count ?? 0) + (string.IsNullOrWhiteSpace(item.Scene.Setting?.LocationName) ? 0 : 1)),
AssetsDiscovered = completedScenes.Sum(item => item.Scene!.Assets?.Count ?? 0),
RelationshipsDiscovered = completedScenes.Sum(item => item.Scene!.Relationships?.Count ?? 0),
AiCalls = 0,
Warnings = runScenes.Where(item => item.Scene is null && !string.IsNullOrWhiteSpace(item.ParseError)).Select(item => item.ParseError!).Take(20).ToList()
};
}
if (options.RebuildAll)
{
await memory.ClearDerivedStateAsync(importSessionId);
}
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<IReadOnlyList<SceneResultWithParse>> LoadImportScenesAsync(StoryIntelligenceBookPipelineState session, CancellationToken cancellationToken)
{
var runs = (await results.ListRunsAsync())
.Where(run => run.ProjectID == session.ProjectID && run.BookID == session.BookID)
.Select(run => new StoryIntelligenceSavedRun
{
StoryIntelligenceRunID = run.StoryIntelligenceRunID,
UserID = run.UserID,
ProjectID = run.ProjectID,
ProjectTitle = run.ProjectTitle,
BookID = run.BookID,
BookTitle = run.BookTitle,
ChapterID = run.ChapterID,
ChapterNumber = run.ChapterNumber,
Status = run.Status,
SourceType = run.SourceType,
CreatedUtc = run.CreatedUtc,
UpdatedUtc = run.CreatedUtc
})
.ToList();
var items = new List<SceneResultWithParse>();
foreach (var run in runs.OrderBy(item => item.ChapterNumber ?? decimal.MaxValue).ThenBy(item => item.StoryIntelligenceRunID))
{
cancellationToken.ThrowIfCancellationRequested();
var sceneResults = await results.ListSceneResultsAsync(run.StoryIntelligenceRunID);
items.AddRange(sceneResults.Select(item => new SceneResultWithParse(run, item, TryReadScene(item, out var error), error)));
}
return items
.OrderBy(item => item.Run.ChapterNumber ?? decimal.MaxValue)
.ThenBy(item => item.Result.TemporarySceneNumber)
.ThenBy(item => item.Result.SceneResultID)
.ToList();
}
private static SceneIntelligenceScene? TryReadScene(StoryIntelligenceSavedSceneResult result, out string? safeError)
{
safeError = null;
try
{
if (!string.IsNullOrWhiteSpace(result.ParsedJson))
{
var parsed = JsonSerializer.Deserialize<SceneIntelligenceScene>(result.ParsedJson, JsonOptions);
if (parsed?.SceneReference is not null || parsed?.Summary is not null || parsed?.Characters is not null)
{
return parsed;
}
using var document = JsonDocument.Parse(result.ParsedJson);
if (document.RootElement.TryGetProperty("parsedScene", out var parsedScene))
{
return parsedScene.Deserialize<SceneIntelligenceScene>(JsonOptions);
}
}
if (!string.IsNullOrWhiteSpace(result.OutputTextJson))
{
return JsonSerializer.Deserialize<SceneIntelligenceScene>(result.OutputTextJson, JsonOptions);
}
}
catch (JsonException ex)
{
safeError = $"SceneResultID {result.SceneResultID} could not be parsed: {ex.Message}";
}
return null;
}
private CharacterEvidenceProfile CharacterEvidence(StoryMemoryCharacter character, IEnumerable<StoryMemoryCharacterAttribute> 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);
}

View File

@ -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