Phase 21S - Durable Story Memory Illustration Pipeline
This commit is contained in:
parent
4e31784f46
commit
f8e320d867
@ -56,6 +56,9 @@ var tests = new (string Name, Action Test)[]
|
||||
("Phase 21P uses one authoritative current visualisation scene", Phase21PUsesOneAuthoritativeCurrentVisualisationScene),
|
||||
("Phase 21Q repairs production visualisation regressions", Phase21QRepairsProductionVisualisationRegressions),
|
||||
("Phase 21Q view keeps panels bounded and real controls hidden", Phase21QViewKeepsPanelsBoundedAndRealControlsHidden),
|
||||
("Phase 21R replay uses persisted data only", Phase21RReplayUsesPersistedDataOnly),
|
||||
("Phase 21R scene browser and panels are replay friendly", Phase21RSceneBrowserAndPanelsAreReplayFriendly),
|
||||
("Phase 21S uses durable Story Memory assignments", Phase21SUsesDurableStoryMemoryAssignments),
|
||||
("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions),
|
||||
("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel),
|
||||
("Live visualisation strips illustration diagnostics", LiveVisualisationStripsIllustrationDiagnostics),
|
||||
@ -635,7 +638,7 @@ static void Phase21OPreservesAdultDefaultsAndRealModeContinuity()
|
||||
|
||||
var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "PlotLine", "Views", "Development", "StoryIntelligenceExperience.cshtml"));
|
||||
Assert(view.Contains("Return to Import", StringComparison.Ordinal), "Real visualisation should expose a return-to-import link.");
|
||||
Assert(view.Contains("Model.Mode == \"simulation\"", StringComparison.Ordinal), "Prototype controls should be gated to simulation mode.");
|
||||
Assert(view.Contains("Model.Mode == \"simulation\" || Model.Mode == \"replay\"", StringComparison.Ordinal), "Prototype controls should be gated to simulation and replay modes.");
|
||||
}
|
||||
|
||||
static void Phase21PUsesOneAuthoritativeCurrentVisualisationScene()
|
||||
@ -914,14 +917,79 @@ static void Phase21QViewKeepsPanelsBoundedAndRealControlsHidden()
|
||||
|
||||
Assert(!view.Contains("data-scene-title", StringComparison.Ordinal), "Redundant scene heading should be removed from the real visualisation.");
|
||||
Assert(view.IndexOf("data-knowledge", StringComparison.Ordinal) < view.IndexOf("data-relationships", StringComparison.Ordinal), "Knowledge threads should be prioritised above relationship changes.");
|
||||
Assert(view.Contains("Model.Mode == \"simulation\"", StringComparison.Ordinal), "Prototype controls must remain simulation-only.");
|
||||
Assert(view.Contains("Model.Mode == \"simulation\" || Model.Mode == \"replay\"", StringComparison.Ordinal), "Prototype controls must remain hidden in live mode while supporting replay.");
|
||||
Assert(script.Contains("relationships.slice(0, 2)", StringComparison.Ordinal), "Relationship changes should be capped to the two most relevant visible cards.");
|
||||
Assert(script.Contains("renderOverflowSummary", StringComparison.Ordinal), "Relationship overflow should be indicated compactly.");
|
||||
Assert(css.Contains("max-height: calc(100vh - 126px)", StringComparison.Ordinal), "Side columns should be bounded to the viewport.");
|
||||
Assert(css.Contains("overflow: auto", StringComparison.Ordinal), "Side-panel content should have internal scrolling.");
|
||||
Assert(css.Contains("overflow-y: auto", StringComparison.Ordinal), "Side columns should keep a single hidden-track vertical scroll.");
|
||||
Assert(css.Contains("z-index: 180", StringComparison.Ordinal), "Character labels should render in a high label layer above nodes and paths.");
|
||||
}
|
||||
|
||||
static void Phase21RReplayUsesPersistedDataOnly()
|
||||
{
|
||||
var snapshotService = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs"));
|
||||
var matcher = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs"));
|
||||
var development = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Controllers/DevelopmentController.cs"));
|
||||
|
||||
Assert(snapshotService.Contains("BuildReplaySnapshotAsync", StringComparison.Ordinal), "Replay mode needs a dedicated snapshot builder.");
|
||||
Assert(snapshotService.Contains("Mode = replayMode ? \"replay\" : \"real\"", StringComparison.Ordinal), "Replay snapshots should be explicitly labelled.");
|
||||
Assert(snapshotService.Contains("ApplyPersistedStoryMemoryAssignmentsAsync(importSessionId, model)", StringComparison.Ordinal), "Replay must use durable Story Memory illustration assignments only.");
|
||||
Assert(!snapshotService.Contains("await illustrationMatcher.ApplyPersistedCharacterIllustrationsAsync", StringComparison.Ordinal), "Replay should not use the legacy character-only matcher.");
|
||||
Assert(!snapshotService.Contains("await illustrationMatcher.ApplySupportingIllustrationDemandAsync", StringComparison.Ordinal), "Snapshot rendering must not create location/asset demand.");
|
||||
Assert(snapshotService.Contains("AiCallsDuringReplay = 0", StringComparison.Ordinal), "Replay diagnostics must state that no AI calls are made.");
|
||||
Assert(development.Contains("BuildReplaySnapshotAsync(importSessionId.Value, userId)", StringComparison.Ordinal), "Development route must call the replay snapshot builder for mode=replay.");
|
||||
Assert(development.Contains("RebuildStoryIntelligenceExperience", StringComparison.Ordinal), "Development route should expose a saved-analysis rebuild action.");
|
||||
Assert(development.Contains("AI calls", StringComparison.Ordinal), "Rebuild action should report that it made no AI calls.");
|
||||
}
|
||||
|
||||
static void Phase21SUsesDurableStoryMemoryAssignments()
|
||||
{
|
||||
var sql = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Sql/142_Phase21S_DurableStoryMemory.sql"));
|
||||
var models = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Models/StoryMemoryModels.cs"));
|
||||
var service = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryMemoryServices.cs"));
|
||||
var snapshot = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs"));
|
||||
var runner = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/PersistedStoryIntelligenceRunner.cs"));
|
||||
var development = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Controllers/DevelopmentController.cs"));
|
||||
|
||||
Assert(sql.Contains("StoryMemoryCharacters", StringComparison.Ordinal), "Durable Story Memory character table is missing.");
|
||||
Assert(sql.Contains("StoryMemoryLocations", StringComparison.Ordinal), "Durable Story Memory location table is missing.");
|
||||
Assert(sql.Contains("StoryMemoryAssets", StringComparison.Ordinal), "Durable Story Memory asset table is missing.");
|
||||
Assert(sql.Contains("StoryMemoryIllustrationAssignments", StringComparison.Ordinal), "Durable illustration assignment table is missing.");
|
||||
Assert(sql.Contains("StoryMemoryIllustrationDemands", StringComparison.Ordinal), "Durable illustration demand table is missing.");
|
||||
Assert(models.Contains("StoryMemoryAppearancePreferences", StringComparison.Ordinal), "Import-level appearance preference model is missing.");
|
||||
Assert(service.Contains("IStoryMemoryService", StringComparison.Ordinal), "Durable Story Memory service is missing.");
|
||||
Assert(service.Contains("QueueDemand = false", StringComparison.Ordinal) || service.Contains("QueueDemand", StringComparison.Ordinal), "Demand queueing must be explicit.");
|
||||
Assert(service.Contains("InferCharacterEvidence", StringComparison.Ordinal), "Durable updater must use deterministic character evidence extraction.");
|
||||
Assert(service.Contains("CharacterHardRejections", StringComparison.Ordinal), "Resolver must revalidate hard character constraints.");
|
||||
Assert(service.Contains("LocationCompatible", StringComparison.Ordinal), "Resolver must enforce location compatibility.");
|
||||
Assert(service.Contains("AssetCompatible", StringComparison.Ordinal), "Resolver must enforce asset compatibility.");
|
||||
Assert(snapshot.Contains("ApplyPersistedStoryMemoryAssignmentsAsync", StringComparison.Ordinal), "Snapshot must read durable persisted assignments.");
|
||||
Assert(runner.Contains("storyMemory.ProcessSceneResultAsync", StringComparison.Ordinal), "Importer must update Story Memory as scenes are persisted.");
|
||||
Assert(development.Contains("StoryMemoryDiagnostics", StringComparison.Ordinal), "Development diagnostics endpoint is missing.");
|
||||
Assert(development.Contains("RebuildImportSessionAsync", StringComparison.Ordinal), "Development rebuild action must call the durable memory rebuild.");
|
||||
}
|
||||
|
||||
static void Phase21RSceneBrowserAndPanelsAreReplayFriendly()
|
||||
{
|
||||
var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Development/StoryIntelligenceExperience.cshtml"));
|
||||
var script = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/wwwroot/js/story-intelligence-experience-prototype.js"));
|
||||
var css = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/wwwroot/css/story-intelligence-experience-prototype.css"));
|
||||
var progressViewModel = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/ViewModels/OnboardingViewModels.cs"));
|
||||
|
||||
Assert(view.Contains("mode=replay", StringComparison.Ordinal), "Replay links should open the visualisation in replay mode.");
|
||||
Assert(view.Contains("data-replay-chapter", StringComparison.Ordinal), "Replay scene browser should expose chapter selection.");
|
||||
Assert(view.Contains("data-detail-panel", StringComparison.Ordinal), "Dense side-panel items should have an expanded detail presentation.");
|
||||
Assert(view.Contains("Rebuild Visualisation from Saved Analysis", StringComparison.Ordinal), "Replay mode should expose the development rebuild action.");
|
||||
Assert(script.Contains("const replayMode = root.dataset.mode === \"replay\"", StringComparison.Ordinal), "Browser script should distinguish replay mode.");
|
||||
Assert(script.Contains("setupReplayBrowser", StringComparison.Ordinal), "Replay browser setup was not found.");
|
||||
Assert(script.Contains("root.dataset.failedChapters", StringComparison.Ordinal), "Failed chapters should appear as replay browser gaps.");
|
||||
Assert(script.Contains("if (!realMode || !snapshotUrl || state.terminal) return;", StringComparison.Ordinal), "Replay must not poll live snapshot endpoints.");
|
||||
Assert(css.Contains(".story-exp-replay-browser", StringComparison.Ordinal), "Replay browser styling was not found.");
|
||||
Assert(css.Contains("scrollbar-width: none", StringComparison.Ordinal), "Side-panel native scrollbar tracks should be suppressed.");
|
||||
Assert(!css.Contains(".story-exp-insight-list {\n display: grid;\n gap: 9px;\n min-height: 0;\n overflow: auto", StringComparison.Ordinal), "Nested insight lists should not create native scrollbars.");
|
||||
Assert(progressViewModel.Contains("public int? ImportSessionID", StringComparison.Ordinal), "Import progress page should carry the import session for replay links.");
|
||||
}
|
||||
|
||||
static JsonSerializerOptions JsonOptions()
|
||||
=> new()
|
||||
{
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PlotLine.Data;
|
||||
using PlotLine.Models;
|
||||
using PlotLine.Services;
|
||||
using PlotLine.ViewModels;
|
||||
|
||||
@ -11,6 +13,8 @@ public sealed class DevelopmentController(
|
||||
IWebHostEnvironment environment,
|
||||
ICurrentUserService currentUser,
|
||||
IIllustrationLibraryService illustrationLibrary,
|
||||
IStoryMemoryService storyMemory,
|
||||
IStoryMemoryRepository storyMemoryRepository,
|
||||
IStoryIntelligenceVisualisationSnapshotService snapshots) : Controller
|
||||
{
|
||||
[HttpGet("StoryIntelligenceExperience")]
|
||||
@ -23,10 +27,13 @@ public sealed class DevelopmentController(
|
||||
|
||||
var requestedMode = string.IsNullOrWhiteSpace(mode) ? "real" : mode.Trim();
|
||||
var simulationRequested = string.Equals(requestedMode, "simulation", StringComparison.OrdinalIgnoreCase);
|
||||
var replayRequested = string.Equals(requestedMode, "replay", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!simulationRequested && importSessionId.HasValue && currentUser.UserId is int userId)
|
||||
{
|
||||
var realModel = await snapshots.BuildImportSessionSnapshotAsync(importSessionId.Value, userId);
|
||||
var realModel = replayRequested
|
||||
? await snapshots.BuildReplaySnapshotAsync(importSessionId.Value, userId)
|
||||
: await snapshots.BuildImportSessionSnapshotAsync(importSessionId.Value, userId);
|
||||
if (realModel is null)
|
||||
{
|
||||
return View(ErrorModel(
|
||||
@ -83,6 +90,69 @@ public sealed class DevelopmentController(
|
||||
return View(ErrorModel(requestedMode, importSessionId, "No development user is signed in."));
|
||||
}
|
||||
|
||||
[HttpPost("StoryIntelligenceExperience/Rebuild")]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<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")]
|
||||
public async Task<IActionResult> StoryIntelligenceIllustrationDiagnostics([FromQuery] int importSessionId)
|
||||
{
|
||||
|
||||
412
PlotLine/Data/StoryMemoryRepository.cs
Normal file
412
PlotLine/Data/StoryMemoryRepository.cs
Normal 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 });
|
||||
}
|
||||
}
|
||||
231
PlotLine/Models/StoryMemoryModels.cs
Normal file
231
PlotLine/Models/StoryMemoryModels.cs
Normal 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;
|
||||
}
|
||||
@ -149,6 +149,7 @@ public class Program
|
||||
builder.Services.AddScoped<IStoryIntelligenceSourceRepository, StoryIntelligenceSourceRepository>();
|
||||
builder.Services.AddScoped<IStoryIntelligencePipelineRepository, StoryIntelligencePipelineRepository>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceIllustrationMatchingRepository, StoryIntelligenceIllustrationMatchingRepository>();
|
||||
builder.Services.AddScoped<IStoryMemoryRepository, StoryMemoryRepository>();
|
||||
builder.Services.AddScoped<IIllustrationLibraryRepository, IllustrationLibraryRepository>();
|
||||
builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>();
|
||||
builder.Services.AddScoped<IProjectCollaborationRepository, ProjectCollaborationRepository>();
|
||||
@ -219,6 +220,7 @@ public class Program
|
||||
builder.Services.AddScoped<IStoryIntelligenceRelationshipImportService, StoryIntelligenceRelationshipImportService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceKnowledgeImportService, StoryIntelligenceKnowledgeImportService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceVisualisationSnapshotService, StoryIntelligenceVisualisationSnapshotService>();
|
||||
builder.Services.AddScoped<IStoryMemoryService, StoryMemoryService>();
|
||||
builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
|
||||
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();
|
||||
|
||||
@ -23,6 +23,7 @@ public sealed class PersistedStoryIntelligenceRunner(
|
||||
IOptions<StoryIntelligenceOptions> options,
|
||||
IOptions<StoryIntelligencePricingOptions> pricingOptions,
|
||||
IStoryIntelligenceProgressNotifier notifier,
|
||||
IStoryMemoryService storyMemory,
|
||||
ILogger<PersistedStoryIntelligenceRunner> logger) : IPersistedStoryIntelligenceRunner
|
||||
{
|
||||
private const string ChapterPromptFile = "Chapter-Structure-Prompt-V2.md";
|
||||
@ -338,6 +339,22 @@ public sealed class PersistedStoryIntelligenceRunner(
|
||||
run.ChapterNumber,
|
||||
block.TemporarySceneNumber,
|
||||
sceneResultId);
|
||||
if (importSessionId.HasValue)
|
||||
{
|
||||
try
|
||||
{
|
||||
await storyMemory.ProcessSceneResultAsync(
|
||||
importSessionId.Value,
|
||||
run.UserID,
|
||||
sceneResultId,
|
||||
new StoryMemoryResolveOptions { QueueDemand = false },
|
||||
cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException && !IsFatal(ex))
|
||||
{
|
||||
logger.LogError(ex, "Story Memory processing failed for import session {ImportSessionId}, scene result {SceneResultId}.", importSessionId, sceneResultId);
|
||||
}
|
||||
}
|
||||
|
||||
completedScenes++;
|
||||
await PublishAsync(
|
||||
|
||||
@ -12,6 +12,7 @@ public interface IStoryIntelligenceVisualisationSnapshotService
|
||||
{
|
||||
Task<StoryIntelligenceExperiencePrototypeViewModel?> BuildSnapshotAsync(int runId, 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>> ListImportSessionOptionsAsync(int userId);
|
||||
}
|
||||
@ -21,7 +22,9 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
IStoryIntelligencePipelineRepository pipelines,
|
||||
IStoryIntelligenceSourceRepository sources,
|
||||
IIllustrationLibraryService illustrationLibrary,
|
||||
IStoryIntelligenceIllustrationMatchingService illustrationMatcher,
|
||||
IStoryMemoryRepository storyMemoryRepository,
|
||||
IIllustrationLibraryRepository illustrationRepository,
|
||||
IUploadStorageService uploadStorage,
|
||||
ILogger<StoryIntelligenceVisualisationSnapshotService> logger) : IStoryIntelligenceVisualisationSnapshotService
|
||||
{
|
||||
private const string FallbackRoot = "/images/story-intelligence/prototype";
|
||||
@ -144,18 +147,19 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
|
||||
StabiliseCharacterIllustrations(model);
|
||||
await illustrationLibrary.ApplyApprovedIllustrationsAsync(model);
|
||||
if (run.ProjectID.HasValue)
|
||||
{
|
||||
await illustrationMatcher.ApplyCharacterIllustrationsAsync(run.ProjectID.Value, model);
|
||||
await illustrationMatcher.ApplySupportingIllustrationDemandAsync(run.ProjectID.Value, model);
|
||||
ApplyIllustrationDiagnostics(model);
|
||||
}
|
||||
StripLiveDiagnostics(model);
|
||||
LogPayload(model, $"run {runId}", stopwatch.ElapsedMilliseconds);
|
||||
return model;
|
||||
}
|
||||
|
||||
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 session = await pipelines.GetByIdForUserAsync(importSessionId, userId);
|
||||
@ -189,7 +193,15 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
}
|
||||
|
||||
var activeSnapshot = PickActiveRunSnapshot(runSnapshots) ?? runSnapshots.Last();
|
||||
var current = PickAuthoritativeCurrentScene(runSnapshots);
|
||||
var allCompleted = runSnapshots
|
||||
.SelectMany(snapshot => snapshot.Completed)
|
||||
.OrderBy(item => SceneSortChapter(item))
|
||||
.ThenBy(item => SceneSortNumber(item))
|
||||
.ThenBy(item => item.Result.SceneResultID)
|
||||
.ToList();
|
||||
var current = replayMode
|
||||
? allCompleted.FirstOrDefault()
|
||||
: PickAuthoritativeCurrentScene(runSnapshots);
|
||||
var currentSnapshot = OwnerOf(runSnapshots, current) ?? activeSnapshot;
|
||||
var totalPersistedResults = runSnapshots.Sum(snapshot => snapshot.Results.Count);
|
||||
var parsedScenes = runSnapshots.Sum(snapshot => snapshot.Completed.Count);
|
||||
@ -217,12 +229,9 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
var overallStage = isSuccessfullyComplete ? "Analysis Complete" : isTerminal ? "Analysis stopped" : BackendStageDisplay(activeSnapshot.Run);
|
||||
var scenes = new List<StoryIntelligenceExperienceSceneState>();
|
||||
var storyMemory = new StoryMemory();
|
||||
var allCompleted = runSnapshots
|
||||
.SelectMany(snapshot => snapshot.Completed)
|
||||
.OrderBy(item => item.Result.CreatedUtc)
|
||||
.ThenBy(item => item.Result.SceneResultID)
|
||||
.ToList();
|
||||
var visibleSceneIds = VisibleSceneResultIds(allCompleted, current, SceneWindowPrevious, followingCount: 0);
|
||||
var visibleSceneIds = replayMode
|
||||
? allCompleted.Select(item => item.Result.SceneResultID).ToHashSet()
|
||||
: VisibleSceneResultIds(allCompleted, current, SceneWindowPrevious, followingCount: 0);
|
||||
foreach (var snapshot in runSnapshots)
|
||||
{
|
||||
var runScenes = BuildSceneStates(
|
||||
@ -245,6 +254,16 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
scenes.AddRange(runScenes);
|
||||
}
|
||||
|
||||
if (replayMode)
|
||||
{
|
||||
scenes = scenes
|
||||
.OrderBy(scene => scene.ChapterNumber ?? decimal.MaxValue)
|
||||
.ThenBy(scene => scene.SceneNumber)
|
||||
.ThenBy(scene => scene.SceneResultId ?? int.MaxValue)
|
||||
.Select((scene, index) => ReplayScene(scene, index, scenes.Count))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
if (scenes.Count == 0)
|
||||
{
|
||||
scenes.Add(BuildEmptyScene(activeSnapshot.Run));
|
||||
@ -252,15 +271,17 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
|
||||
var model = new StoryIntelligenceExperiencePrototypeViewModel
|
||||
{
|
||||
Mode = "real",
|
||||
Mode = replayMode ? "replay" : "real",
|
||||
ImportSessionId = session.StoryIntelligenceBookPipelineID,
|
||||
RunId = currentSnapshot.Run.StoryIntelligenceRunID,
|
||||
BookId = session.BookID,
|
||||
RunStatus = SessionStatus(session, runSnapshots),
|
||||
GeneratedUtc = DateTime.UtcNow,
|
||||
ChangeToken = SessionChangeToken(session, runSnapshots),
|
||||
IsTerminal = isTerminal,
|
||||
SnapshotMessage = SessionSnapshotMessage(session, runSnapshots),
|
||||
IsTerminal = replayMode || isTerminal,
|
||||
SnapshotMessage = replayMode
|
||||
? ReplaySnapshotMessage(session, runSnapshots, parsedScenes)
|
||||
: SessionSnapshotMessage(session, runSnapshots),
|
||||
Diagnostics = BuildSessionDiagnostics(
|
||||
session,
|
||||
runSnapshots,
|
||||
@ -275,16 +296,15 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
bookChapterTotal,
|
||||
completedChapterRuns,
|
||||
displayedProgressReason,
|
||||
storyMemory),
|
||||
storyMemory,
|
||||
replayMode),
|
||||
ProjectName = DisplayTitle(session.ProjectName, "Untitled project"),
|
||||
BookTitle = DisplayTitle(session.BookDisplayTitle, "Untitled book"),
|
||||
Scenes = scenes
|
||||
};
|
||||
|
||||
StabiliseCharacterIllustrations(model);
|
||||
await illustrationLibrary.ApplyApprovedIllustrationsAsync(model);
|
||||
await illustrationMatcher.ApplyCharacterIllustrationsAsync(session.ProjectID, model);
|
||||
await illustrationMatcher.ApplySupportingIllustrationDemandAsync(session.ProjectID, model);
|
||||
await ApplyPersistedStoryMemoryAssignmentsAsync(importSessionId, model);
|
||||
ApplyIllustrationDiagnostics(model);
|
||||
StripLiveDiagnostics(model);
|
||||
LogSnapshotSynchronisation(importSessionId, currentSnapshot, current, scenes, model.ChangeToken);
|
||||
@ -331,6 +351,9 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
{
|
||||
Id = $"scene-result-{item.Result.SceneResultID}",
|
||||
IsCurrent = isCurrent,
|
||||
ChapterRunId = run.StoryIntelligenceRunID,
|
||||
ChapterNumber = scene.SceneReference?.ChapterNumber ?? run.ChapterNumber,
|
||||
SceneResultId = item.Result.SceneResultID,
|
||||
ChapterLabel = ChapterLabel(run, item),
|
||||
SceneNumber = sceneNumber,
|
||||
Title = SceneTitle(scene, item.Result, sceneNumber),
|
||||
@ -361,6 +384,44 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
}
|
||||
}
|
||||
|
||||
private static StoryIntelligenceExperienceSceneState ReplayScene(StoryIntelligenceExperienceSceneState scene, int index, int total)
|
||||
{
|
||||
var replayProgress = total <= 1 ? 100 : Math.Clamp((int)Math.Round((index + 1) * 100m / total), 1, 100);
|
||||
return new()
|
||||
{
|
||||
Id = scene.Id,
|
||||
IsCurrent = index == 0,
|
||||
ChapterRunId = scene.ChapterRunId,
|
||||
ChapterNumber = scene.ChapterNumber,
|
||||
SceneResultId = scene.SceneResultId,
|
||||
ReplayIndex = index,
|
||||
GlobalSceneIndex = index + 1,
|
||||
ChapterLabel = scene.ChapterLabel,
|
||||
SceneNumber = scene.SceneNumber,
|
||||
Title = scene.Title,
|
||||
Summary = scene.Summary,
|
||||
Stage = $"Replay saved analysis - {scene.ChapterLabel} Scene {scene.SceneNumber:N0}",
|
||||
TimelineLabel = scene.TimelineLabel,
|
||||
TimelineEvent = scene.TimelineEvent,
|
||||
ProgressPercent = replayProgress,
|
||||
ChaptersAnalysed = scene.ChaptersAnalysed,
|
||||
ChapterTotal = scene.ChapterTotal,
|
||||
ScenesAnalysed = index + 1,
|
||||
SceneTotal = total,
|
||||
ElapsedTime = $"Scene {index + 1:N0} of {total:N0}",
|
||||
EstimatedRemaining = index >= total - 1 ? "Replay complete" : "Replay mode",
|
||||
PovCharacterId = scene.PovCharacterId,
|
||||
Location = scene.Location,
|
||||
Characters = scene.Characters,
|
||||
Assets = scene.Assets,
|
||||
Relationships = scene.Relationships,
|
||||
KnowledgeThreads = scene.KnowledgeThreads,
|
||||
Observations = scene.Observations,
|
||||
LatestDiscoveries = scene.LatestDiscoveries,
|
||||
ActivityFeed = scene.ActivityFeed
|
||||
};
|
||||
}
|
||||
|
||||
private static StoryIntelligenceExperienceSceneState BuildEmptyScene(StoryIntelligenceSavedRun run)
|
||||
=> new()
|
||||
{
|
||||
@ -1379,13 +1440,21 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
int totalChapters,
|
||||
int completedChapters,
|
||||
string displayedProgressReason,
|
||||
StoryMemory storyMemory)
|
||||
StoryMemory storyMemory,
|
||||
bool replayMode = false)
|
||||
{
|
||||
var failed = snapshots.Sum(snapshot => snapshot.Parsed.Count(item => item.Scene is null));
|
||||
var warning = failed == 0 ? null : $"{failed:N0} scene result(s) skipped because they could not be parsed.";
|
||||
var failedChapterSnapshots = snapshots
|
||||
.Where(snapshot => string.Equals(snapshot.Run.Status, StoryIntelligenceRunStatuses.Failed, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(snapshot => snapshot.Run.ChapterNumber ?? decimal.MaxValue)
|
||||
.ThenBy(snapshot => snapshot.Run.StoryIntelligenceRunID)
|
||||
.ToList();
|
||||
var failedChapterSummary = string.Join("; ", failedChapterSnapshots.Select(snapshot =>
|
||||
$"Chapter {snapshot.Run.ChapterNumber:0.##} - {FirstConfigured(snapshot.Run.ErrorMessage, snapshot.Run.FailureStage, "analysis failed")}"));
|
||||
return new()
|
||||
{
|
||||
RequestedMode = "real",
|
||||
RequestedMode = replayMode ? "replay" : "real",
|
||||
CurrentImportId = session.StoryIntelligenceBookPipelineID,
|
||||
RequestedImportSessionId = session.StoryIntelligenceBookPipelineID,
|
||||
ResolvedImportSessionId = session.StoryIntelligenceBookPipelineID,
|
||||
@ -1402,9 +1471,9 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
TotalScenes = totalScenes,
|
||||
ParsedScenes = parsedScenes,
|
||||
TerminalRunSessionStatus = $"{session.Status}/{SessionStatus(session, snapshots)}",
|
||||
DisplayedProgressReason = displayedProgressReason,
|
||||
ProgressSource = "ImportSession",
|
||||
CurrentBackendStage = BackendStageDisplay(activeSnapshot.Run),
|
||||
DisplayedProgressReason = replayMode ? "Replay position through available saved scenes" : displayedProgressReason,
|
||||
ProgressSource = replayMode ? "ReplayCursor" : "ImportSession",
|
||||
CurrentBackendStage = replayMode ? "Replay saved analysis" : BackendStageDisplay(activeSnapshot.Run),
|
||||
StoryMemoryCharacterCount = storyMemory.Characters.Count,
|
||||
ResolvedCharacterCount = storyMemory.ResolvedCharacterCount,
|
||||
UnresolvedCharacterCount = storyMemory.UnresolvedCharacterCount,
|
||||
@ -1415,7 +1484,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
PovResolutionReason = storyMemory.PovResolutionReason,
|
||||
PovConfidence = storyMemory.PovConfidence,
|
||||
PovChangeDetected = storyMemory.PovChangeDetected,
|
||||
SnapshotSource = "Real",
|
||||
SnapshotSource = replayMode ? "Replay" : "Real",
|
||||
RunStatus = SessionStatus(session, snapshots),
|
||||
PersistedCompletedSceneCount = snapshots.Sum(snapshot => snapshot.Run.CompletedScenes ?? snapshot.Completed.Count),
|
||||
TotalPersistedSceneResultCount = totalPersistedResults,
|
||||
@ -1427,10 +1496,30 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
SelectedChapterAndSceneOrder = current is null ? "none" : $"Chapter {activeSnapshot.Run.ChapterNumber:0.##}, Scene {SceneSortNumber(current):0.##}",
|
||||
CurrentChangeToken = SessionChangeToken(session, snapshots),
|
||||
AnalysisCursor = FirstConfigured(activeSnapshot.Run.CurrentMessage, activeSnapshot.Run.CurrentStage, session.CurrentStage),
|
||||
Warning = warning
|
||||
Warning = replayMode && failedChapterSnapshots.Count > 0
|
||||
? $"{failedChapterSnapshots.Count:N0} failed chapter(s) skipped during replay. {failedChapterSummary}"
|
||||
: warning,
|
||||
ReplayIndex = replayMode ? 1 : 0,
|
||||
TotalReplayableScenes = replayMode ? parsedScenes : 0,
|
||||
ReplayMilestone = replayMode ? "Scene completed snapshot" : string.Empty,
|
||||
ReplayState = replayMode ? "Paused at first available scene" : string.Empty,
|
||||
SnapshotCacheStatus = replayMode ? "Reconstructed from saved analysis" : string.Empty,
|
||||
RebuildVersion = replayMode ? "21R-replay-v1" : string.Empty,
|
||||
FailedChaptersSkipped = failedChapterSnapshots.Count,
|
||||
FailedChapterSummary = failedChapterSummary,
|
||||
PanelVisibleCounts = replayMode ? "relationships:2; knowledge:3; observations:2; discoveries:4; activity:4" : string.Empty,
|
||||
PanelHiddenCounts = replayMode ? "computed in browser per replay cursor" : string.Empty,
|
||||
AiCallsDuringReplay = 0
|
||||
};
|
||||
}
|
||||
|
||||
private static string ReplaySnapshotMessage(StoryIntelligenceBookPipelineState session, IReadOnlyList<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(
|
||||
StoryIntelligenceSavedRun run,
|
||||
IReadOnlyList<StoryIntelligenceSavedSceneResult> results,
|
||||
@ -1505,6 +1594,131 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
_ => FirstConfigured(run.CurrentMessage, "Analysis snapshot is updating.")
|
||||
};
|
||||
|
||||
private async Task ApplyPersistedStoryMemoryAssignmentsAsync(int importSessionId, StoryIntelligenceExperiencePrototypeViewModel model)
|
||||
{
|
||||
var assignments = await storyMemoryRepository.ListAssignmentsAsync(importSessionId);
|
||||
if (assignments.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var characters = await storyMemoryRepository.ListCharactersAsync(importSessionId);
|
||||
var locations = await storyMemoryRepository.ListLocationsAsync(importSessionId);
|
||||
var assets = await storyMemoryRepository.ListAssetsAsync(importSessionId);
|
||||
var characterIds = characters.ToDictionary(item => item.StoryMemoryCharacterID, item => item.CanonicalIdentityKey);
|
||||
var locationIds = locations.ToDictionary(item => item.StoryMemoryLocationID, item => item.CanonicalIdentityKey);
|
||||
var assetIds = assets.ToDictionary(item => item.StoryMemoryAssetID, item => item.CanonicalIdentityKey);
|
||||
var catalogue = (await illustrationRepository.ListAsync(new IllustrationLibraryFilter()))
|
||||
.ToDictionary(item => item.IllustrationLibraryItemID);
|
||||
|
||||
var characterAssignments = BuildAssignmentLookup(assignments, StoryMemoryEntityTypes.Character, characterIds, catalogue);
|
||||
var locationAssignments = BuildAssignmentLookup(assignments, StoryMemoryEntityTypes.Location, locationIds, catalogue);
|
||||
var assetAssignments = BuildAssignmentLookup(assignments, StoryMemoryEntityTypes.Asset, assetIds, catalogue);
|
||||
|
||||
foreach (var scene in model.Scenes)
|
||||
{
|
||||
ApplyLocationAssignment(scene.Location, locationAssignments);
|
||||
foreach (var character in scene.Characters)
|
||||
{
|
||||
ApplyCharacterAssignment(character, characterAssignments);
|
||||
}
|
||||
|
||||
foreach (var asset in scene.Assets)
|
||||
{
|
||||
ApplyAssetAssignment(asset, assetAssignments);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Dictionary<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)
|
||||
{
|
||||
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 AssignmentImage(
|
||||
int IllustrationLibraryItemId,
|
||||
string StableCode,
|
||||
string Status,
|
||||
string PublicUrl,
|
||||
decimal Confidence,
|
||||
int MatchScore,
|
||||
string? Reason);
|
||||
|
||||
private sealed record CharacterCodeProfile(
|
||||
string Code,
|
||||
string AgeBand,
|
||||
|
||||
513
PlotLine/Services/StoryMemoryServices.cs
Normal file
513
PlotLine/Services/StoryMemoryServices.cs
Normal 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);
|
||||
}
|
||||
256
PlotLine/Sql/142_Phase21S_DurableStoryMemory.sql
Normal file
256
PlotLine/Sql/142_Phase21S_DurableStoryMemory.sql
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user