Compare commits

...

10 Commits

25 changed files with 4608 additions and 271 deletions

View File

@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using PlotLine.Models; using PlotLine.Models;
using PlotLine.Services; using PlotLine.Services;
using PlotLine.ViewModels;
var tests = new (string Name, Action Test)[] var tests = new (string Name, Action Test)[]
{ {
@ -34,6 +35,8 @@ var tests = new (string Name, Action Test)[]
("Illustration starter batch has required category counts", IllustrationStarterBatchHasRequiredCategoryCounts), ("Illustration starter batch has required category counts", IllustrationStarterBatchHasRequiredCategoryCounts),
("Illustration starter generation plan skips successful items", IllustrationStarterGenerationPlanSkipsSuccessfulItems), ("Illustration starter generation plan skips successful items", IllustrationStarterGenerationPlanSkipsSuccessfulItems),
("Story Intelligence prototype uses starter illustration codes", StoryIntelligencePrototypeUsesStarterIllustrationCodes), ("Story Intelligence prototype uses starter illustration codes", StoryIntelligencePrototypeUsesStarterIllustrationCodes),
("Story Intelligence simulation mode remains available", StoryIntelligenceSimulationModeRemainsAvailable),
("Story Intelligence visualisation contract omits raw manuscript text", StoryIntelligenceVisualisationContractOmitsRawManuscriptText),
("Illustration bulk retry result reports counts", IllustrationBulkRetryResultReportsCounts), ("Illustration bulk retry result reports counts", IllustrationBulkRetryResultReportsCounts),
("Illustration bulk retry skips blocking duplicate stable codes", IllustrationBulkRetrySkipsBlockingDuplicateStableCodes), ("Illustration bulk retry skips blocking duplicate stable codes", IllustrationBulkRetrySkipsBlockingDuplicateStableCodes),
("Illustration provider reports missing image model", IllustrationProviderReportsMissingImageModel), ("Illustration provider reports missing image model", IllustrationProviderReportsMissingImageModel),
@ -450,6 +453,43 @@ static void StoryIntelligencePrototypeUsesStarterIllustrationCodes()
Assert(prototype.Scenes.SelectMany(scene => scene.Assets).All(asset => asset.ImagePath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase)), "Asset SVG fallback should remain before library resolution."); Assert(prototype.Scenes.SelectMany(scene => scene.Assets).All(asset => asset.ImagePath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase)), "Asset SVG fallback should remain before library resolution.");
} }
static void StoryIntelligenceSimulationModeRemainsAvailable()
{
var prototype = StoryIntelligenceExperiencePrototypeData.Build();
Assert(prototype.Scenes.Count >= 7, "Simulation should keep the existing multi-scene visual dataset.");
Assert(prototype.Scenes.Any(scene => scene.PovCharacterId == "rosie"), "Simulation should still exercise POV transitions.");
}
static void StoryIntelligenceVisualisationContractOmitsRawManuscriptText()
{
var model = new StoryIntelligenceExperiencePrototypeViewModel
{
Mode = "real",
RunId = 42,
ChangeToken = "stable-token",
ProjectName = "Project",
BookTitle = "Book",
Scenes =
[
new StoryIntelligenceExperienceSceneState
{
Id = "scene-result-1",
SceneNumber = 1,
Title = "Analysed scene",
Summary = "Safe summary",
Location = new StoryIntelligenceExperienceLocationState { Id = "location-result-room", Name = "Room" }
}
]
};
var json = JsonSerializer.Serialize(model, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
Assert(json.Contains("\"changeToken\":\"stable-token\"", StringComparison.Ordinal), "Snapshot contract should expose a change token.");
Assert(!json.Contains("sourceText", StringComparison.OrdinalIgnoreCase), "Snapshot contract should not expose raw manuscript source text.");
Assert(!json.Contains("rawResponseJson", StringComparison.OrdinalIgnoreCase), "Snapshot contract should not expose raw AI responses.");
Assert(!json.Contains("prompt", StringComparison.OrdinalIgnoreCase), "Snapshot contract should not expose prompts.");
}
static void IllustrationBulkRetryResultReportsCounts() static void IllustrationBulkRetryResultReportsCounts()
{ {
var result = new IllustrationBulkRetryResult(12, 4, 3); var result = new IllustrationBulkRetryResult(12, 4, 3);

View File

@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using PlotLine.Services; using PlotLine.Services;
using PlotLine.ViewModels;
namespace PlotLine.Controllers; namespace PlotLine.Controllers;
@ -8,18 +9,93 @@ namespace PlotLine.Controllers;
[Route("Development")] [Route("Development")]
public sealed class DevelopmentController( public sealed class DevelopmentController(
IWebHostEnvironment environment, IWebHostEnvironment environment,
IIllustrationLibraryService illustrationLibrary) : Controller ICurrentUserService currentUser,
IIllustrationLibraryService illustrationLibrary,
IStoryIntelligenceVisualisationSnapshotService snapshots) : Controller
{ {
[HttpGet("StoryIntelligenceExperience")] [HttpGet("StoryIntelligenceExperience")]
public async Task<IActionResult> StoryIntelligenceExperience() public async Task<IActionResult> StoryIntelligenceExperience([FromQuery] string? mode, [FromQuery] int? importSessionId, [FromQuery] int? runId)
{ {
if (!environment.IsDevelopment()) if (!environment.IsDevelopment())
{ {
return NotFound(); return NotFound();
} }
var model = StoryIntelligenceExperiencePrototypeData.Build(); var requestedMode = string.IsNullOrWhiteSpace(mode) ? "real" : mode.Trim();
await illustrationLibrary.ApplyApprovedIllustrationsAsync(model); var simulationRequested = string.Equals(requestedMode, "simulation", StringComparison.OrdinalIgnoreCase);
return View(model);
if (!simulationRequested && importSessionId.HasValue && currentUser.UserId is int userId)
{
var realModel = await snapshots.BuildImportSessionSnapshotAsync(importSessionId.Value, userId);
if (realModel is null)
{
return View(ErrorModel(
requestedMode,
importSessionId,
$"Import Session {importSessionId.Value:N0} could not be found for the current user, or it has no persisted chapter analysis runs yet."));
}
return View(realModel);
}
if (!simulationRequested && runId.HasValue && currentUser.UserId is int legacyUserId)
{
var realModel = await snapshots.BuildSnapshotAsync(runId.Value, legacyUserId);
if (realModel is null)
{
return View(ErrorModel(
requestedMode,
null,
$"Run {runId.Value:N0} could not be found for the current user. Live visualisation URLs should use importSessionId for book imports."));
}
return View(realModel);
}
if (simulationRequested)
{
var model = StoryIntelligenceExperiencePrototypeData.Build();
await illustrationLibrary.ApplyApprovedIllustrationsAsync(model);
return View(model);
}
if (currentUser.UserId is int selectorUserId)
{
var sessions = await snapshots.ListImportSessionOptionsAsync(selectorUserId);
return View(new StoryIntelligenceExperiencePrototypeViewModel
{
Mode = "selector",
AvailableRuns = sessions,
ProjectName = "Development",
BookTitle = "Story Intelligence",
SnapshotMessage = sessions.Count == 0
? "No persisted Story Intelligence import sessions were found for the current user. Start a real import, then refresh this page."
: "Choose a Story Intelligence import session, or explicitly open simulation mode.",
Diagnostics = new StoryIntelligenceExperienceSnapshotDiagnostics
{
RequestedMode = requestedMode,
RequestedImportSessionId = importSessionId,
SnapshotSource = "Real"
}
});
}
return View(ErrorModel(requestedMode, importSessionId, "No development user is signed in."));
} }
private static StoryIntelligenceExperiencePrototypeViewModel ErrorModel(string requestedMode, int? importSessionId, string message)
=> new()
{
Mode = "error",
ProjectName = "Development",
BookTitle = "Story Intelligence",
SnapshotMessage = message,
Diagnostics = new StoryIntelligenceExperienceSnapshotDiagnostics
{
RequestedMode = requestedMode,
RequestedImportSessionId = importSessionId,
SnapshotSource = "Real",
Warning = message
}
};
} }

View File

@ -0,0 +1,36 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PlotLine.Services;
namespace PlotLine.Controllers;
[Authorize]
[Route("api/story-intelligence")]
public sealed class StoryIntelligenceVisualisationController(
ICurrentUserService currentUser,
IStoryIntelligenceVisualisationSnapshotService snapshots) : ControllerBase
{
[HttpGet("runs/{runId:int}/visualisation-snapshot")]
public async Task<IActionResult> GetSnapshot(int runId)
{
if (currentUser.UserId is not int userId)
{
return Unauthorized();
}
var snapshot = await snapshots.BuildSnapshotAsync(runId, userId);
return snapshot is null ? NotFound() : Ok(snapshot);
}
[HttpGet("import-sessions/{importSessionId:int}/visualisation-snapshot")]
public async Task<IActionResult> GetImportSessionSnapshot(int importSessionId)
{
if (currentUser.UserId is not int userId)
{
return Unauthorized();
}
var snapshot = await snapshots.BuildImportSessionSnapshotAsync(importSessionId, userId);
return snapshot is null ? NotFound() : Ok(snapshot);
}
}

View File

@ -0,0 +1,93 @@
using Dapper;
using PlotLine.Models;
namespace PlotLine.Data;
public interface IStoryIntelligenceIllustrationMatchingRepository
{
Task<IReadOnlyList<StoryIntelligenceCharacterIllustrationAssignment>> ListAssignmentsAsync(int projectId);
Task<StoryIntelligenceCharacterIllustrationAssignment> UpsertAssignmentAsync(StoryIntelligenceCharacterIllustrationAssignmentSave request);
Task<StoryIntelligenceIllustrationDemand> UpsertDemandAsync(StoryIntelligenceIllustrationDemandRequest request);
}
public sealed class StoryIntelligenceIllustrationMatchingRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligenceIllustrationMatchingRepository
{
public async Task<IReadOnlyList<StoryIntelligenceCharacterIllustrationAssignment>> ListAssignmentsAsync(int projectId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<StoryIntelligenceCharacterIllustrationAssignment>(
"""
SELECT *
FROM dbo.StoryIntelligenceCharacterIllustrationAssignments
WHERE ProjectID = @ProjectID
AND IsActive = 1;
""",
new { ProjectID = projectId });
return rows.ToList();
}
public async Task<StoryIntelligenceCharacterIllustrationAssignment> UpsertAssignmentAsync(StoryIntelligenceCharacterIllustrationAssignmentSave request)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<StoryIntelligenceCharacterIllustrationAssignment>(
"""
SET XACT_ABORT ON;
MERGE dbo.StoryIntelligenceCharacterIllustrationAssignments WITH (HOLDLOCK) AS target
USING (SELECT @ProjectID AS ProjectID, @CharacterKey AS CharacterKey) AS source
ON target.ProjectID = source.ProjectID
AND target.CharacterKey = source.CharacterKey
AND target.IsActive = 1
WHEN MATCHED THEN
UPDATE SET
CharacterName = @CharacterName,
IllustrationLibraryItemID = @IllustrationLibraryItemID,
StableCode = @StableCode,
MatchConfidence = @MatchConfidence,
MatchingScore = @MatchingScore,
EvidenceJson = @EvidenceJson,
CandidateDiagnosticsJson = @CandidateDiagnosticsJson,
ReasonChosen = @ReasonChosen,
ReasonReassigned = @ReasonReassigned,
UpdatedUtc = SYSUTCDATETIME()
WHEN NOT MATCHED THEN
INSERT (ProjectID, CharacterKey, CharacterName, IllustrationLibraryItemID, StableCode, MatchConfidence, MatchingScore, EvidenceJson, CandidateDiagnosticsJson, ReasonChosen, ReasonReassigned)
VALUES (@ProjectID, @CharacterKey, @CharacterName, @IllustrationLibraryItemID, @StableCode, @MatchConfidence, @MatchingScore, @EvidenceJson, @CandidateDiagnosticsJson, @ReasonChosen, @ReasonReassigned);
SELECT *
FROM dbo.StoryIntelligenceCharacterIllustrationAssignments
WHERE ProjectID = @ProjectID
AND CharacterKey = @CharacterKey
AND IsActive = 1;
""",
request);
}
public async Task<StoryIntelligenceIllustrationDemand> UpsertDemandAsync(StoryIntelligenceIllustrationDemandRequest request)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<StoryIntelligenceIllustrationDemand>(
"""
SET XACT_ABORT ON;
MERGE dbo.StoryIntelligenceIllustrationDemands WITH (HOLDLOCK) AS target
USING (SELECT @ProjectID AS ProjectID, @DemandKey AS DemandKey) AS source
ON ISNULL(target.ProjectID, -1) = ISNULL(source.ProjectID, -1)
AND target.DemandKey = source.DemandKey
WHEN MATCHED THEN
UPDATE SET
RequestCount = RequestCount + 1,
LastRequestedUtc = SYSUTCDATETIME(),
UpdatedUtc = SYSUTCDATETIME()
WHEN NOT MATCHED THEN
INSERT (ProjectID, DemandKey, AgeBand, Presentation, HairColour, SkinTone, HairLength, Glasses, FacialHair)
VALUES (@ProjectID, @DemandKey, @AgeBand, @Presentation, @HairColour, @SkinTone, @HairLength, @Glasses, @FacialHair);
SELECT *
FROM dbo.StoryIntelligenceIllustrationDemands
WHERE ISNULL(ProjectID, -1) = ISNULL(@ProjectID, -1)
AND DemandKey = @DemandKey;
""",
request);
}
}

View File

@ -6,6 +6,7 @@ namespace PlotLine.Data;
public interface IStoryIntelligencePipelineRepository public interface IStoryIntelligencePipelineRepository
{ {
Task<StoryIntelligenceBookPipelineState?> GetByIdForUserAsync(int importSessionId, int userId);
Task<StoryIntelligenceBookPipelineState?> GetByBookAsync(int bookId); Task<StoryIntelligenceBookPipelineState?> GetByBookAsync(int bookId);
Task<StoryIntelligenceBookPipelineState?> GetByBookForUserAsync(int bookId, int userId); Task<StoryIntelligenceBookPipelineState?> GetByBookForUserAsync(int bookId, int userId);
Task<IReadOnlyList<StoryIntelligenceBookPipelineState>> ListForUserAsync(int userId); Task<IReadOnlyList<StoryIntelligenceBookPipelineState>> ListForUserAsync(int userId);
@ -15,6 +16,15 @@ public interface IStoryIntelligencePipelineRepository
public sealed class StoryIntelligencePipelineRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligencePipelineRepository public sealed class StoryIntelligencePipelineRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligencePipelineRepository
{ {
public async Task<StoryIntelligenceBookPipelineState?> GetByIdForUserAsync(int importSessionId, int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceBookPipelineState>(
"dbo.StoryIntelligenceBookPipeline_GetByIDForUser",
new { StoryIntelligenceBookPipelineID = importSessionId, UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task<StoryIntelligenceBookPipelineState?> GetByBookAsync(int bookId) public async Task<StoryIntelligenceBookPipelineState?> GetByBookAsync(int bookId)
{ {
using var connection = connectionFactory.CreateConnection(); using var connection = connectionFactory.CreateConnection();

View File

@ -30,6 +30,7 @@ public interface IStoryIntelligenceResultRepository
Task<int> SaveChapterResultAsync(int runId, StoryIntelligenceChapterResultSaveRequest chapter); Task<int> SaveChapterResultAsync(int runId, StoryIntelligenceChapterResultSaveRequest chapter);
Task<int> SaveSceneResultAsync(int runId, int? chapterResultId, StoryIntelligenceSceneResultSaveRequest scene); Task<int> SaveSceneResultAsync(int runId, int? chapterResultId, StoryIntelligenceSceneResultSaveRequest scene);
Task<IReadOnlyList<StoryIntelligenceSavedRunListItem>> ListRunsAsync(); Task<IReadOnlyList<StoryIntelligenceSavedRunListItem>> ListRunsAsync();
Task<IReadOnlyList<StoryIntelligenceSavedRun>> ListRunsByBookForUserAsync(int bookId, int userId);
Task<StoryIntelligenceSavedRun?> GetRunAsync(int runId); Task<StoryIntelligenceSavedRun?> GetRunAsync(int runId);
Task<StoryIntelligenceSavedChapterResult?> GetChapterResultAsync(int runId); Task<StoryIntelligenceSavedChapterResult?> GetChapterResultAsync(int runId);
Task<IReadOnlyList<StoryIntelligenceSavedSceneResult>> ListSceneResultsAsync(int runId); Task<IReadOnlyList<StoryIntelligenceSavedSceneResult>> ListSceneResultsAsync(int runId);
@ -350,6 +351,16 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
return rows.ToList(); return rows.ToList();
} }
public async Task<IReadOnlyList<StoryIntelligenceSavedRun>> ListRunsByBookForUserAsync(int bookId, int userId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<StoryIntelligenceSavedRun>(
"dbo.StoryIntelligenceRun_ListByBookForUser",
new { BookID = bookId, UserID = userId },
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<StoryIntelligenceSavedRun?> GetRunAsync(int runId) public async Task<StoryIntelligenceSavedRun?> GetRunAsync(int runId)
{ {
using var connection = connectionFactory.CreateConnection(); using var connection = connectionFactory.CreateConnection();

View File

@ -0,0 +1,60 @@
namespace PlotLine.Models;
public sealed class StoryIntelligenceCharacterIllustrationAssignment
{
public int StoryIntelligenceCharacterIllustrationAssignmentID { get; init; }
public int ProjectID { get; init; }
public string CharacterKey { get; init; } = string.Empty;
public string CharacterName { get; init; } = string.Empty;
public int? IllustrationLibraryItemID { get; init; }
public string? StableCode { get; init; }
public decimal MatchConfidence { get; init; }
public int MatchingScore { get; init; }
public string? EvidenceJson { get; init; }
public string? CandidateDiagnosticsJson { get; init; }
public string? ReasonChosen { get; init; }
public string? ReasonReassigned { get; init; }
public bool IsActive { get; init; }
public DateTime CreatedUtc { get; init; }
public DateTime UpdatedUtc { get; init; }
}
public sealed record StoryIntelligenceCharacterIllustrationAssignmentSave(
int ProjectID,
string CharacterKey,
string CharacterName,
int? IllustrationLibraryItemID,
string? StableCode,
decimal MatchConfidence,
int MatchingScore,
string? EvidenceJson,
string? CandidateDiagnosticsJson,
string? ReasonChosen,
string? ReasonReassigned);
public sealed class StoryIntelligenceIllustrationDemand
{
public int StoryIntelligenceIllustrationDemandID { get; init; }
public int? ProjectID { get; init; }
public string DemandKey { get; init; } = string.Empty;
public string AgeBand { get; init; } = string.Empty;
public string Presentation { get; init; } = string.Empty;
public string HairColour { get; init; } = string.Empty;
public string SkinTone { get; init; } = string.Empty;
public string? HairLength { get; init; }
public string? Glasses { get; init; }
public string? FacialHair { get; init; }
public int RequestCount { get; init; }
public DateTime LastRequestedUtc { get; init; }
}
public sealed record StoryIntelligenceIllustrationDemandRequest(
int? ProjectID,
string DemandKey,
string AgeBand,
string Presentation,
string HairColour,
string SkinTone,
string? HairLength,
string? Glasses,
string? FacialHair);

View File

@ -144,6 +144,7 @@ public class Program
builder.Services.AddScoped<IStoryIntelligenceResultRepository, StoryIntelligenceResultRepository>(); builder.Services.AddScoped<IStoryIntelligenceResultRepository, StoryIntelligenceResultRepository>();
builder.Services.AddScoped<IStoryIntelligenceSourceRepository, StoryIntelligenceSourceRepository>(); builder.Services.AddScoped<IStoryIntelligenceSourceRepository, StoryIntelligenceSourceRepository>();
builder.Services.AddScoped<IStoryIntelligencePipelineRepository, StoryIntelligencePipelineRepository>(); builder.Services.AddScoped<IStoryIntelligencePipelineRepository, StoryIntelligencePipelineRepository>();
builder.Services.AddScoped<IStoryIntelligenceIllustrationMatchingRepository, StoryIntelligenceIllustrationMatchingRepository>();
builder.Services.AddScoped<IIllustrationLibraryRepository, IllustrationLibraryRepository>(); builder.Services.AddScoped<IIllustrationLibraryRepository, IllustrationLibraryRepository>();
builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>(); builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>();
builder.Services.AddScoped<IProjectCollaborationRepository, ProjectCollaborationRepository>(); builder.Services.AddScoped<IProjectCollaborationRepository, ProjectCollaborationRepository>();
@ -213,6 +214,7 @@ public class Program
builder.Services.AddScoped<IStoryIntelligenceAssetImportService, StoryIntelligenceAssetImportService>(); builder.Services.AddScoped<IStoryIntelligenceAssetImportService, StoryIntelligenceAssetImportService>();
builder.Services.AddScoped<IStoryIntelligenceRelationshipImportService, StoryIntelligenceRelationshipImportService>(); builder.Services.AddScoped<IStoryIntelligenceRelationshipImportService, StoryIntelligenceRelationshipImportService>();
builder.Services.AddScoped<IStoryIntelligenceKnowledgeImportService, StoryIntelligenceKnowledgeImportService>(); builder.Services.AddScoped<IStoryIntelligenceKnowledgeImportService, StoryIntelligenceKnowledgeImportService>();
builder.Services.AddScoped<IStoryIntelligenceVisualisationSnapshotService, StoryIntelligenceVisualisationSnapshotService>();
builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>(); builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>();
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>(); builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>(); builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();
@ -222,6 +224,7 @@ public class Program
builder.Services.AddHttpClient<IIllustrationImageProvider, OpenAIIllustrationImageProvider>(); builder.Services.AddHttpClient<IIllustrationImageProvider, OpenAIIllustrationImageProvider>();
builder.Services.AddScoped<IIllustrationGenerationRunner, IllustrationGenerationRunner>(); builder.Services.AddScoped<IIllustrationGenerationRunner, IllustrationGenerationRunner>();
builder.Services.AddScoped<IIllustrationLibraryService, IllustrationLibraryService>(); builder.Services.AddScoped<IIllustrationLibraryService, IllustrationLibraryService>();
builder.Services.AddScoped<IStoryIntelligenceIllustrationMatchingService, StoryIntelligenceIllustrationMatchingService>();
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>(); builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>(); builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
builder.Services.AddScoped<IStripeBillingService, StripeBillingService>(); builder.Services.AddScoped<IStripeBillingService, StripeBillingService>();

View File

@ -32,7 +32,23 @@ public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder
throw new ArgumentException(string.Join(" ", errors), nameof(specification)); throw new ArgumentException(string.Join(" ", errors), nameof(specification));
} }
var metadataJson = JsonSerializer.Serialize(specification.Metadata, JsonOptions); var metadata = new Dictionary<string, string>(specification.Metadata, StringComparer.OrdinalIgnoreCase);
AddMetadata("category", specification.Category);
AddMetadata("metadataConfidence", "High");
if (string.Equals(specification.Category, IllustrationLibraryCategories.Character, StringComparison.OrdinalIgnoreCase))
{
AddMetadata("ageBand", specification.ApparentAgeBand);
AddMetadata("presentation", specification.Presentation);
AddMetadata("hairColour", specification.HairColour);
AddMetadata("skinTone", specification.SkinTone);
AddMetadata("hairLength", specification.HairLength);
AddMetadata("glasses", specification.Features.Any(feature => feature.Contains("glasses", StringComparison.OrdinalIgnoreCase)) ? "Yes" : "Unknown");
AddMetadata("facialHair", specification.Features.FirstOrDefault(feature => feature.Contains("beard", StringComparison.OrdinalIgnoreCase) || feature.Contains("moustache", StringComparison.OrdinalIgnoreCase)));
AddMetadata("clothingEra", specification.ClothingEra);
AddMetadata("clothingStyle", specification.ClothingStyle);
}
var metadataJson = JsonSerializer.Serialize(metadata, JsonOptions);
var specificationJson = JsonSerializer.Serialize(specification, JsonOptions); var specificationJson = JsonSerializer.Serialize(specification, JsonOptions);
var palette = specification.Palette.Count == 0 ? "balanced editorial colour, not monochrome" : string.Join(", ", specification.Palette); var palette = specification.Palette.Count == 0 ? "balanced editorial colour, not monochrome" : string.Join(", ", specification.Palette);
var tags = specification.MetadataTags.Count == 0 ? "generic reusable story identifier" : string.Join(", ", specification.MetadataTags); var tags = specification.MetadataTags.Count == 0 ? "generic reusable story identifier" : string.Join(", ", specification.MetadataTags);
@ -71,6 +87,14 @@ public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder
"""; """;
return new(prompt, CurrentTemplateVersion, specificationJson, metadataJson); return new(prompt, CurrentTemplateVersion, specificationJson, metadataJson);
void AddMetadata(string key, string? value)
{
if (!string.IsNullOrWhiteSpace(value))
{
metadata[key] = value;
}
}
} }
private static string CategoryRules(string category) private static string CategoryRules(string category)

View File

@ -13,6 +13,7 @@ public sealed class StoryIntelligenceExistingChapterQueueService(
IStoryIntelligenceSourceRepository sources, IStoryIntelligenceSourceRepository sources,
IStoryIntelligenceResultRepository runs, IStoryIntelligenceResultRepository runs,
IStoryIntelligenceClient client, IStoryIntelligenceClient client,
IStoryIntelligencePipelineStateService pipelines,
ILogger<StoryIntelligenceExistingChapterQueueService> logger) : IStoryIntelligenceExistingChapterQueueService ILogger<StoryIntelligenceExistingChapterQueueService> logger) : IStoryIntelligenceExistingChapterQueueService
{ {
private const string NoStoredChapterTextMessage = "No stored chapter text is currently available for this chapter. A future Word Companion/document extraction phase is required."; private const string NoStoredChapterTextMessage = "No stored chapter text is currently available for this chapter. A future Word Companion/document extraction phase is required.";
@ -77,6 +78,7 @@ public sealed class StoryIntelligenceExistingChapterQueueService(
clientStatus.SceneIntelligenceModel), clientStatus.SceneIntelligenceModel),
PromptVersionsSummary = "Chapter=Chapter-Structure-Prompt-V2; Scene=Scene-Prompt-V2" PromptVersionsSummary = "Chapter=Chapter-Structure-Prompt-V2; Scene=Scene-Prompt-V2"
}); });
await pipelines.RecordChapterAnalysisQueuedAsync(selected.ProjectID, selected.BookID, runId);
logger.LogInformation( logger.LogInformation(
"Queued Story Intelligence run {StoryIntelligenceRunID} from existing chapter {ChapterID}. ProjectID={ProjectID} BookID={BookID} SourceWordCount={SourceWordCount} SourceCharacterCount={SourceCharacterCount}", "Queued Story Intelligence run {StoryIntelligenceRunID} from existing chapter {ChapterID}. ProjectID={ProjectID} BookID={BookID} SourceWordCount={SourceWordCount} SourceCharacterCount={SourceCharacterCount}",

View File

@ -9,6 +9,12 @@ public static class StoryIntelligenceExperiencePrototypeData
public static StoryIntelligenceExperiencePrototypeViewModel Build() public static StoryIntelligenceExperiencePrototypeViewModel Build()
=> new() => new()
{ {
Mode = "simulation",
Diagnostics = new StoryIntelligenceExperienceSnapshotDiagnostics
{
RequestedMode = "simulation",
SnapshotSource = "Simulation"
},
ProjectName = "Alpha Flame", ProjectName = "Alpha Flame",
BookTitle = "The Doweries Manuscript", BookTitle = "The Doweries Manuscript",
Scenes = Scenes =
@ -150,6 +156,7 @@ public static class StoryIntelligenceExperiencePrototypeData
=> new() => new()
{ {
Id = id, Id = id,
ChapterLabel = $"Chapter {Math.Max(1, chapters)}",
SceneNumber = number, SceneNumber = number,
Title = title, Title = title,
Summary = summary, Summary = summary,
@ -161,6 +168,7 @@ public static class StoryIntelligenceExperiencePrototypeData
ChapterTotal = 5, ChapterTotal = 5,
ScenesAnalysed = number, ScenesAnalysed = number,
SceneTotal = sceneTotal, SceneTotal = sceneTotal,
ElapsedTime = $"{Math.Max(1, number * 2)}m",
EstimatedRemaining = remaining, EstimatedRemaining = remaining,
PovCharacterId = pov, PovCharacterId = pov,
Location = location, Location = location,

View File

@ -16,6 +16,7 @@ public interface IStoryIntelligenceFullChapterTestService
public sealed class StoryIntelligenceFullChapterTestService( public sealed class StoryIntelligenceFullChapterTestService(
IStoryIntelligenceResultRepository runs, IStoryIntelligenceResultRepository runs,
IStoryIntelligencePipelineStateService pipelines,
IOptions<StoryIntelligenceOptions> options, IOptions<StoryIntelligenceOptions> options,
IOptions<StoryIntelligencePricingOptions> pricingOptions, IOptions<StoryIntelligencePricingOptions> pricingOptions,
ILogger<StoryIntelligenceFullChapterTestService> logger) : IStoryIntelligenceFullChapterTestService ILogger<StoryIntelligenceFullChapterTestService> logger) : IStoryIntelligenceFullChapterTestService
@ -104,6 +105,10 @@ public sealed class StoryIntelligenceFullChapterTestService(
Model = StoryIntelligenceOptions.BuildModelSummary(modelSelection.ChapterStructureModel, modelSelection.SceneIntelligenceModel), Model = StoryIntelligenceOptions.BuildModelSummary(modelSelection.ChapterStructureModel, modelSelection.SceneIntelligenceModel),
PromptVersionsSummary = PromptVersionsSummary PromptVersionsSummary = PromptVersionsSummary
}); });
if (form.ProjectID is int projectId && form.BookID is int bookId)
{
await pipelines.RecordChapterAnalysisQueuedAsync(projectId, bookId, runId);
}
logger.LogInformation( logger.LogInformation(
"Queued full chapter Story Intelligence test run {StoryIntelligenceRunID}. UserID={UserID} ProjectID={ProjectID} BookID={BookID} ChapterID={ChapterID} CharacterCount={CharacterCount} WordCount={WordCount} ParagraphCount={ParagraphCount} EstimatedInputTokens={EstimatedInputTokens} ChapterStructureModel={ChapterStructureModel} SceneIntelligenceModel={SceneIntelligenceModel} SourceFileName={SourceFileName}", "Queued full chapter Story Intelligence test run {StoryIntelligenceRunID}. UserID={UserID} ProjectID={ProjectID} BookID={BookID} ChapterID={ChapterID} CharacterCount={CharacterCount} WordCount={WordCount} ParagraphCount={ParagraphCount} EstimatedInputTokens={EstimatedInputTokens} ChapterStructureModel={ChapterStructureModel} SceneIntelligenceModel={SceneIntelligenceModel} SourceFileName={SourceFileName}",

View File

@ -0,0 +1,729 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Security.Cryptography;
using System.Text;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface IStoryIntelligenceIllustrationMatchingService
{
Task ApplyCharacterIllustrationsAsync(int projectId, StoryIntelligenceExperiencePrototypeViewModel prototype);
}
public sealed class StoryIntelligenceIllustrationMatchingService(
IIllustrationLibraryRepository library,
IStoryIntelligenceIllustrationMatchingRepository assignments,
IIllustrationPromptBuilder promptBuilder,
IUploadStorageService uploadStorage,
ILogger<StoryIntelligenceIllustrationMatchingService> logger) : IStoryIntelligenceIllustrationMatchingService
{
private const int SuitableScore = 48;
private const int ReassignmentMargin = 24;
private const int DemandGenerationThreshold = 3;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
public async Task ApplyCharacterIllustrationsAsync(int projectId, StoryIntelligenceExperiencePrototypeViewModel prototype)
{
if (projectId <= 0 || prototype.Scenes.Count == 0)
{
return;
}
var catalogue = (await library.ListAsync(new IllustrationLibraryFilter { Category = IllustrationLibraryCategories.Character }))
.Where(item => item.Status is IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Generated)
.Select(CharacterCandidate.From)
.Where(candidate => candidate is not null)
.Cast<CharacterCandidate>()
.Select(candidate => candidate with { PublicUrl = ResolveExistingPublicUploadUrl(candidate.Item.ThumbnailPath) ?? ResolveExistingPublicUploadUrl(candidate.Item.FilePath) })
.Where(candidate => !string.IsNullOrWhiteSpace(candidate.PublicUrl))
.ToList();
if (catalogue.Count == 0)
{
logger.LogWarning("No generated character illustrations are available for Story Intelligence matching.");
return;
}
var existingAssignments = (await assignments.ListAssignmentsAsync(projectId))
.ToDictionary(item => item.CharacterKey, StringComparer.OrdinalIgnoreCase);
var assignedItemIds = existingAssignments.Values
.Where(item => item.IllustrationLibraryItemID.HasValue)
.Select(item => item.IllustrationLibraryItemID!.Value)
.ToHashSet();
var duplicateExistingItemIds = existingAssignments.Values
.Where(item => item.IllustrationLibraryItemID.HasValue)
.GroupBy(item => item.IllustrationLibraryItemID!.Value)
.Where(group => group.Select(item => item.CharacterKey).Distinct(StringComparer.OrdinalIgnoreCase).Count() > 1)
.Select(group => group.Key)
.ToHashSet();
var retainedExistingItemIds = new HashSet<int>();
var observations = BuildCharacterObservations(prototype);
foreach (var observation in observations.Values.OrderByDescending(item => item.Significance).ThenBy(item => item.Name))
{
var existing = existingAssignments.GetValueOrDefault(observation.CharacterKey);
var ownExistingItemId = existing?.IllustrationLibraryItemID;
if (ownExistingItemId.HasValue && duplicateExistingItemIds.Contains(ownExistingItemId.Value) && !retainedExistingItemIds.Add(ownExistingItemId.Value))
{
ownExistingItemId = null;
existing = null;
}
var allScores = catalogue
.Select(candidate => Score(
candidate,
observation,
assignedItemIds.Contains(candidate.Item.IllustrationLibraryItemID)
&& candidate.Item.IllustrationLibraryItemID != ownExistingItemId))
.OrderByDescending(score => score.Score)
.ThenBy(score => score.IsAlreadyAssignedToAnotherSignificantCharacter)
.ThenByDescending(score => score.Candidate.Item.Status == IllustrationLibraryStatuses.Approved)
.ThenByDescending(score => score.Candidate.Item.UpdatedUtc)
.ToList();
var existingScore = existing?.IllustrationLibraryItemID is int existingItemId
? allScores.FirstOrDefault(score => score.Candidate.Item.IllustrationLibraryItemID == existingItemId)
: null;
var best = allScores
.Where(score => !score.IsAlreadyAssignedToAnotherSignificantCharacter)
.FirstOrDefault(score => score.Score >= SuitableScore);
var chosen = ChooseAssignment(observation, existing, existingScore, best);
if (chosen is null)
{
var demandStatus = await RecordDemandAsync(projectId, observation, allScores);
ApplyDemandDiagnostics(prototype, observation, allScores, demandStatus);
continue;
}
var reasonReassigned = existingScore is not null && chosen.Candidate.Item.IllustrationLibraryItemID != existing?.IllustrationLibraryItemID
? ReassignmentReason(observation, existingScore, chosen)
: null;
var reason = reasonReassigned ?? (existing is null
? $"Assigned {chosen.Candidate.Item.StableCode} from structured character evidence."
: "Preserved existing Story Intelligence character illustration assignment.");
var candidateDiagnostics = JsonSerializer.Serialize(allScores.Take(6).Select(MatchDiagnostics.From), JsonOptions);
var evidenceJson = JsonSerializer.Serialize(observation.Evidence, JsonOptions);
var saved = await assignments.UpsertAssignmentAsync(new StoryIntelligenceCharacterIllustrationAssignmentSave(
projectId,
observation.CharacterKey,
observation.Name,
chosen.Candidate.Item.IllustrationLibraryItemID,
chosen.Candidate.Item.StableCode,
Confidence(chosen.Score),
chosen.Score,
evidenceJson,
candidateDiagnostics,
reason,
reasonReassigned));
assignedItemIds.Add(chosen.Candidate.Item.IllustrationLibraryItemID);
ApplyAssignment(prototype, observation, chosen, saved, reason, reasonReassigned, candidateDiagnostics);
}
}
private MatchScore? ChooseAssignment(
CharacterObservation observation,
StoryIntelligenceCharacterIllustrationAssignment? existing,
MatchScore? existingScore,
MatchScore? best)
{
if (existingScore is not null && existingScore.Candidate.PublicUrl is not null)
{
if (best is null)
{
return existingScore;
}
var materiallyWrong = MateriallyWrong(existingScore.Candidate.Metadata, observation.Evidence);
if (materiallyWrong && observation.Evidence.HasStrongPresentationEvidence)
{
return best;
}
if (!materiallyWrong || best.Score < existingScore.Score + ReassignmentMargin)
{
return existingScore;
}
}
if (best is not null)
{
return best;
}
return existing is null ? null : existingScore;
}
private static bool MateriallyWrong(CharacterIllustrationMetadata candidate, CharacterEvidence evidence)
=> KnownMismatch(evidence.AgeBand, candidate.AgeBand)
|| KnownMismatch(evidence.Presentation, candidate.Presentation)
|| KnownMismatch(evidence.HairColour, candidate.HairColour)
|| KnownMismatch(evidence.SkinTone, candidate.SkinTone);
private static bool KnownMismatch(string value, string candidate)
=> !IsUnknown(value) && !IsUnknown(candidate) && !string.Equals(value, candidate, StringComparison.OrdinalIgnoreCase);
private async Task<string> RecordDemandAsync(int projectId, CharacterObservation observation, IReadOnlyList<MatchScore> allScores)
{
var evidence = observation.Evidence;
var request = new StoryIntelligenceIllustrationDemandRequest(
projectId,
DemandKey(evidence),
evidence.AgeBand,
evidence.Presentation,
evidence.HairColour,
evidence.SkinTone,
evidence.HairLength,
evidence.Glasses,
evidence.FacialHair);
var demand = await assignments.UpsertDemandAsync(request);
var status = $"Demand recorded ({demand.RequestCount:N0}/{DemandGenerationThreshold:N0})";
if (demand.RequestCount >= DemandGenerationThreshold)
{
var specification = DemandSpecification(demand);
var prompt = promptBuilder.Build(specification);
var item = await library.UpsertPlannedAsync(specification, prompt);
var queued = item.Status == IllustrationLibraryStatuses.Planned
? await library.QueueAsync(item.IllustrationLibraryItemID, null)
: 0;
status = queued > 0
? $"Demand threshold reached; queued {item.StableCode}"
: $"Demand threshold reached; {item.StableCode} already active";
}
logger.LogInformation(
"Recorded Story Intelligence illustration demand {DemandKey} for project {ProjectId}. Request count {RequestCount}. Best score {BestScore}. Status {DemandStatus}.",
demand.DemandKey,
projectId,
demand.RequestCount,
allScores.FirstOrDefault()?.Score ?? 0,
status);
return status;
}
private static string DemandKey(CharacterEvidence evidence)
=> string.Join('|', evidence.AgeBand, evidence.Presentation, evidence.HairColour, evidence.SkinTone, evidence.HairLength, evidence.Glasses, evidence.FacialHair).ToLowerInvariant();
private static IllustrationGenerationSpecification DemandSpecification(StoryIntelligenceIllustrationDemand demand)
{
var age = NormaliseUnknown(demand.AgeBand, "YoungAdult");
var presentation = NormaliseUnknown(demand.Presentation, "Androgynous");
var hairColour = NormaliseUnknown(demand.HairColour, "Brown");
var hairLength = NormaliseUnknown(demand.HairLength, "Medium");
var skinTone = NormaliseUnknown(demand.SkinTone, "Medium");
var features = new List<string> { "DemandGenerated" };
if (string.Equals(demand.Glasses, "Yes", StringComparison.OrdinalIgnoreCase))
{
features.Add("Glasses");
}
if (!IsUnknown(demand.FacialHair))
{
features.Add(demand.FacialHair!);
}
return new IllustrationGenerationSpecification
{
Category = IllustrationLibraryCategories.Character,
Code = $"char-demand-{StableHash(demand.DemandKey)}",
Title = $"{age} {presentation.ToLowerInvariant()} character",
Description = "Demand-created reusable character illustration for Story Intelligence matching.",
VisualSubject = $"{age} {presentation.ToLowerInvariant()} character with {hairLength.ToLowerInvariant()} {hairColour.ToLowerInvariant()} hair and {skinTone.ToLowerInvariant()} skin tone range.",
Composition = "clear head-and-shoulders portrait with bright readable face, clean background separation, suitable for circular cropping",
Mood = "neutral, story-ready, emotionally readable",
ApparentAgeBand = age,
Presentation = presentation,
HairColour = hairColour,
HairLength = hairLength,
SkinTone = skinTone,
ClothingEra = "Contemporary",
ClothingStyle = "Plain reusable story wardrobe",
Features = features,
Palette = ["clear blue", "warm skin tones", "fresh accent colour"],
MetadataTags = ["character", "demand-generated", "story-intelligence"],
Metadata = new(StringComparer.OrdinalIgnoreCase)
{
["libraryPurpose"] = "Story Intelligence demand-generated identifier",
["privacy"] = "generic-no-manuscript-data",
["style"] = "editorial-stylised-realism",
["demandKey"] = demand.DemandKey,
["requestCount"] = demand.RequestCount.ToString("N0")
}
};
}
private static Dictionary<string, CharacterObservation> BuildCharacterObservations(StoryIntelligenceExperiencePrototypeViewModel prototype)
{
var observations = new Dictionary<string, CharacterObservation>(StringComparer.OrdinalIgnoreCase);
foreach (var scene in prototype.Scenes)
{
foreach (var character in scene.Characters)
{
if (string.IsNullOrWhiteSpace(character.Id) || string.IsNullOrWhiteSpace(character.Name))
{
continue;
}
if (!observations.TryGetValue(character.Id, out var observation))
{
observation = new CharacterObservation(character.Id, character.Name);
observations[character.Id] = observation;
}
observation.Significance = Math.Max(observation.Significance, character.Weight);
observation.TextEvidence.Add(character.Name);
observation.TextEvidence.Add(character.Role);
observation.TextEvidence.Add(character.Relevance);
observation.IdentityEvidence.Add(character.Name);
observation.IdentityEvidence.Add(character.Role);
observation.IdentityEvidence.Add(character.Relevance);
foreach (var relationship in scene.Relationships.Where(item => string.Equals(item.SourceId, character.Id, StringComparison.OrdinalIgnoreCase)
|| string.Equals(item.TargetId, character.Id, StringComparison.OrdinalIgnoreCase)))
{
observation.TextEvidence.Add(relationship.Label);
observation.TextEvidence.Add(relationship.State);
observation.RelationshipEvidence.Add(relationship.Label);
observation.RelationshipEvidence.Add(relationship.State);
}
}
}
foreach (var observation in observations.Values)
{
observation.Evidence = InferEvidence(observation.Name, observation.IdentityEvidence, observation.RelationshipEvidence);
}
return observations;
}
private static CharacterEvidence InferEvidence(string characterName, IEnumerable<string> identityValues, IEnumerable<string> relationshipValues)
{
var identityText = $" {string.Join(' ', identityValues.Where(value => !string.IsNullOrWhiteSpace(value))).ToLowerInvariant()} ";
var relationshipText = $" {string.Join(' ', relationshipValues.Where(value => !string.IsNullOrWhiteSpace(value))).ToLowerInvariant()} ";
var text = $"{identityText} {relationshipText}";
var relationshipPresentation = RelationshipPresentation(text);
var pronounPresentation = PronounPresentation(text);
var namePresentation = NamePresentation(identityText);
var presentation = FirstKnown(relationshipPresentation.Value, pronounPresentation.Value, namePresentation.Value, "Androgynous");
var cleanName = characterName.Trim().ToLowerInvariant();
var age = cleanName is "beth" or "grace" or "rosie" ? "Older Teen"
: ContainsAny(identityText, "grandfather", "grandmother", "elderly", "old man", "old woman", "older woman", "older man") ? "Elderly"
: ContainsAny(identityText, "middle aged", "middle-aged", "mother", " mum ", "father", " dad ", "parent", "aunt", "uncle") ? "Middle Aged"
: ContainsAny(identityText, "young teen", "child", "boy", "girl") ? "Child"
: ContainsAny(identityText, "older teen", "teenage", "teenager", "fifteen", "sixteen", "seventeen", " beth ", " grace ", " rosie ") ? "Older Teen"
: ContainsAny(identityText, "young adult", "student", "young woman", "young man") ? "Young Adult"
: "Unknown";
var hairColour = ContainsAny(text, "black hair", "dark hair") ? "Black"
: ContainsAny(text, "brown hair", "brunette") ? "Brown"
: ContainsAny(text, "blonde", "fair hair") ? "Blonde"
: ContainsAny(text, "red hair", "red-haired", "auburn", "ginger") ? "Red"
: ContainsAny(text, "grey hair", "gray hair") ? "Grey"
: ContainsAny(text, "white hair") ? "White"
: "Unknown";
var skinTone = ContainsAny(text, "light skin", "pale skin", "fair skin") ? "Light"
: ContainsAny(text, "medium skin", "olive skin", "brown skin") ? "Medium"
: ContainsAny(text, "dark skin", "deep skin", "black skin") ? "Dark"
: "Unknown";
var hairLength = ContainsAny(text, "bald", "shaved head") ? "Bald"
: ContainsAny(text, "short hair") ? "Short"
: ContainsAny(text, "long hair") ? "Long"
: ContainsAny(text, "medium hair", "shoulder length") ? "Medium"
: "Unknown";
var glasses = ContainsAny(text, "glasses", "spectacles") ? "Yes" : "Unknown";
var facialHair = ContainsAny(text, "moustache", "mustache") ? "Moustache"
: ContainsAny(text, "beard", "bearded") ? "Beard"
: "Unknown";
return new(
age,
presentation,
hairColour,
skinTone,
hairLength,
glasses,
facialHair,
relationshipPresentation.Confidence,
namePresentation.Confidence,
pronounPresentation.Confidence);
}
private static bool ContainsAny(string text, params string[] needles)
=> needles.Any(needle => text.Contains(needle, StringComparison.OrdinalIgnoreCase));
private static EvidenceSignal RelationshipPresentation(string text)
{
if (ContainsAny(text, "mother", " mum", "aunt", "sister", "daughter", "wife", "grandmother", " niece"))
{
return new("Feminine", 0.92m);
}
if (ContainsAny(text, "father", " dad", "uncle", "brother", "son", "husband", "grandfather", " nephew"))
{
return new("Masculine", 0.92m);
}
return EvidenceSignal.Unknown;
}
private static EvidenceSignal PronounPresentation(string text)
{
if (ContainsAny(text, " she ", " her ", " hers ", " herself "))
{
return new("Feminine", 0.86m);
}
if (ContainsAny(text, " he ", " him ", " his ", " himself "))
{
return new("Masculine", 0.86m);
}
return EvidenceSignal.Unknown;
}
private static EvidenceSignal NamePresentation(string text)
{
if (ContainsAny(text, " beth ", " maggie ", " rosie ", " grace ", " annie ", " helen ", " elen "))
{
return new("Feminine", 0.82m);
}
if (ContainsAny(text, " graham ", " george ", " john ", " david ", " simon ", " kevin ", " colin "))
{
return new("Masculine", 0.82m);
}
return EvidenceSignal.Unknown;
}
private static string FirstKnown(params string[] values)
=> values.FirstOrDefault(value => !IsUnknown(value) && !string.Equals(value, "Androgynous", StringComparison.OrdinalIgnoreCase)) ?? values.LastOrDefault() ?? "Unknown";
private static MatchScore Score(CharacterCandidate candidate, CharacterObservation observation, bool alreadyAssigned)
{
var score = 0;
var rejected = new List<string>();
AddScore(observation.Evidence.AgeBand, candidate.Metadata.AgeBand, 25, "age band");
AddScore(observation.Evidence.Presentation, candidate.Metadata.Presentation, observation.Evidence.HasStrongPresentationEvidence ? 42 : 25, "presentation");
AddScore(observation.Evidence.HairColour, candidate.Metadata.HairColour, 20, "hair colour");
AddScore(observation.Evidence.SkinTone, candidate.Metadata.SkinTone, 20, "skin tone");
AddScore(observation.Evidence.HairLength, candidate.Metadata.HairLength, 8, "hair length");
AddScore(observation.Evidence.Glasses, candidate.Metadata.Glasses, 7, "glasses");
AddScore(observation.Evidence.FacialHair, candidate.Metadata.FacialHair, 7, "facial hair");
if (candidate.Item.Status == IllustrationLibraryStatuses.Approved)
{
score += 3;
}
if (observation.Evidence.HasStrongPresentationEvidence && KnownMismatch(observation.Evidence.Presentation, candidate.Metadata.Presentation))
{
score -= 60;
rejected.Add($"strong presentation evidence rejected {candidate.Metadata.Presentation}");
}
if (alreadyAssigned)
{
score -= 38;
rejected.Add("already assigned to another character");
}
return new(candidate, Math.Clamp(score, 0, 100), alreadyAssigned, rejected);
void AddScore(string evidence, string value, int weight, string label)
{
if (IsUnknown(evidence))
{
score += weight / 2;
return;
}
if (string.Equals(evidence, value, StringComparison.OrdinalIgnoreCase))
{
score += weight;
return;
}
if (IsUnknown(value))
{
score += weight / 3;
rejected.Add($"{label} unknown in illustration metadata");
return;
}
rejected.Add($"{label} mismatch: wanted {evidence}, candidate {value}");
}
}
private static decimal Confidence(int score)
=> Math.Round(Math.Clamp(score, 0, 100) / 100m, 2);
private static string ReassignmentReason(CharacterObservation observation, MatchScore existing, MatchScore chosen)
=> $"Reassigned {observation.Name} because new structured evidence made {existing.Candidate.Item.StableCode} materially weaker than {chosen.Candidate.Item.StableCode}.";
private void ApplyAssignment(
StoryIntelligenceExperiencePrototypeViewModel prototype,
CharacterObservation observation,
MatchScore chosen,
StoryIntelligenceCharacterIllustrationAssignment saved,
string reason,
string? reasonReassigned,
string candidateDiagnostics)
{
foreach (var character in prototype.Scenes.SelectMany(scene => scene.Characters).Where(character => string.Equals(character.Id, observation.CharacterKey, StringComparison.OrdinalIgnoreCase)))
{
var fallbackImageUrl = string.IsNullOrWhiteSpace(character.ImageResolution.FallbackImageUrl)
? character.ImagePath
: character.ImageResolution.FallbackImageUrl;
character.LibraryCode = chosen.Candidate.Item.StableCode;
character.ImagePath = chosen.Candidate.PublicUrl!;
character.ImageResolution = new StoryIntelligenceExperienceImageResolution
{
EntityId = character.Id,
StableCode = chosen.Candidate.Item.StableCode,
IllustrationLibraryItemId = chosen.Candidate.Item.IllustrationLibraryItemID,
Status = chosen.Candidate.Item.Status,
FinalImageUrl = chosen.Candidate.PublicUrl,
FallbackImageUrl = fallbackImageUrl,
FallbackUsed = false,
AssignmentConfidence = saved.MatchConfidence,
MatchingScore = saved.MatchingScore,
RejectedCandidates = candidateDiagnostics,
ReasonChosen = reason,
ReasonReassigned = reasonReassigned,
DemandStatus = "Satisfied",
ResolvedPresentation = observation.Evidence.Presentation,
ResolvedAgeBand = observation.Evidence.AgeBand,
RelationshipDerivedConfidence = observation.Evidence.RelationshipConfidence,
NameDerivedConfidence = observation.Evidence.NameConfidence,
PronounDerivedConfidence = observation.Evidence.PronounConfidence,
IllustrationReuseCount = 1,
IllustrationAllocationStatus = "Unique allocation"
};
}
}
private static void ApplyDemandDiagnostics(StoryIntelligenceExperiencePrototypeViewModel prototype, CharacterObservation observation, IReadOnlyList<MatchScore> allScores, string demandStatus)
{
var diagnostics = JsonSerializer.Serialize(allScores.Take(6).Select(MatchDiagnostics.From), JsonOptions);
foreach (var character in prototype.Scenes.SelectMany(scene => scene.Characters).Where(character => string.Equals(character.Id, observation.CharacterKey, StringComparison.OrdinalIgnoreCase)))
{
var fallbackImageUrl = string.IsNullOrWhiteSpace(character.ImageResolution.FallbackImageUrl)
? character.ImagePath
: character.ImageResolution.FallbackImageUrl;
character.LibraryCode = string.Empty;
if (!string.IsNullOrWhiteSpace(fallbackImageUrl))
{
character.ImagePath = fallbackImageUrl;
}
character.ImageResolution = new StoryIntelligenceExperienceImageResolution
{
EntityId = character.Id,
StableCode = string.Empty,
IllustrationLibraryItemId = null,
Status = "DemandRecorded",
FinalImageUrl = null,
FallbackImageUrl = fallbackImageUrl,
FallbackUsed = true,
MatchingScore = allScores.FirstOrDefault()?.Score,
RejectedCandidates = diagnostics,
ReasonChosen = "No suitable unused illustration matched structured character evidence.",
DemandStatus = demandStatus,
ResolvedPresentation = observation.Evidence.Presentation,
ResolvedAgeBand = observation.Evidence.AgeBand,
RelationshipDerivedConfidence = observation.Evidence.RelationshipConfidence,
NameDerivedConfidence = observation.Evidence.NameConfidence,
PronounDerivedConfidence = observation.Evidence.PronounConfidence,
IllustrationAllocationStatus = "Awaiting generated unique illustration"
};
}
}
private string? ResolveExistingPublicUploadUrl(string? storedPath)
{
var physicalPath = uploadStorage.TryResolvePublicPath(storedPath, "uploads/story-intelligence-library");
if (physicalPath is null || !File.Exists(physicalPath))
{
return null;
}
var normalised = storedPath!.Replace('\\', '/').Trim();
if (!normalised.StartsWith('/'))
{
normalised = "/" + normalised.TrimStart('/');
}
return EncodeUrlPath(normalised);
}
private static string EncodeUrlPath(string path)
=> string.Join(
'/',
path.Split('/', StringSplitOptions.None)
.Select((segment, index) => index == 0 && segment.Length == 0 ? string.Empty : Uri.EscapeDataString(segment)));
private static bool IsUnknown(string? value)
=> string.IsNullOrWhiteSpace(value) || string.Equals(value, "Unknown", StringComparison.OrdinalIgnoreCase);
private static string NormaliseUnknown(string? value, string fallback)
=> IsUnknown(value) ? fallback : value!;
private static string StableHash(string value)
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)))[..12].ToLowerInvariant();
private sealed class CharacterObservation(string characterKey, string name)
{
public string CharacterKey { get; } = characterKey;
public string Name { get; } = name;
public int Significance { get; set; }
public bool IsSignificant => Significance >= 50;
public List<string> TextEvidence { get; } = [];
public List<string> IdentityEvidence { get; } = [];
public List<string> RelationshipEvidence { get; } = [];
public CharacterEvidence Evidence { get; set; } = CharacterEvidence.Unknown;
}
private sealed record EvidenceSignal(string Value, decimal Confidence)
{
public static EvidenceSignal Unknown { get; } = new("Unknown", 0);
}
private sealed record CharacterEvidence(
string AgeBand,
string Presentation,
string HairColour,
string SkinTone,
string HairLength,
string Glasses,
string FacialHair,
decimal RelationshipConfidence,
decimal NameConfidence,
decimal PronounConfidence)
{
public bool HasStrongPresentationEvidence => RelationshipConfidence >= 0.8m || NameConfidence >= 0.75m || PronounConfidence >= 0.8m;
public static CharacterEvidence Unknown { get; } = new("Unknown", "Androgynous", "Unknown", "Unknown", "Unknown", "Unknown", "Unknown", 0, 0, 0);
}
private sealed record CharacterCandidate(IllustrationLibraryItem Item, CharacterIllustrationMetadata Metadata, string? PublicUrl)
{
public static CharacterCandidate? From(IllustrationLibraryItem item)
{
var metadata = CharacterIllustrationMetadata.From(item);
return metadata is null ? null : new(item, metadata, null);
}
}
private sealed record CharacterIllustrationMetadata(string AgeBand, string Presentation, string HairColour, string SkinTone, string HairLength, string Glasses, string FacialHair)
{
public static CharacterIllustrationMetadata? From(IllustrationLibraryItem item)
{
IllustrationGenerationSpecification? specification = null;
Dictionary<string, string>? metadata = null;
if (!string.IsNullOrWhiteSpace(item.MetadataJson))
{
try
{
metadata = JsonSerializer.Deserialize<Dictionary<string, string>>(item.MetadataJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (JsonException)
{
}
}
if (!string.IsNullOrWhiteSpace(item.SpecificationJson))
{
try
{
specification = JsonSerializer.Deserialize<IllustrationGenerationSpecification>(item.SpecificationJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (JsonException)
{
}
}
return new(
NormaliseAge(FirstMetadata(metadata, "ageBand", "apparentAgeBand") ?? specification?.ApparentAgeBand, item.StableCode),
NormalisePresentation(FirstMetadata(metadata, "presentation") ?? specification?.Presentation, item.StableCode),
NormaliseHairColour(FirstMetadata(metadata, "hairColour") ?? specification?.HairColour, item.StableCode),
NormaliseSkinTone(FirstMetadata(metadata, "skinTone") ?? specification?.SkinTone, item.StableCode),
NormaliseHairLength(FirstMetadata(metadata, "hairLength") ?? specification?.HairLength, item.StableCode),
FirstMetadata(metadata, "glasses") ?? (FeatureValue(specification?.Features, "Glasses") ? "Yes" : "Unknown"),
FirstMetadata(metadata, "facialHair") ?? (FeatureValue(specification?.Features, "Beard") ? "Beard" : FeatureValue(specification?.Features, "Moustache") ? "Moustache" : "Unknown"));
}
private static string? FirstMetadata(Dictionary<string, string>? metadata, params string[] keys)
=> keys.Select(key => metadata?.GetValueOrDefault(key)).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
private static bool FeatureValue(IReadOnlyList<string>? features, string value)
=> features?.Any(feature => feature.Contains(value, StringComparison.OrdinalIgnoreCase)) == true;
private static string NormaliseAge(string? value, string code)
{
var text = $"{value} {code}".ToLowerInvariant();
if (text.Contains("child")) return "Child";
if (text.Contains("youngteen")) return "Young Teen";
if (text.Contains("teen")) return "Older Teen";
if (text.Contains("youngadult") || text.Contains("young-adult")) return "Young Adult";
if (text.Contains("middleaged") || text.Contains("middle-aged")) return "Middle Aged";
if (text.Contains("elderly") || text.Contains("older")) return "Elderly";
return "Unknown";
}
private static string NormalisePresentation(string? value, string code)
{
var text = $"{value} {code}".ToLowerInvariant();
if (text.Contains("feminine") || text.Contains("woman") || text.Contains("female")) return "Feminine";
if (text.Contains("masculine") || text.Contains("man") || text.Contains("male")) return "Masculine";
if (text.Contains("androgynous")) return "Androgynous";
return "Androgynous";
}
private static string NormaliseHairColour(string? value, string code)
{
var text = $"{value} {code}".ToLowerInvariant();
if (text.Contains("black")) return "Black";
if (text.Contains("brown") || text.Contains("brunette") || text.Contains("darkbrown")) return "Brown";
if (text.Contains("blonde")) return "Blonde";
if (text.Contains("red") || text.Contains("auburn")) return "Red";
if (text.Contains("grey") || text.Contains("gray")) return "Grey";
if (text.Contains("white")) return "White";
return "Unknown";
}
private static string NormaliseSkinTone(string? value, string code)
{
var text = $"{value} {code}".ToLowerInvariant();
if (text.Contains("deep") || text.Contains("dark")) return "Dark";
if (text.Contains("medium") || text.Contains("lightmedium")) return "Medium";
if (text.Contains("light")) return "Light";
return "Unknown";
}
private static string NormaliseHairLength(string? value, string code)
{
var text = $"{value} {code}".ToLowerInvariant();
if (text.Contains("bald")) return "Bald";
if (text.Contains("short")) return "Short";
if (text.Contains("long")) return "Long";
if (text.Contains("medium")) return "Medium";
return "Unknown";
}
}
private sealed record MatchScore(CharacterCandidate Candidate, int Score, bool IsAlreadyAssignedToAnotherSignificantCharacter, IReadOnlyList<string> Rejections);
private sealed record MatchDiagnostics(int IllustrationLibraryItemID, string StableCode, int Score, IReadOnlyList<string> Rejections)
{
public static MatchDiagnostics From(MatchScore score)
=> new(score.Candidate.Item.IllustrationLibraryItemID, score.Candidate.Item.StableCode, score.Score, score.Rejections);
}
}

View File

@ -8,6 +8,7 @@ public interface IStoryIntelligencePipelineStateService
Task<StoryIntelligenceBookPipelineState?> GetForBookAsync(int bookId, int userId); Task<StoryIntelligenceBookPipelineState?> GetForBookAsync(int bookId, int userId);
Task<IReadOnlyList<StoryIntelligenceBookPipelineState>> ListForUserAsync(int userId); Task<IReadOnlyList<StoryIntelligenceBookPipelineState>> ListForUserAsync(int userId);
Task<StoryIntelligenceBookPipelineState?> EnsureForBookAsync(int bookId, int userId); Task<StoryIntelligenceBookPipelineState?> EnsureForBookAsync(int bookId, int userId);
Task RecordChapterAnalysisQueuedAsync(int projectId, int bookId, int runId);
Task RecordSceneImportAsync(int projectId, int bookId, int runId); Task RecordSceneImportAsync(int projectId, int bookId, int runId);
Task RecordCharacterImportAsync(int projectId, int bookId); Task RecordCharacterImportAsync(int projectId, int bookId);
Task RecordLocationImportAsync(int projectId, int bookId); Task RecordLocationImportAsync(int projectId, int bookId);
@ -78,6 +79,21 @@ public sealed class StoryIntelligencePipelineStateService(
}); });
} }
public async Task RecordChapterAnalysisQueuedAsync(int projectId, int bookId, int runId)
{
await pipelines.UpsertAsync(new StoryIntelligenceBookPipelineSaveRequest
{
ProjectID = projectId,
BookID = bookId,
CurrentStage = StoryIntelligencePipelineStages.Chapters,
LastCompletedStage = null,
CurrentReviewStage = null,
Status = StoryIntelligencePipelineStatuses.InProgress,
CompletedUtc = null,
LastRunID = runId
});
}
public async Task RecordCharacterImportAsync(int projectId, int bookId) public async Task RecordCharacterImportAsync(int projectId, int bookId)
{ {
await pipelines.UpsertAsync(new StoryIntelligenceBookPipelineSaveRequest await pipelines.UpsertAsync(new StoryIntelligenceBookPipelineSaveRequest

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,141 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
;WITH LatestBookRun AS
(
SELECT CAST(r.ProjectID AS int) AS ProjectID,
CAST(r.BookID AS int) AS BookID,
r.StoryIntelligenceRunID,
r.Status AS RunStatus,
r.CurrentStage,
ROW_NUMBER() OVER (PARTITION BY r.BookID ORDER BY r.StoryIntelligenceRunID DESC) AS RowNumber,
MAX(CASE WHEN r.Status IN (N'Pending', N'Running') THEN 1 ELSE 0 END) OVER (PARTITION BY r.BookID) AS HasActiveRun
FROM dbo.StoryIntelligenceRuns r
INNER JOIN dbo.Books b ON b.BookID = r.BookID
INNER JOIN dbo.Projects p ON p.ProjectID = r.ProjectID
WHERE r.ProjectID IS NOT NULL
AND r.BookID IS NOT NULL
AND b.IsArchived = 0
AND p.IsArchived = 0
)
MERGE dbo.StoryIntelligenceBookPipelines AS target
USING
(
SELECT ProjectID,
BookID,
StoryIntelligenceRunID,
CASE WHEN HasActiveRun = 1 THEN N'InProgress' ELSE N'NeedsReview' END AS Status,
CASE
WHEN HasActiveRun = 1 THEN N'Chapters'
ELSE N'CharacterReview'
END AS CurrentStage,
CASE WHEN HasActiveRun = 1 THEN NULL ELSE N'SceneImport' END AS LastCompletedStage,
CASE WHEN HasActiveRun = 1 THEN NULL ELSE N'CharacterReview' END AS CurrentReviewStage
FROM LatestBookRun
WHERE RowNumber = 1
) AS source
ON target.BookID = source.BookID
WHEN MATCHED THEN
UPDATE SET ProjectID = source.ProjectID,
LastRunID = source.StoryIntelligenceRunID,
Status = CASE WHEN source.Status = N'InProgress' THEN source.Status ELSE target.Status END,
CurrentStage = CASE WHEN source.Status = N'InProgress' THEN source.CurrentStage ELSE target.CurrentStage END,
UpdatedUtc = SYSUTCDATETIME()
WHEN NOT MATCHED THEN
INSERT (ProjectID, BookID, CurrentStage, LastCompletedStage, CurrentReviewStage, Status, CompletedUtc, LastRunID)
VALUES (source.ProjectID, source.BookID, source.CurrentStage, source.LastCompletedStage, source.CurrentReviewStage, source.Status, NULL, source.StoryIntelligenceRunID);
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceBookPipeline_GetByIDForUser
@StoryIntelligenceBookPipelineID int,
@UserID int
AS
BEGIN
SET NOCOUNT ON;
SELECT p.StoryIntelligenceBookPipelineID, p.ProjectID, project.ProjectName,
p.BookID, book.BookTitle, book.Subtitle AS BookSubtitle,
p.CurrentStage, p.LastCompletedStage, p.CurrentReviewStage, p.Status,
p.CompletedUtc, p.LastRunID, p.CreatedUtc, p.UpdatedUtc
FROM dbo.StoryIntelligenceBookPipelines p
INNER JOIN dbo.Projects project ON project.ProjectID = p.ProjectID
INNER JOIN dbo.Books book ON book.BookID = p.BookID
INNER JOIN dbo.ProjectUserAccess access ON access.ProjectID = p.ProjectID
AND access.UserID = @UserID
AND access.IsActive = 1
WHERE p.StoryIntelligenceBookPipelineID = @StoryIntelligenceBookPipelineID
AND book.IsArchived = 0
AND project.IsArchived = 0;
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_ListByBookForUser
@BookID int,
@UserID int
AS
BEGIN
SET NOCOUNT ON;
SELECT
r.StoryIntelligenceRunID,
r.UserID,
r.ProjectID,
p.ProjectName AS ProjectTitle,
r.BookID,
b.BookTitle,
r.ChapterID,
r.ChapterNumber,
r.Status,
r.SourceType,
r.SourceText,
r.SourceFileName,
r.SourceFileSizeBytes,
r.SourceWordCount,
r.SourceCharacterCount,
r.SourceParagraphCount,
r.SourceChapterCount,
r.SourceDetectedImagesCount,
r.SourceDetectedTablesCount,
r.SourceDetectedFootnotesCount,
r.SourceDetectedCommentsCount,
r.CurrentStage,
r.CurrentMessage,
r.TotalDetectedScenes,
r.CompletedScenes,
r.FailedScenes,
r.CancellationRequestedUtc,
r.CancelledUtc,
r.PromptVersion,
r.PromptVersionsSummary,
r.Model,
r.StartedUtc,
r.CompletedUtc,
r.FailureStage,
r.TotalInputTokens,
r.TotalOutputTokens,
r.TotalTokens,
r.TotalDurationMs,
r.EstimatedCostGBP,
r.EstimatedCostUSD,
r.ErrorMessage,
r.ErrorDetail,
r.CreatedUtc,
r.UpdatedUtc
FROM dbo.StoryIntelligenceRuns r
INNER JOIN dbo.Projects p ON p.ProjectID = r.ProjectID
INNER JOIN dbo.Books b ON b.BookID = r.BookID
INNER JOIN dbo.ProjectUserAccess access ON access.ProjectID = r.ProjectID
AND access.UserID = @UserID
AND access.IsActive = 1
LEFT JOIN dbo.Chapters chapter ON chapter.ChapterID = r.ChapterID
WHERE r.BookID = @BookID
AND r.ProjectID IS NOT NULL
AND b.IsArchived = 0
AND p.IsArchived = 0
ORDER BY COALESCE(chapter.SortOrder, 2147483647),
COALESCE(r.ChapterNumber, chapter.ChapterNumber, 999999),
r.StoryIntelligenceRunID;
END;
GO

View File

@ -0,0 +1,79 @@
SET ANSI_NULLS ON;
SET QUOTED_IDENTIFIER ON;
GO
IF OBJECT_ID(N'dbo.StoryIntelligenceCharacterIllustrationAssignments', N'U') IS NULL
BEGIN
CREATE TABLE dbo.StoryIntelligenceCharacterIllustrationAssignments
(
StoryIntelligenceCharacterIllustrationAssignmentID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryIntelligenceCharacterIllustrationAssignments PRIMARY KEY,
ProjectID int NOT NULL,
CharacterKey nvarchar(160) NOT NULL,
CharacterName nvarchar(200) NOT NULL,
IllustrationLibraryItemID int NULL,
StableCode nvarchar(120) NULL,
MatchConfidence decimal(5,2) NOT NULL CONSTRAINT DF_SICharacterIllustrationAssignments_MatchConfidence DEFAULT 0,
MatchingScore int NOT NULL CONSTRAINT DF_SICharacterIllustrationAssignments_MatchingScore DEFAULT 0,
EvidenceJson nvarchar(max) NULL,
CandidateDiagnosticsJson nvarchar(max) NULL,
ReasonChosen nvarchar(500) NULL,
ReasonReassigned nvarchar(500) NULL,
IsActive bit NOT NULL CONSTRAINT DF_SICharacterIllustrationAssignments_IsActive DEFAULT 1,
CreatedUtc datetime2(0) NOT NULL CONSTRAINT DF_SICharacterIllustrationAssignments_CreatedUtc DEFAULT SYSUTCDATETIME(),
UpdatedUtc datetime2(0) NOT NULL CONSTRAINT DF_SICharacterIllustrationAssignments_UpdatedUtc DEFAULT SYSUTCDATETIME()
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_SICharacterIllustrationAssignments_ProjectCharacter' AND object_id = OBJECT_ID(N'dbo.StoryIntelligenceCharacterIllustrationAssignments'))
BEGIN
CREATE UNIQUE INDEX UX_SICharacterIllustrationAssignments_ProjectCharacter
ON dbo.StoryIntelligenceCharacterIllustrationAssignments(ProjectID, CharacterKey)
WHERE IsActive = 1;
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_SICharacterIllustrationAssignments_Project')
BEGIN
ALTER TABLE dbo.StoryIntelligenceCharacterIllustrationAssignments
ADD CONSTRAINT FK_SICharacterIllustrationAssignments_Project
FOREIGN KEY (ProjectID) REFERENCES dbo.Projects(ProjectID);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_SICharacterIllustrationAssignments_Illustration')
BEGIN
ALTER TABLE dbo.StoryIntelligenceCharacterIllustrationAssignments
ADD CONSTRAINT FK_SICharacterIllustrationAssignments_Illustration
FOREIGN KEY (IllustrationLibraryItemID) REFERENCES dbo.IllustrationLibraryItems(IllustrationLibraryItemID);
END;
GO
IF OBJECT_ID(N'dbo.StoryIntelligenceIllustrationDemands', N'U') IS NULL
BEGIN
CREATE TABLE dbo.StoryIntelligenceIllustrationDemands
(
StoryIntelligenceIllustrationDemandID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryIntelligenceIllustrationDemands PRIMARY KEY,
ProjectID int NULL,
DemandKey nvarchar(240) NOT NULL,
AgeBand nvarchar(40) NOT NULL,
Presentation nvarchar(40) NOT NULL,
HairColour nvarchar(40) NOT NULL,
SkinTone nvarchar(40) NOT NULL,
HairLength nvarchar(40) NULL,
Glasses nvarchar(20) NULL,
FacialHair nvarchar(40) NULL,
RequestCount int NOT NULL CONSTRAINT DF_StoryIntelligenceIllustrationDemands_RequestCount DEFAULT 1,
LastRequestedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryIntelligenceIllustrationDemands_LastRequestedUtc DEFAULT SYSUTCDATETIME(),
CreatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryIntelligenceIllustrationDemands_CreatedUtc DEFAULT SYSUTCDATETIME(),
UpdatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryIntelligenceIllustrationDemands_UpdatedUtc DEFAULT SYSUTCDATETIME()
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_StoryIntelligenceIllustrationDemands_ProjectDemand' AND object_id = OBJECT_ID(N'dbo.StoryIntelligenceIllustrationDemands'))
BEGIN
CREATE UNIQUE INDEX UX_StoryIntelligenceIllustrationDemands_ProjectDemand
ON dbo.StoryIntelligenceIllustrationDemands(ProjectID, DemandKey);
END;
GO

View File

@ -0,0 +1,62 @@
SET ANSI_NULLS ON;
SET QUOTED_IDENTIFIER ON;
GO
UPDATE dbo.IllustrationLibraryItems
SET MetadataJson = JSON_MODIFY(
JSON_MODIFY(
JSON_MODIFY(
JSON_MODIFY(
JSON_MODIFY(
JSON_MODIFY(
JSON_MODIFY(
JSON_MODIFY(
CASE WHEN ISJSON(MetadataJson) = 1 THEN MetadataJson ELSE N'{}' END,
'$.category',
Category),
'$.metadataConfidence',
COALESCE(JSON_VALUE(MetadataJson, '$.metadataConfidence'), N'High')),
'$.ageBand',
COALESCE(JSON_VALUE(MetadataJson, '$.ageBand'), JSON_VALUE(SpecificationJson, '$.apparentAgeBand'))),
'$.presentation',
COALESCE(JSON_VALUE(MetadataJson, '$.presentation'), JSON_VALUE(SpecificationJson, '$.presentation'))),
'$.hairColour',
COALESCE(JSON_VALUE(MetadataJson, '$.hairColour'), JSON_VALUE(SpecificationJson, '$.hairColour'))),
'$.hairLength',
COALESCE(JSON_VALUE(MetadataJson, '$.hairLength'), JSON_VALUE(SpecificationJson, '$.hairLength'))),
'$.skinTone',
COALESCE(JSON_VALUE(MetadataJson, '$.skinTone'), JSON_VALUE(SpecificationJson, '$.skinTone'))),
'$.source',
COALESCE(JSON_VALUE(MetadataJson, '$.source'), N'specification-backfill')),
UpdatedUtc = SYSUTCDATETIME()
WHERE Category = N'Character'
AND IsActive = 1
AND ISJSON(SpecificationJson) = 1
AND (
ISJSON(MetadataJson) <> 1
OR JSON_VALUE(MetadataJson, '$.category') IS NULL
OR JSON_VALUE(MetadataJson, '$.metadataConfidence') IS NULL
OR JSON_VALUE(MetadataJson, '$.ageBand') IS NULL
OR JSON_VALUE(MetadataJson, '$.presentation') IS NULL
OR JSON_VALUE(MetadataJson, '$.hairColour') IS NULL
OR JSON_VALUE(MetadataJson, '$.skinTone') IS NULL
);
GO
UPDATE dbo.IllustrationLibraryItems
SET MetadataJson = JSON_MODIFY(
JSON_MODIFY(
CASE WHEN ISJSON(MetadataJson) = 1 THEN MetadataJson ELSE N'{}' END,
'$.category',
Category),
'$.metadataConfidence',
COALESCE(JSON_VALUE(MetadataJson, '$.metadataConfidence'), N'High')),
UpdatedUtc = SYSUTCDATETIME()
WHERE Category IN (N'Location', N'Asset')
AND IsActive = 1
AND (
ISJSON(MetadataJson) <> 1
OR JSON_VALUE(MetadataJson, '$.category') IS NULL
OR JSON_VALUE(MetadataJson, '$.metadataConfidence') IS NULL
);
GO

View File

@ -2,14 +2,77 @@ namespace PlotLine.ViewModels;
public sealed class StoryIntelligenceExperiencePrototypeViewModel public sealed class StoryIntelligenceExperiencePrototypeViewModel
{ {
public string Mode { get; init; } = "simulation";
public int? RunId { get; init; }
public int? ImportSessionId { get; init; }
public string? RunStatus { get; init; }
public string? ChangeToken { get; init; }
public DateTime GeneratedUtc { get; init; } = DateTime.UtcNow;
public bool IsTerminal { get; init; }
public string? SnapshotMessage { get; init; }
public StoryIntelligenceExperienceSnapshotDiagnostics Diagnostics { get; init; } = new();
public IReadOnlyList<StoryIntelligenceExperienceRunOption> AvailableRuns { get; init; } = [];
public string ProjectName { get; init; } = string.Empty; public string ProjectName { get; init; } = string.Empty;
public string BookTitle { get; init; } = string.Empty; public string BookTitle { get; init; } = string.Empty;
public IReadOnlyList<StoryIntelligenceExperienceSceneState> Scenes { get; init; } = []; public IReadOnlyList<StoryIntelligenceExperienceSceneState> Scenes { get; init; } = [];
} }
public sealed class StoryIntelligenceExperienceSnapshotDiagnostics
{
public string RequestedMode { get; init; } = string.Empty;
public int? CurrentImportId { get; init; }
public int? RequestedImportSessionId { get; init; }
public int? ResolvedImportSessionId { get; init; }
public int? ActiveChapterRunId { get; init; }
public string SessionStatus { get; init; } = string.Empty;
public int OverallBookProgress { get; init; }
public string CurrentChapter { get; init; } = string.Empty;
public string CurrentScene { get; init; } = string.Empty;
public int CompletedScenes { get; init; }
public int TotalScenes { get; init; }
public string ProgressSource { get; init; } = string.Empty;
public string CurrentBackendStage { get; init; } = string.Empty;
public int StoryMemoryCharacterCount { get; init; }
public int ResolvedCharacterCount { get; init; }
public int UnresolvedCharacterCount { get; init; }
public decimal NarratorConfidence { get; init; }
public int IllustrationAssignmentCount { get; set; }
public int IllustrationChangeCount { get; set; }
public string CharacterConsistencyReport { get; set; } = string.Empty;
public string RawScenePov { get; init; } = string.Empty;
public string InheritedChapterPov { get; init; } = string.Empty;
public string ResolvedPovIdentity { get; init; } = string.Empty;
public string PovResolutionReason { get; init; } = string.Empty;
public decimal PovConfidence { get; init; }
public bool PovChangeDetected { get; init; }
public string SnapshotSource { get; init; } = string.Empty;
public string RunStatus { get; init; } = string.Empty;
public int PersistedCompletedSceneCount { get; init; }
public int TotalPersistedSceneResultCount { get; init; }
public int SuccessfullyParsedSceneResultCount { get; init; }
public int FailedSceneResultCount { get; init; }
public int? HighestPersistedSceneResultId { get; init; }
public int? HighestParsedSceneResultId { get; init; }
public int? SelectedCurrentSceneResultId { get; init; }
public string SelectedChapterAndSceneOrder { get; init; } = string.Empty;
public string CurrentChangeToken { get; init; } = string.Empty;
public string? AnalysisCursor { get; init; }
public string? Warning { get; init; }
}
public sealed class StoryIntelligenceExperienceRunOption
{
public int RunId { get; init; }
public int? ImportSessionId { get; init; }
public string Label { get; init; } = string.Empty;
public string Status { get; init; } = string.Empty;
public DateTime CreatedUtc { get; init; }
}
public sealed class StoryIntelligenceExperienceSceneState public sealed class StoryIntelligenceExperienceSceneState
{ {
public string Id { get; init; } = string.Empty; public string Id { get; init; } = string.Empty;
public string ChapterLabel { get; init; } = string.Empty;
public int SceneNumber { get; init; } public int SceneNumber { get; init; }
public string Title { get; init; } = string.Empty; public string Title { get; init; } = string.Empty;
public string Summary { get; init; } = string.Empty; public string Summary { get; init; } = string.Empty;
@ -21,6 +84,7 @@ public sealed class StoryIntelligenceExperienceSceneState
public int ChapterTotal { get; init; } public int ChapterTotal { get; init; }
public int ScenesAnalysed { get; init; } public int ScenesAnalysed { get; init; }
public int SceneTotal { get; init; } public int SceneTotal { get; init; }
public string ElapsedTime { get; init; } = string.Empty;
public string EstimatedRemaining { get; init; } = string.Empty; public string EstimatedRemaining { get; init; } = string.Empty;
public string PovCharacterId { get; init; } = string.Empty; public string PovCharacterId { get; init; } = string.Empty;
public StoryIntelligenceExperienceLocationState Location { get; init; } = new(); public StoryIntelligenceExperienceLocationState Location { get; init; } = new();
@ -75,6 +139,19 @@ public sealed class StoryIntelligenceExperienceImageResolution
public string? FinalImageUrl { get; init; } public string? FinalImageUrl { get; init; }
public string FallbackImageUrl { get; init; } = string.Empty; public string FallbackImageUrl { get; init; } = string.Empty;
public bool FallbackUsed { get; init; } = true; public bool FallbackUsed { get; init; } = true;
public decimal? AssignmentConfidence { get; init; }
public int? MatchingScore { get; init; }
public string? RejectedCandidates { get; init; }
public string? ReasonChosen { get; init; }
public string? ReasonReassigned { get; init; }
public string? DemandStatus { get; init; }
public string? ResolvedPresentation { get; init; }
public string? ResolvedAgeBand { get; init; }
public decimal? RelationshipDerivedConfidence { get; init; }
public decimal? NameDerivedConfidence { get; init; }
public decimal? PronounDerivedConfidence { get; init; }
public int? IllustrationReuseCount { get; init; }
public string? IllustrationAllocationStatus { get; init; }
} }
public sealed class StoryIntelligenceExperienceRelationshipState public sealed class StoryIntelligenceExperienceRelationshipState

View File

@ -3,6 +3,21 @@
@{ @{
Layout = null; Layout = null;
var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
var statusLabel = Model.Mode switch
{
"simulation" => "Simulation",
"real" when Model.IsTerminal => "Analysis Complete",
"real" => "Live Analysis",
"error" => "Import Session Error",
_ => "Select Import Session"
};
var sourceLabel = Model.Mode switch
{
"simulation" => "Simulation only",
"real" => "Real snapshot",
"error" => "No live snapshot",
_ => "Choose a session"
};
} }
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-theme="dark" data-bs-theme="dark"> <html lang="en" data-theme="dark" data-bs-theme="dark">
@ -15,22 +30,48 @@
<body> <body>
<script id="storyExperienceData" type="application/json">@Html.Raw(JsonSerializer.Serialize(Model, jsonOptions))</script> <script id="storyExperienceData" type="application/json">@Html.Raw(JsonSerializer.Serialize(Model, jsonOptions))</script>
<main class="story-exp" data-story-experience data-image-diagnostics="true"> <main class="story-exp"
data-story-experience
data-image-diagnostics="true"
data-mode="@Model.Mode"
data-run-id="@Model.RunId"
data-import-session-id="@Model.ImportSessionId"
data-snapshot-url="@(Model.ImportSessionId.HasValue ? $"/api/story-intelligence/import-sessions/{Model.ImportSessionId.Value}/visualisation-snapshot" : Model.RunId.HasValue ? $"/api/story-intelligence/runs/{Model.RunId.Value}/visualisation-snapshot" : string.Empty)"
data-change-token="@Model.ChangeToken"
data-terminal="@Model.IsTerminal.ToString().ToLowerInvariant()">
<header class="story-exp-header" aria-label="Story Intelligence status"> <header class="story-exp-header" aria-label="Story Intelligence status">
<div class="story-exp-brand"> <div class="story-exp-brand">
<strong>PlotDirector</strong> <strong>PlotDirector</strong>
<span>Story Intelligence</span> <span>Story Intelligence</span>
</div> </div>
<ol class="story-exp-pipeline" data-analysis-stages aria-label="Analysis stages"></ol>
<div class="story-exp-context"> <div class="story-exp-context">
<span>@Model.ProjectName</span> <span data-experience-status>@statusLabel</span>
<span>@Model.BookTitle</span> <span data-project-name>@Model.ProjectName</span>
<span data-book-title>@Model.BookTitle</span>
<span data-stage-label>Preparing</span> <span data-stage-label>Preparing</span>
<span>Scene <strong data-current-scene>1</strong></span> <span>Scene <strong data-current-scene>1</strong></span>
</div> </div>
<button class="story-exp-background" type="button">Continue in Background</button> <a class="story-exp-background" href="/Development/StoryIntelligenceExperience?mode=simulation">Simulation</a>
</header> </header>
@if (Model.AvailableRuns.Count > 0)
{
<nav class="story-exp-run-selector" aria-label="Development Story Intelligence import sessions">
<strong>Story Intelligence import sessions</strong>
@foreach (var run in Model.AvailableRuns)
{
<a href="/Development/StoryIntelligenceExperience?importSessionId=@run.ImportSessionId">@run.Label</a>
}
</nav>
}
@if (!string.IsNullOrWhiteSpace(Model.SnapshotMessage))
{
<aside class="story-exp-status-message" role="status" data-snapshot-message>
@Model.SnapshotMessage
</aside>
}
<section class="story-exp-shell" aria-label="Story Intelligence visual prototype"> <section class="story-exp-shell" aria-label="Story Intelligence visual prototype">
<aside class="story-exp-left" aria-label="Analysis progress"> <aside class="story-exp-left" aria-label="Analysis progress">
<div class="story-exp-progress"> <div class="story-exp-progress">
@ -128,7 +169,7 @@
<aside class="story-exp-controls" aria-label="Prototype controls"> <aside class="story-exp-controls" aria-label="Prototype controls">
<div> <div>
<span>Prototype controls</span> <span>Prototype controls</span>
<small>Simulation only</small> <small>@sourceLabel</small>
</div> </div>
<button type="button" data-control="restart">Restart</button> <button type="button" data-control="restart">Restart</button>
<button type="button" data-control="previous">Previous</button> <button type="button" data-control="previous">Previous</button>
@ -139,6 +180,224 @@
<input type="range" min="3500" max="10000" step="500" value="6200" data-speed-control /> <input type="range" min="3500" max="10000" step="500" value="6200" data-speed-control />
</label> </label>
</aside> </aside>
<details class="story-exp-diagnostics" data-diagnostics>
<summary>Diagnostics</summary>
<dl>
<div>
<dt>Current Import ID</dt>
<dd data-diagnostic-key="currentImportId">@Model.Diagnostics.CurrentImportId</dd>
</div>
<div>
<dt>Requested mode</dt>
<dd data-diagnostic-key="requestedMode">@Model.Diagnostics.RequestedMode</dd>
</div>
<div>
<dt>Requested ImportSessionID</dt>
<dd data-diagnostic-key="requestedImportSessionId">@Model.Diagnostics.RequestedImportSessionId</dd>
</div>
<div>
<dt>Resolved ImportSessionID</dt>
<dd data-diagnostic-key="resolvedImportSessionId">@Model.Diagnostics.ResolvedImportSessionId</dd>
</div>
<div>
<dt>Active chapter RunID</dt>
<dd data-diagnostic-key="activeChapterRunId">@Model.Diagnostics.ActiveChapterRunId</dd>
</div>
<div>
<dt>Session status</dt>
<dd data-diagnostic-key="sessionStatus">@Model.Diagnostics.SessionStatus</dd>
</div>
<div>
<dt>Overall book progress</dt>
<dd data-diagnostic-key="overallBookProgress">@Model.Diagnostics.OverallBookProgress</dd>
</div>
<div>
<dt>Current chapter</dt>
<dd data-diagnostic-key="currentChapter">@Model.Diagnostics.CurrentChapter</dd>
</div>
<div>
<dt>Current scene</dt>
<dd data-diagnostic-key="currentScene">@Model.Diagnostics.CurrentScene</dd>
</div>
<div>
<dt>Completed scenes</dt>
<dd data-diagnostic-key="completedScenes">@Model.Diagnostics.CompletedScenes</dd>
</div>
<div>
<dt>Total scenes</dt>
<dd data-diagnostic-key="totalScenes">@Model.Diagnostics.TotalScenes</dd>
</div>
<div>
<dt>Progress source</dt>
<dd data-diagnostic-key="progressSource">@Model.Diagnostics.ProgressSource</dd>
</div>
<div>
<dt>Current backend stage</dt>
<dd data-diagnostic-key="currentBackendStage">@Model.Diagnostics.CurrentBackendStage</dd>
</div>
<div>
<dt>Story Memory character count</dt>
<dd data-diagnostic-key="storyMemoryCharacterCount">@Model.Diagnostics.StoryMemoryCharacterCount</dd>
</div>
<div>
<dt>Resolved characters</dt>
<dd data-diagnostic-key="resolvedCharacterCount">@Model.Diagnostics.ResolvedCharacterCount</dd>
</div>
<div>
<dt>Unresolved characters</dt>
<dd data-diagnostic-key="unresolvedCharacterCount">@Model.Diagnostics.UnresolvedCharacterCount</dd>
</div>
<div>
<dt>Narrator confidence</dt>
<dd data-diagnostic-key="narratorConfidence">@Model.Diagnostics.NarratorConfidence</dd>
</div>
<div>
<dt>Raw scene POV</dt>
<dd data-diagnostic-key="rawScenePov">@Model.Diagnostics.RawScenePov</dd>
</div>
<div>
<dt>Inherited chapter POV</dt>
<dd data-diagnostic-key="inheritedChapterPov">@Model.Diagnostics.InheritedChapterPov</dd>
</div>
<div>
<dt>Resolved POV identity</dt>
<dd data-diagnostic-key="resolvedPovIdentity">@Model.Diagnostics.ResolvedPovIdentity</dd>
</div>
<div>
<dt>POV resolution reason</dt>
<dd data-diagnostic-key="povResolutionReason">@Model.Diagnostics.PovResolutionReason</dd>
</div>
<div>
<dt>POV confidence</dt>
<dd data-diagnostic-key="povConfidence">@Model.Diagnostics.PovConfidence</dd>
</div>
<div>
<dt>POV change detected</dt>
<dd data-diagnostic-key="povChangeDetected">@Model.Diagnostics.PovChangeDetected</dd>
</div>
<div>
<dt>Illustration assignments</dt>
<dd data-diagnostic-key="illustrationAssignmentCount">@Model.Diagnostics.IllustrationAssignmentCount</dd>
</div>
<div>
<dt>Illustration changes</dt>
<dd data-diagnostic-key="illustrationChangeCount">@Model.Diagnostics.IllustrationChangeCount</dd>
</div>
<div>
<dt>Character consistency report</dt>
<dd data-diagnostic-key="characterConsistencyReport">@Model.Diagnostics.CharacterConsistencyReport</dd>
</div>
<div>
<dt>Current polling interval</dt>
<dd data-diagnostic-key="currentPollingInterval">Initial</dd>
</div>
<div>
<dt>Latest snapshot age</dt>
<dd data-diagnostic-key="latestSnapshotAge">Initial</dd>
</div>
<div>
<dt>Snapshot source</dt>
<dd data-diagnostic-key="snapshotSource">@Model.Diagnostics.SnapshotSource</dd>
</div>
<div>
<dt>Current Snapshot Version</dt>
<dd data-diagnostics-current>0</dd>
</div>
<div>
<dt>Previous Snapshot Version</dt>
<dd data-diagnostics-previous>0</dd>
</div>
<div>
<dt>Change Token</dt>
<dd data-diagnostics-token>@Model.ChangeToken</dd>
</div>
<div>
<dt>Animation Queue Length</dt>
<dd data-diagnostics-queue>0</dd>
</div>
<div>
<dt>Last Refresh Time</dt>
<dd data-diagnostics-refresh>Initial</dd>
</div>
<div>
<dt>Snapshot Build Time</dt>
<dd data-diagnostics-build>0ms</dd>
</div>
<div>
<dt>Operations detected</dt>
<dd data-diagnostics-operations>InitialRender</dd>
</div>
<div>
<dt>Run status</dt>
<dd data-diagnostic-key="runStatus">@Model.Diagnostics.RunStatus</dd>
</div>
<div>
<dt>Persisted completed scenes</dt>
<dd data-diagnostic-key="persistedCompletedSceneCount">@Model.Diagnostics.PersistedCompletedSceneCount</dd>
</div>
<div>
<dt>Total persisted scene results</dt>
<dd data-diagnostic-key="totalPersistedSceneResultCount">@Model.Diagnostics.TotalPersistedSceneResultCount</dd>
</div>
<div>
<dt>Successfully parsed scene results</dt>
<dd data-diagnostic-key="successfullyParsedSceneResultCount">@Model.Diagnostics.SuccessfullyParsedSceneResultCount</dd>
</div>
<div>
<dt>Failed/unparseable scene results</dt>
<dd data-diagnostic-key="failedSceneResultCount">@Model.Diagnostics.FailedSceneResultCount</dd>
</div>
<div>
<dt>Highest persisted SceneResultID</dt>
<dd data-diagnostic-key="highestPersistedSceneResultId">@Model.Diagnostics.HighestPersistedSceneResultId</dd>
</div>
<div>
<dt>Highest parsed SceneResultID</dt>
<dd data-diagnostic-key="highestParsedSceneResultId">@Model.Diagnostics.HighestParsedSceneResultId</dd>
</div>
<div>
<dt>Selected current SceneResultID</dt>
<dd data-diagnostic-key="selectedCurrentSceneResultId">@Model.Diagnostics.SelectedCurrentSceneResultId</dd>
</div>
<div>
<dt>Selected chapter and scene order</dt>
<dd data-diagnostic-key="selectedChapterAndSceneOrder">@Model.Diagnostics.SelectedChapterAndSceneOrder</dd>
</div>
<div>
<dt>Analysis cursor</dt>
<dd data-diagnostic-key="analysisCursor">@Model.Diagnostics.AnalysisCursor</dd>
</div>
<div>
<dt>Snapshot warning</dt>
<dd data-diagnostic-key="warning">@Model.Diagnostics.Warning</dd>
</div>
<div>
<dt>Last successful snapshot fetch</dt>
<dd data-diagnostic-key="lastSuccessfulSnapshotFetch">Initial</dd>
</div>
<div>
<dt>Last snapshot HTTP status</dt>
<dd data-diagnostic-key="lastSnapshotHttpStatus">Initial</dd>
</div>
<div>
<dt>Consecutive unchanged polls</dt>
<dd data-diagnostic-key="consecutiveUnchangedPolls">0</dd>
</div>
<div>
<dt>Consecutive failed polls</dt>
<dd data-diagnostic-key="consecutiveFailedPolls">0</dd>
</div>
<div>
<dt>Polling</dt>
<dd data-diagnostic-key="pollingActive">Initialising</dd>
</div>
<div>
<dt>Polling stopped reason</dt>
<dd data-diagnostic-key="pollingStoppedReason">none</dd>
</div>
</dl>
</details>
</main> </main>
<script src="~/js/story-intelligence-experience-prototype.js" asp-append-version="true"></script> <script src="~/js/story-intelligence-experience-prototype.js" asp-append-version="true"></script>

View File

@ -54,7 +54,7 @@ input:focus-visible {
.story-exp { .story-exp {
position: relative; position: relative;
display: grid; display: grid;
grid-template-rows: 66px minmax(0, 1fr); grid-template-rows: 56px minmax(0, 1fr);
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
isolation: isolate; isolation: isolate;
@ -87,10 +87,10 @@ input:focus-visible {
.story-exp-header { .story-exp-header {
display: grid; display: grid;
grid-template-columns: minmax(210px, 0.52fr) minmax(400px, 1.35fr) minmax(290px, 0.78fr) auto; grid-template-columns: minmax(210px, auto) minmax(0, 1fr) auto;
align-items: center; align-items: center;
gap: 18px; gap: 16px;
padding: 10px 24px; padding: 8px 24px;
border-bottom: 1px solid var(--story-line); border-bottom: 1px solid var(--story-line);
background: rgba(4, 10, 20, 0.68); background: rgba(4, 10, 20, 0.68);
backdrop-filter: blur(18px); backdrop-filter: blur(18px);
@ -102,87 +102,12 @@ input:focus-visible {
gap: 12px; gap: 12px;
} }
.story-exp-pipeline {
position: relative;
display: grid;
grid-template-columns: repeat(11, minmax(38px, 1fr));
gap: 0;
min-width: 0;
margin: 0;
padding: 0;
list-style: none;
}
.story-exp-pipeline::before {
content: "";
position: absolute;
left: 4%;
right: 4%;
top: 10px;
height: 1px;
background: linear-gradient(90deg, rgba(109, 181, 255, 0.54), rgba(215, 168, 93, 0.24), rgba(255, 255, 255, 0.18));
}
.story-exp-pipeline-step {
position: relative;
display: grid;
justify-items: center;
gap: 6px;
color: rgba(196, 214, 235, 0.54);
font-size: 0.62rem;
font-weight: 700;
min-width: 0;
}
.story-exp-pipeline-step span {
z-index: 1;
width: 10px;
height: 10px;
border: 1px solid rgba(196, 214, 235, 0.36);
border-radius: 50%;
background: rgba(8, 18, 32, 0.98);
}
.story-exp-pipeline-step strong {
overflow: hidden;
max-width: 78px;
text-overflow: ellipsis;
white-space: nowrap;
}
.story-exp-pipeline-step.is-complete span {
border-color: rgba(109, 181, 255, 0.9);
background: var(--story-blue);
box-shadow: 0 0 14px rgba(109, 181, 255, 0.35);
}
.story-exp-pipeline-step.is-current {
color: var(--story-text);
}
.story-exp-pipeline-step.is-current span {
width: 14px;
height: 14px;
margin-top: -2px;
border-color: rgba(215, 168, 93, 0.9);
background: var(--story-gold);
box-shadow: 0 0 0 5px rgba(215, 168, 93, 0.12), 0 0 22px rgba(215, 168, 93, 0.5);
}
@media (max-width: 1500px) { @media (max-width: 1500px) {
.story-exp-header { .story-exp-header {
grid-template-columns: minmax(188px, 0.44fr) minmax(360px, 1.2fr) minmax(230px, 0.66fr) auto; grid-template-columns: minmax(188px, auto) minmax(0, 1fr) auto;
gap: 12px; gap: 12px;
} }
.story-exp-pipeline-step {
font-size: 0.55rem;
}
.story-exp-pipeline-step strong {
max-width: 45px;
}
.story-exp-context { .story-exp-context {
gap: 8px; gap: 8px;
} }
@ -282,6 +207,67 @@ input:focus-visible {
background: rgba(215, 168, 93, 0.2); background: rgba(215, 168, 93, 0.2);
} }
.story-exp-background {
display: inline-flex;
align-items: center;
justify-content: center;
text-decoration: none;
}
.story-exp-run-selector {
position: fixed;
left: 50%;
top: 68px;
z-index: 20;
display: grid;
gap: 8px;
width: min(720px, calc(100vw - 48px));
max-height: min(560px, calc(100vh - 160px));
overflow: auto;
border: 1px solid rgba(151, 185, 218, 0.18);
border-radius: 16px;
padding: 14px;
background: rgba(4, 10, 20, 0.9);
box-shadow: var(--story-shadow);
backdrop-filter: blur(18px);
transform: translateX(-50%);
}
.story-exp-run-selector strong {
color: var(--story-text);
}
.story-exp-run-selector a {
border: 1px solid rgba(255, 255, 255, 0.075);
border-radius: 10px;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.04);
color: var(--story-soft);
text-decoration: none;
}
.story-exp-run-selector a:hover {
border-color: rgba(215, 168, 93, 0.42);
color: var(--story-text);
}
.story-exp-status-message {
position: fixed;
left: 50%;
top: 68px;
z-index: 19;
width: min(720px, calc(100vw - 48px));
border: 1px solid rgba(215, 168, 93, 0.32);
border-radius: 12px;
padding: 10px 14px;
background: rgba(6, 14, 26, 0.9);
box-shadow: var(--story-shadow);
color: var(--story-text);
font-size: 0.84rem;
line-height: 1.45;
transform: translateX(-50%);
}
.story-exp-shell { .story-exp-shell {
display: grid; display: grid;
grid-template-columns: minmax(206px, 0.54fr) minmax(760px, 2.44fr) minmax(214px, 0.56fr); grid-template-columns: minmax(206px, 0.54fr) minmax(760px, 2.44fr) minmax(214px, 0.56fr);
@ -607,22 +593,31 @@ input:focus-visible {
stroke: rgba(182, 162, 255, 0.44); stroke: rgba(182, 162, 255, 0.44);
} }
.story-exp-connection-label-bg {
fill: rgba(4, 10, 20, 0.72);
stroke: rgba(151, 185, 218, 0.12);
stroke-width: 1px;
}
.story-exp-connection-label { .story-exp-connection-label {
paint-order: stroke; display: -webkit-box;
stroke: rgba(4, 10, 20, 0.68); width: 100%;
stroke-width: 3px; max-height: 100%;
fill: var(--story-soft); overflow: hidden;
border: 1px solid rgba(151, 185, 218, 0.18);
border-radius: 8px;
padding: 4px 7px 5px;
background: rgba(4, 10, 20, 0.86);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.32), 0 0 14px rgba(4, 10, 20, 0.55);
color: var(--story-soft);
font-size: 0.66rem; font-size: 0.66rem;
font-weight: 800; font-weight: 800;
line-height: 1.15;
letter-spacing: 0.01em; letter-spacing: 0.01em;
overflow-wrap: anywhere;
text-align: center;
text-transform: lowercase; text-transform: lowercase;
filter: drop-shadow(0 0 9px rgba(0, 0, 0, 0.5)); -webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.story-exp-connection-label-wrap {
overflow: visible;
pointer-events: none;
} }
.story-exp-zone { .story-exp-zone {
@ -719,7 +714,7 @@ input:focus-visible {
display: grid; display: grid;
justify-items: center; justify-items: center;
gap: 2px; gap: 2px;
width: min(176px, calc(var(--node-size, 150px) + 44px)); width: min(188px, calc(var(--node-size, 150px) + 58px));
max-width: calc(100vw - 32px); max-width: calc(100vw - 32px);
margin-left: 0; margin-left: 0;
border: 1px solid rgba(151, 185, 218, 0.1); border: 1px solid rgba(151, 185, 218, 0.1);
@ -769,23 +764,27 @@ input:focus-visible {
} }
.story-character__text strong { .story-character__text strong {
display: -webkit-box;
overflow: hidden; overflow: hidden;
max-width: 100%; max-width: 100%;
font-size: clamp(0.82rem, 0.9vw, 0.98rem); font-size: clamp(0.82rem, 0.9vw, 0.98rem);
line-height: 1.05; line-height: 1.05;
text-overflow: ellipsis; overflow-wrap: anywhere;
white-space: nowrap; -webkit-box-orient: vertical;
-webkit-line-clamp: 2;
} }
.story-character__text span { .story-character__text span {
display: -webkit-box;
overflow: hidden; overflow: hidden;
max-width: 100%; max-width: 100%;
color: var(--story-muted); color: var(--story-muted);
font-size: 0.68rem; font-size: 0.68rem;
font-weight: 700; font-weight: 700;
line-height: 1.1; line-height: 1.1;
text-overflow: ellipsis; overflow-wrap: anywhere;
white-space: nowrap; -webkit-box-orient: vertical;
-webkit-line-clamp: 3;
} }
.story-character.is-context .story-character__text { .story-character.is-context .story-character__text {
@ -807,7 +806,7 @@ input:focus-visible {
.story-location.is-changing { .story-location.is-changing {
opacity: 0.35; opacity: 0.35;
transform: scale(0.98); transform: translate(var(--x, 0), var(--y, 0)) scale(0.98);
} }
.story-location__image { .story-location__image {
@ -918,11 +917,12 @@ input:focus-visible {
.story-exp-ribbon-wrap { .story-exp-ribbon-wrap {
display: grid; display: grid;
gap: 12px; grid-template-rows: minmax(76px, auto) 54px;
gap: 10px;
min-width: 0; min-width: 0;
border: 1px solid rgba(151, 185, 218, 0.1); border: 1px solid rgba(151, 185, 218, 0.1);
border-radius: 24px; border-radius: 24px;
padding: 15px; padding: 12px 14px 14px;
background: rgba(6, 16, 30, 0.54); background: rgba(6, 16, 30, 0.54);
box-shadow: 0 20px 58px rgba(0, 0, 0, 0.22); box-shadow: 0 20px 58px rgba(0, 0, 0, 0.22);
backdrop-filter: blur(14px); backdrop-filter: blur(14px);
@ -934,18 +934,19 @@ input:focus-visible {
align-items: stretch; align-items: stretch;
justify-content: center; justify-content: center;
gap: 12px; gap: 12px;
min-height: 94px; min-height: 76px;
overflow: hidden; overflow: hidden;
} }
.story-scene-card { .story-scene-card {
flex: 0 0 136px; flex: 0 0 136px;
display: grid; display: grid;
align-content: space-between; grid-template-rows: auto minmax(0, 1fr) auto;
gap: 7px; align-content: start;
gap: 5px;
border: 1px solid rgba(255, 255, 255, 0.07); border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 15px; border-radius: 15px;
padding: 10px 11px; padding: 9px 10px;
background: rgba(255, 255, 255, 0.032); background: rgba(255, 255, 255, 0.032);
color: var(--story-muted); color: var(--story-muted);
opacity: 0.68; opacity: 0.68;
@ -953,8 +954,12 @@ input:focus-visible {
transition: transform 840ms cubic-bezier(.16, .84, .28, 1), opacity 760ms ease, background 760ms ease, border-color 760ms ease, box-shadow 760ms ease; transition: transform 840ms cubic-bezier(.16, .84, .28, 1), opacity 760ms ease, background 760ms ease, border-color 760ms ease, box-shadow 760ms ease;
} }
.story-scene-card.is-complete {
opacity: 0.82;
}
.story-scene-card.is-current { .story-scene-card.is-current {
flex-basis: 194px; flex-basis: 186px;
border-color: rgba(215, 168, 93, 0.64); border-color: rgba(215, 168, 93, 0.64);
background: linear-gradient(180deg, rgba(215, 168, 93, 0.18), rgba(109, 181, 255, 0.075)); background: linear-gradient(180deg, rgba(215, 168, 93, 0.18), rgba(109, 181, 255, 0.075));
color: var(--story-text); color: var(--story-text);
@ -964,24 +969,39 @@ input:focus-visible {
} }
.story-scene-card strong { .story-scene-card strong {
display: -webkit-box;
overflow: hidden;
color: inherit; color: inherit;
font-size: 0.86rem; font-size: 0.86rem;
line-height: 1.12;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
} }
.story-scene-card span { .story-scene-card span {
color: var(--story-muted); color: var(--story-muted);
font-size: 0.72rem; font-size: 0.66rem;
font-weight: 800; font-weight: 800;
letter-spacing: 0.06em; letter-spacing: 0.04em;
text-transform: uppercase; text-transform: uppercase;
} }
.story-scene-card small {
display: -webkit-box;
overflow: hidden;
color: var(--story-muted);
font-size: 0.68rem;
line-height: 1.1;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
}
.story-exp-timeline { .story-exp-timeline {
position: relative; position: relative;
display: grid; display: grid;
grid-template-columns: repeat(var(--timeline-count, 1), minmax(0, 1fr)); grid-template-columns: repeat(var(--timeline-count, 1), minmax(0, 1fr));
gap: 0; gap: 0;
min-height: 46px; min-height: 54px;
} }
.story-exp-timeline::before { .story-exp-timeline::before {
@ -999,11 +1019,24 @@ input:focus-visible {
position: relative; position: relative;
display: grid; display: grid;
justify-items: center; justify-items: center;
gap: 8px; gap: 6px;
color: var(--story-muted); color: var(--story-muted);
font-size: 0.73rem; font-size: 0.66rem;
font-weight: 700; font-weight: 700;
text-align: center; text-align: center;
transition: opacity 560ms ease, transform 720ms cubic-bezier(.16, .84, .28, 1);
}
.story-timeline-marker span {
display: block;
max-width: 72px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.story-timeline-marker.is-label-hidden span {
opacity: 0;
} }
.story-timeline-marker::before { .story-timeline-marker::before {
@ -1040,6 +1073,11 @@ input:focus-visible {
transition: opacity 560ms ease, transform 700ms cubic-bezier(.16, .84, .28, 1); transition: opacity 560ms ease, transform 700ms cubic-bezier(.16, .84, .28, 1);
} }
.story-exp-list li,
.story-exp-feed li {
transition: opacity 520ms ease, transform 640ms cubic-bezier(.16, .84, .28, 1);
}
.story-exp-insight strong { .story-exp-insight strong {
color: var(--story-text); color: var(--story-text);
font-size: 0.92rem; font-size: 0.92rem;
@ -1092,6 +1130,85 @@ input:focus-visible {
width: 104px; width: 104px;
} }
.story-exp-diagnostics {
position: fixed;
left: 24px;
bottom: 18px;
z-index: 11;
width: min(420px, calc(100vw - 48px));
border: 1px solid rgba(151, 185, 218, 0.16);
border-radius: 16px;
background: rgba(4, 10, 20, 0.84);
box-shadow: var(--story-shadow);
color: var(--story-soft);
backdrop-filter: blur(16px);
}
.story-exp-diagnostics summary {
cursor: pointer;
padding: 10px 13px;
color: var(--story-text);
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.story-exp-diagnostics dl {
display: grid;
gap: 1px;
max-height: 280px;
overflow: auto;
margin: 0;
padding: 0 10px 10px;
}
.story-exp-diagnostics div {
display: grid;
grid-template-columns: 1fr 1.25fr;
gap: 10px;
border-top: 1px solid rgba(255, 255, 255, 0.07);
padding: 8px 0;
}
.story-exp-diagnostics dt {
color: var(--story-muted);
font-size: 0.68rem;
font-weight: 800;
}
.story-exp-diagnostics dd {
margin: 0;
overflow-wrap: anywhere;
color: var(--story-soft);
font-size: 0.72rem;
}
.is-entering {
opacity: 0;
transform: translateY(12px) scale(0.96);
}
.story-character.is-entering,
.story-asset.is-entering {
opacity: 0;
transform: translate(var(--x, 0), var(--y, 0)) scale(0.82);
}
.story-location.is-entering {
opacity: 0;
transform: translate(var(--x, 0), var(--y, 0)) scale(0.94);
}
.story-exp-connection-group.is-entering .story-exp-connection {
stroke-dashoffset: 1;
}
.is-removing {
opacity: 0 !important;
transform: translateY(12px) scale(0.96) !important;
}
@keyframes drawLine { @keyframes drawLine {
to { to {
stroke-dashoffset: 0; stroke-dashoffset: 0;
@ -1118,16 +1235,9 @@ input:focus-visible {
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
} }
.story-exp-pipeline {
grid-column: 1 / -1;
grid-row: 2;
overflow-x: auto;
padding-bottom: 4px;
}
.story-exp-context { .story-exp-context {
grid-column: 1 / -1; grid-column: 1 / -1;
grid-row: 3; grid-row: 2;
justify-content: start; justify-content: start;
} }

View File

@ -3,11 +3,13 @@
const dataNode = document.getElementById("storyExperienceData"); const dataNode = document.getElementById("storyExperienceData");
if (!root || !dataNode) return; if (!root || !dataNode) return;
const model = JSON.parse(dataNode.textContent || "{}"); let model = JSON.parse(dataNode.textContent || "{}");
const scenes = model.scenes || []; let scenes = model.scenes || [];
if (!scenes.length) return;
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const realMode = root.dataset.mode === "real";
const simulationMode = root.dataset.mode === "simulation";
const snapshotUrl = root.dataset.snapshotUrl || "";
const failedImageUrls = new Set(); const failedImageUrls = new Set();
const preloadedImageUrls = new Set(); const preloadedImageUrls = new Set();
const state = { const state = {
@ -15,25 +17,30 @@
playing: true, playing: true,
intervalMs: 6200, intervalMs: 6200,
timer: null, timer: null,
drawRequest: null drawRequest: null,
pollTimer: null,
polling: false,
changeToken: model.changeToken || root.dataset.changeToken || "",
terminal: model.isTerminal || root.dataset.terminal === "true",
pollingStoppedReason: "",
lastSnapshotHttpStatus: "initial",
consecutiveUnchangedPolls: 0,
consecutiveFailedPolls: 0,
lastSuccessfulSnapshotFetch: "Initial",
currentSnapshot: model,
previousSnapshot: null,
currentVersion: 1,
previousVersion: 0,
operationQueue: [],
lastOperations: ["InitialRender"],
snapshotBuildMs: 0,
lastRefreshTime: "Initial"
}; };
const analysisStages = [
{ key: "reading", label: "Reading" },
{ key: "chapters", label: "Chapters" },
{ key: "scenes", label: "Scenes" },
{ key: "content", label: "Content" },
{ key: "characters", label: "Characters" },
{ key: "locations", label: "Locations" },
{ key: "assets", label: "Assets" },
{ key: "relationships", label: "Relationships" },
{ key: "knowledge", label: "Knowledge" },
{ key: "continuity", label: "Continuity" },
{ key: "finalising", label: "Finalising" }
];
const dom = { const dom = {
stageLabel: root.querySelector("[data-stage-label]"), stageLabel: root.querySelector("[data-stage-label]"),
stageRail: root.querySelector("[data-analysis-stages]"), projectName: root.querySelector("[data-project-name]"),
bookTitle: root.querySelector("[data-book-title]"),
snapshotMessage: root.querySelector("[data-snapshot-message]"),
currentScene: root.querySelector("[data-current-scene]"), currentScene: root.querySelector("[data-current-scene]"),
progressPercent: root.querySelector("[data-progress-percent]"), progressPercent: root.querySelector("[data-progress-percent]"),
elapsedTime: root.querySelector("[data-elapsed-time]"), elapsedTime: root.querySelector("[data-elapsed-time]"),
@ -57,23 +64,37 @@
knowledge: root.querySelector("[data-knowledge]"), knowledge: root.querySelector("[data-knowledge]"),
observations: root.querySelector("[data-observations]"), observations: root.querySelector("[data-observations]"),
play: root.querySelector("[data-control='play']"), play: root.querySelector("[data-control='play']"),
speed: root.querySelector("[data-speed-control]") speed: root.querySelector("[data-speed-control]"),
diagnostics: root.querySelector("[data-diagnostics]"),
diagnosticsCurrent: root.querySelector("[data-diagnostics-current]"),
diagnosticsPrevious: root.querySelector("[data-diagnostics-previous]"),
diagnosticsToken: root.querySelector("[data-diagnostics-token]"),
diagnosticsQueue: root.querySelector("[data-diagnostics-queue]"),
diagnosticsRefresh: root.querySelector("[data-diagnostics-refresh]"),
diagnosticsBuild: root.querySelector("[data-diagnostics-build]"),
diagnosticsOperations: root.querySelector("[data-diagnostics-operations]"),
diagnosticValues: root.querySelectorAll("[data-diagnostic-key]")
}; };
if (!scenes.length) {
updateDiagnostics();
return;
}
function loadSceneState(index) { function loadSceneState(index) {
return scenes[Math.max(0, Math.min(scenes.length - 1, index))]; return scenes[Math.max(0, Math.min(scenes.length - 1, index))];
} }
function transitionToNextState(nextIndex) { function transitionToNextState(nextIndex, operations) {
const previous = loadSceneState(state.index); const previous = loadSceneState(state.index);
state.index = Math.max(0, Math.min(scenes.length - 1, nextIndex)); state.index = Math.max(0, Math.min(scenes.length - 1, nextIndex));
const current = loadSceneState(state.index); const current = loadSceneState(state.index);
queueOperations(operations || compareScenes(previous, current));
preloadSceneImages(current); preloadSceneImages(current);
preloadSceneImages(loadSceneState(Math.min(scenes.length - 1, state.index + 1))); preloadSceneImages(loadSceneState(Math.min(scenes.length - 1, state.index + 1)));
logImageDiagnostics(current); logImageDiagnostics(current);
updateProgress(current); updateProgress(current);
updateAnalysisStages(current);
updateSceneCopy(current); updateSceneCopy(current);
const layout = sceneLayout(current); const layout = sceneLayout(current);
reconcileCharacters(current, previous, layout); reconcileCharacters(current, previous, layout);
@ -85,13 +106,45 @@
updateTimeline(current); updateTimeline(current);
updateFeeds(current); updateFeeds(current);
scheduleConnectionDraw(); scheduleConnectionDraw();
updateDiagnostics();
}
function replaceModel(nextModel, snapshotBuildMs) {
if (!nextModel) return;
const operations = compareSnapshots(state.currentSnapshot, nextModel);
if (!operations.length && !nextModel.isTerminal) return;
state.previousSnapshot = state.currentSnapshot;
state.currentSnapshot = nextModel;
state.previousVersion = state.currentVersion;
state.currentVersion += 1;
state.operationQueue = operations;
state.lastOperations = operations.map((operation) => operation.type);
state.snapshotBuildMs = snapshotBuildMs || 0;
state.lastRefreshTime = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
model = nextModel || model;
scenes = model.scenes || [];
state.changeToken = model.changeToken || state.changeToken;
state.terminal = !!model.isTerminal;
state.index = realMode ? Math.max(0, scenes.length - 1) : Math.min(state.index, Math.max(0, scenes.length - 1));
if (model.projectName) {
text(dom.projectName, model.projectName);
}
if (model.bookTitle) {
text(dom.bookTitle, model.bookTitle);
}
if (model.snapshotMessage) {
text(dom.snapshotMessage, model.snapshotMessage);
}
transitionToNextState(state.index, operations);
if (state.terminal) stopPolling("terminal model received");
} }
function updateProgress(scene) { function updateProgress(scene) {
text(dom.stageLabel, scene.stage); text(dom.stageLabel, scene.stage);
text(dom.currentScene, scene.sceneNumber); text(dom.currentScene, scene.sceneNumber);
text(dom.progressPercent, `${scene.progressPercent}%`); animateNumber(dom.progressPercent, scene.progressPercent, "%");
text(dom.elapsedTime, `${Math.max(1, Math.round(scene.progressPercent / 9))}m`); text(dom.elapsedTime, scene.elapsedTime || "Calculating");
text(dom.estimatedRemaining, scene.estimatedRemaining); text(dom.estimatedRemaining, scene.estimatedRemaining);
text(dom.chapterCount, `${scene.chaptersAnalysed} of ${scene.chapterTotal}`); text(dom.chapterCount, `${scene.chaptersAnalysed} of ${scene.chapterTotal}`);
text(dom.sceneCount, `${scene.scenesAnalysed} of ${scene.sceneTotal}`); text(dom.sceneCount, `${scene.scenesAnalysed} of ${scene.sceneTotal}`);
@ -99,37 +152,6 @@
if (dom.progressRing) dom.progressRing.style.setProperty("--progress-value", `${scene.progressPercent * 3.6}deg`); if (dom.progressRing) dom.progressRing.style.setProperty("--progress-value", `${scene.progressPercent * 3.6}deg`);
} }
function updateAnalysisStages(scene) {
if (!dom.stageRail) return;
const currentIndex = stageIndex(scene);
dom.stageRail.innerHTML = "";
analysisStages.forEach((stage, index) => {
const node = document.createElement("li");
node.className = "story-exp-pipeline-step";
node.classList.toggle("is-complete", index < currentIndex || scene.progressPercent >= 100);
node.classList.toggle("is-current", index === currentIndex && scene.progressPercent < 100);
node.classList.toggle("is-future", index > currentIndex && scene.progressPercent < 100);
node.innerHTML = "<span></span><strong></strong>";
text(node.querySelector("strong"), stage.label);
dom.stageRail.append(node);
});
}
function stageIndex(scene) {
const stage = String(scene.stage || "").toLowerCase();
if (scene.progressPercent >= 100 || stage.includes("review")) return analysisStages.length - 1;
if (stage.includes("continuity")) return 9;
if (stage.includes("knowledge")) return 8;
if (stage.includes("relationship")) return 7;
if (stage.includes("asset")) return 6;
if (stage.includes("location")) return 5;
if (stage.includes("character")) return 4;
if (stage.includes("content") || stage.includes("inferring")) return 3;
if (stage.includes("scene")) return 2;
if (stage.includes("chapter")) return 1;
return Math.max(0, Math.min(analysisStages.length - 1, Math.floor(scene.progressPercent / 10)));
}
function updateSceneCopy(scene) { function updateSceneCopy(scene) {
text(dom.sceneTitle, `Scene ${scene.sceneNumber}${scene.title}`); text(dom.sceneTitle, `Scene ${scene.sceneNumber}${scene.title}`);
text(dom.sceneSummary, scene.summary); text(dom.sceneSummary, scene.summary);
@ -188,7 +210,13 @@
recordId: resolution.illustrationLibraryItemId || null, recordId: resolution.illustrationLibraryItemId || null,
status: resolution.status || "fallback", status: resolution.status || "fallback",
finalImageUrl: resolution.finalImageUrl || item?.imagePath || "", finalImageUrl: resolution.finalImageUrl || item?.imagePath || "",
fallbackUsed: !!resolution.fallbackUsed fallbackUsed: !!resolution.fallbackUsed,
assignmentConfidence: resolution.assignmentConfidence || null,
matchingScore: resolution.matchingScore || null,
reasonChosen: resolution.reasonChosen || "",
reasonReassigned: resolution.reasonReassigned || "",
rejectedCandidates: resolution.rejectedCandidates || "",
demandStatus: resolution.demandStatus || ""
}); });
}; };
@ -204,7 +232,9 @@
let node = dom.characters.querySelector(`[data-character-id="${cssEscape(character.id)}"]`); let node = dom.characters.querySelector(`[data-character-id="${cssEscape(character.id)}"]`);
if (!node) { if (!node) {
node = createCharacterNode(character); node = createCharacterNode(character);
node.classList.add("is-entering");
dom.characters.append(node); dom.characters.append(node);
settleEntering(node);
} }
const position = layout.characters.get(character.id) || characterPosition(character, index, scene.povCharacterId, scene.characters); const position = layout.characters.get(character.id) || characterPosition(character, index, scene.povCharacterId, scene.characters);
@ -240,32 +270,35 @@
function reconcileLocation(scene, previous) { function reconcileLocation(scene, previous) {
const changed = previous?.location?.id !== scene.location.id; const changed = previous?.location?.id !== scene.location.id;
if (changed) { let node = dom.location.querySelector("[data-location-id]");
dom.location.firstElementChild?.classList.add("is-changing"); if (!node) {
} node = document.createElement("article");
window.setTimeout(() => {
dom.location.innerHTML = "";
const node = document.createElement("article");
node.className = "story-location"; node.className = "story-location";
node.dataset.locationId = scene.location.id;
node.innerHTML = ` node.innerHTML = `
<div class="story-location__image"><img alt="${escapeHtml(scene.location.name)} prototype location" /></div> <div class="story-location__image"><img alt="${escapeHtml(scene.location.name)} prototype location" /></div>
<div class="story-location__text"> <div class="story-location__text">
<h2></h2> <h2></h2>
<p></p> <p></p>
</div>`; </div>`;
const position = locationPosition();
node.style.setProperty("--node-size", `${position.size}px`);
node.style.setProperty("--x", `${position.x}px`);
node.style.setProperty("--y", `${position.y}px`);
setEntityImage(node.querySelector("img"), scene.location.imagePath, scene.location.imageResolution, `${scene.location.name} prototype location`);
text(node.querySelector("h2"), scene.location.name);
text(node.querySelector("p"), conciseLocationLabel(scene.location));
node.title = `${scene.location.name}: ${scene.location.label}`;
dom.location.append(node); dom.location.append(node);
scheduleConnectionDraw(); node.classList.add("is-entering");
}, changed && !reducedMotion ? 260 : 0); settleEntering(node);
}
if (changed) {
node.classList.add("is-changing");
window.setTimeout(() => node.classList.remove("is-changing"), reducedMotion ? 0 : 420);
}
node.dataset.locationId = scene.location.id;
const position = locationPosition();
node.style.setProperty("--node-size", `${position.size}px`);
node.style.setProperty("--x", `${position.x}px`);
node.style.setProperty("--y", `${position.y}px`);
setEntityImage(node.querySelector("img"), scene.location.imagePath, scene.location.imageResolution, `${scene.location.name} prototype location`);
text(node.querySelector("h2"), scene.location.name);
text(node.querySelector("p"), conciseLocationLabel(scene.location));
node.title = `${scene.location.name}: ${scene.location.label}`;
} }
function reconcileAssets(scene, previous, layout) { function reconcileAssets(scene, previous, layout) {
@ -274,7 +307,9 @@
let node = dom.assets.querySelector(`[data-asset-id="${cssEscape(asset.id)}"]`); let node = dom.assets.querySelector(`[data-asset-id="${cssEscape(asset.id)}"]`);
if (!node) { if (!node) {
node = createAssetNode(asset); node = createAssetNode(asset);
node.classList.add("is-entering");
dom.assets.append(node); dom.assets.append(node);
settleEntering(node);
} }
const position = layout.assets.get(asset.id) || assetPosition(asset, index, scene.assets.length); const position = layout.assets.get(asset.id) || assetPosition(asset, index, scene.assets.length);
@ -320,30 +355,68 @@
const currentIndex = state.index; const currentIndex = state.index;
const start = Math.max(0, Math.min(scenes.length - 7, currentIndex - 3)); const start = Math.max(0, Math.min(scenes.length - 7, currentIndex - 3));
const visible = scenes.slice(start, start + 7); const visible = scenes.slice(start, start + 7);
dom.ribbon.innerHTML = ""; const activeIds = new Set(visible.map((item) => item.id));
visible.forEach((item) => { visible.forEach((item) => {
const card = document.createElement("article"); let card = dom.ribbon.querySelector(`[data-scene-id="${cssEscape(item.id)}"]`);
card.className = "story-scene-card"; if (!card) {
card = document.createElement("article");
card.className = "story-scene-card is-entering";
card.dataset.sceneId = item.id;
card.innerHTML = `<span></span><strong></strong><small></small>`;
settleEntering(card);
}
card.classList.toggle("is-current", item.id === scene.id); card.classList.toggle("is-current", item.id === scene.id);
card.innerHTML = `<span>Scene ${item.sceneNumber}</span><strong></strong><small></small>`; card.classList.toggle("is-complete", item.sceneNumber < scene.sceneNumber);
text(card.querySelector("strong"), item.title); text(card.querySelector("span"), `${(item.chapterLabel || "Chapter").toUpperCase()} · SCENE ${item.sceneNumber}`);
text(card.querySelector("small"), item.timelineEvent); text(card.querySelector("strong"), compactPhrase(cleanSceneTitle(item.title), item.id === scene.id ? 44 : 30));
text(card.querySelector("small"), compactPhrase(item.timelineEvent, 34));
dom.ribbon.append(card); dom.ribbon.append(card);
}); });
removeInactive(dom.ribbon, "[data-scene-id]", "sceneId", activeIds);
} }
function updateTimeline(scene) { function updateTimeline(scene) {
const completed = scenes.slice(0, state.index + 1); const completed = scenes.slice(0, state.index + 1);
dom.timeline.style.setProperty("--timeline-count", String(completed.length)); dom.timeline.style.setProperty("--timeline-count", String(completed.length));
dom.timeline.innerHTML = ""; const activeIds = new Set(completed.map((item) => item.id));
completed.forEach((item) => { completed.forEach((item) => {
const marker = document.createElement("div"); let marker = dom.timeline.querySelector(`[data-timeline-id="${cssEscape(item.id)}"]`);
marker.className = "story-timeline-marker"; if (!marker) {
marker = document.createElement("div");
marker.className = "story-timeline-marker is-entering";
marker.dataset.timelineId = item.id;
marker.innerHTML = `<span></span>`;
settleEntering(marker);
}
marker.classList.toggle("is-current", item.id === scene.id); marker.classList.toggle("is-current", item.id === scene.id);
marker.innerHTML = `<span></span>`; const shouldLabel = shouldShowTimelineLabel(completed, item, scene);
text(marker.querySelector("span"), `${item.timelineLabel} - S${item.sceneNumber}`); marker.classList.toggle("is-label-hidden", !shouldLabel);
text(marker.querySelector("span"), shortTimelineLabel(item));
dom.timeline.append(marker); dom.timeline.append(marker);
}); });
removeInactive(dom.timeline, "[data-timeline-id]", "timelineId", activeIds);
}
function shouldShowTimelineLabel(items, item, scene) {
const index = items.findIndex((candidate) => candidate.id === item.id);
if (index === 0 || index === items.length - 1 || item.id === scene.id) return true;
const interval = items.length > 12 ? 5 : items.length > 8 ? 4 : 3;
return index % interval === 0 && Math.abs(item.sceneNumber - scene.sceneNumber) > 1;
}
function shortTimelineLabel(item) {
const value = String(item.timelineLabel || "").trim();
if (value && !/book\s+\d|alpha flame|chapter/i.test(value) && value.length <= 18) return value;
return `${abbreviateChapter(item.chapterLabel)} · S${item.sceneNumber}`;
}
function abbreviateChapter(value) {
const match = String(value || "").match(/(\d+(?:\.\d+)?)/);
return match ? `Ch ${match[1]}` : "Ch";
}
function cleanSceneTitle(value) {
return String(value || "").replace(/^book\s+\d+\s*[:|-]\s*/i, "").replace(/^the alpha flame[^:|-]*[:|-]\s*/i, "").trim();
} }
function updateFeeds(scene) { function updateFeeds(scene) {
@ -357,10 +430,11 @@
let node = container.querySelector(`[data-insight-id="${cssEscape(item.id)}"]`); let node = container.querySelector(`[data-insight-id="${cssEscape(item.id)}"]`);
if (!node) { if (!node) {
node = document.createElement("article"); node = document.createElement("article");
node.className = "story-exp-insight"; node.className = "story-exp-insight is-entering";
node.dataset.insightId = item.id; node.dataset.insightId = item.id;
node.innerHTML = `<span></span><strong></strong><p></p>`; node.innerHTML = `<span></span><strong></strong><p></p>`;
container.append(node); container.append(node);
settleEntering(node);
} }
node.dataset.tone = item.tone || ""; node.dataset.tone = item.tone || "";
@ -379,9 +453,16 @@
} }
function renderList(container, items) { function renderList(container, items) {
container.innerHTML = ""; const activeIds = new Set(items.map((item, index) => feedId(item, index)));
items.forEach((item) => { items.forEach((item, index) => {
const node = document.createElement(container.tagName === "OL" ? "li" : "li"); const id = feedId(item, index);
let node = container.querySelector(`[data-feed-id="${cssEscape(id)}"]`);
if (!node) {
node = document.createElement("li");
node.dataset.feedId = id;
node.className = "is-entering";
settleEntering(node);
}
if (container === dom.discoveries) { if (container === dom.discoveries) {
node.dataset.discoveryType = discoveryType(item); node.dataset.discoveryType = discoveryType(item);
node.innerHTML = "<span aria-hidden=\"true\"></span><p></p>"; node.innerHTML = "<span aria-hidden=\"true\"></span><p></p>";
@ -391,6 +472,11 @@
} }
container.append(node); container.append(node);
}); });
removeInactive(container, "[data-feed-id]", "feedId", activeIds);
}
function feedId(item, index) {
return `${index}-${stableHash(item)}`;
} }
function discoveryType(item) { function discoveryType(item) {
@ -416,8 +502,8 @@
function drawConnections(scene) { function drawConnections(scene) {
if (!dom.connections || !dom.visualStage) return; if (!dom.connections || !dom.visualStage) return;
const stageRect = dom.visualStage.getBoundingClientRect(); const stageRect = dom.visualStage.getBoundingClientRect();
dom.connections.innerHTML = "";
const drawn = []; const drawn = [];
const activeIds = new Set();
const occupiedRects = layoutRects(stageRect); const occupiedRects = layoutRects(stageRect);
scene.relationships.forEach((relationship, index) => { scene.relationships.forEach((relationship, index) => {
@ -428,13 +514,17 @@
const start = connectionPoint(source, stageRect, target); const start = connectionPoint(source, stageRect, target);
const end = connectionPoint(target, stageRect, source); const end = connectionPoint(target, stageRect, source);
const obstacles = connectionObstacles(stageRect, source, target); const obstacles = connectionObstacles(stageRect, source, target);
drawn.push(drawConnection(start, end, relationshipTone(relationship), relationship.weight, index < 2 ? inlineRelationshipLabel(relationship) : "", index, obstacles, occupiedRects)); const id = relationship.id || `relationship-${relationship.sourceId}-${relationship.targetId}`;
activeIds.add(id);
drawn.push(drawConnection(id, start, end, relationshipTone(relationship), relationship.weight, index < 2 ? inlineRelationshipLabel(relationship) : "", index, obstacles, occupiedRects));
}); });
const pov = dom.visualStage.querySelector(`[data-character-id="${cssEscape(scene.povCharacterId)}"]`); const pov = dom.visualStage.querySelector(`[data-character-id="${cssEscape(scene.povCharacterId)}"]`);
const location = dom.visualStage.querySelector("[data-location-id]"); const location = dom.visualStage.querySelector("[data-location-id]");
if (pov && location) { if (pov && location) {
drawn.push(drawConnection(connectionPoint(pov, stageRect, location), connectionPoint(location, stageRect, pov), "presence", 68, "", drawn.length, connectionObstacles(stageRect, pov, location), occupiedRects)); const id = `presence-${scene.povCharacterId}-${location.dataset.locationId}`;
activeIds.add(id);
drawn.push(drawConnection(id, connectionPoint(pov, stageRect, location), connectionPoint(location, stageRect, pov), "presence", 68, "", drawn.length, connectionObstacles(stageRect, pov, location), occupiedRects));
} }
scene.characters scene.characters
@ -443,56 +533,80 @@
.forEach((character) => { .forEach((character) => {
const node = dom.visualStage.querySelector(`[data-character-id="${cssEscape(character.id)}"]`); const node = dom.visualStage.querySelector(`[data-character-id="${cssEscape(character.id)}"]`);
if (node && location) { if (node && location) {
drawn.push(drawConnection(connectionPoint(node, stageRect, location), connectionPoint(location, stageRect, node), "presence-soft", character.weight, "", drawn.length, connectionObstacles(stageRect, node, location), occupiedRects)); const id = `presence-soft-${character.id}-${location.dataset.locationId}`;
activeIds.add(id);
drawn.push(drawConnection(id, connectionPoint(node, stageRect, location), connectionPoint(location, stageRect, node), "presence-soft", character.weight, "", drawn.length, connectionObstacles(stageRect, node, location), occupiedRects));
} }
}); });
scene.assets.forEach((asset, index) => { scene.assets.forEach((asset, index) => {
const assetNode = dom.visualStage.querySelector(`[data-asset-id="${cssEscape(asset.id)}"]`); const assetNode = dom.visualStage.querySelector(`[data-asset-id="${cssEscape(asset.id)}"]`);
if (!assetNode || !location) return; if (!assetNode || !location) return;
drawn.push(drawConnection(connectionPoint(location, stageRect, assetNode), connectionPoint(assetNode, stageRect, location), "evidence", asset.weight, index === 0 ? assetConnectionLabel(asset) : "", drawn.length, connectionObstacles(stageRect, location, assetNode), occupiedRects)); const id = `asset-${location.dataset.locationId}-${asset.id}`;
activeIds.add(id);
drawn.push(drawConnection(id, connectionPoint(location, stageRect, assetNode), connectionPoint(assetNode, stageRect, location), "evidence", asset.weight, index === 0 ? assetConnectionLabel(asset) : "", drawn.length, connectionObstacles(stageRect, location, assetNode), occupiedRects));
}); });
removeInactiveSvgConnections(activeIds);
} }
function drawConnection(start, end, tone, weight, label, index, obstacles, occupiedRects) { function drawConnection(id, start, end, tone, weight, label, index, obstacles, occupiedRects) {
const group = document.createElementNS("http://www.w3.org/2000/svg", "g"); let group = dom.connections.querySelector(`[data-connection-id="${cssEscape(id)}"]`);
const isNew = !group;
if (!group) {
group = document.createElementNS("http://www.w3.org/2000/svg", "g");
group.dataset.connectionId = id;
group.classList.add("is-entering");
dom.connections.append(group);
settleEntering(group);
}
group.setAttribute("class", `story-exp-connection-group story-exp-connection-group--${tone}`); group.setAttribute("class", `story-exp-connection-group story-exp-connection-group--${tone}`);
const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); let path = group.querySelector("path");
if (!path) {
path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("pathLength", "1");
path.setAttribute("class", "story-exp-connection");
group.append(path);
}
const route = routeConnection(start, end, index, obstacles); const route = routeConnection(start, end, index, obstacles);
const d = `M ${start.x} ${start.y} C ${route.startControl.x} ${route.startControl.y}, ${route.endControl.x} ${route.endControl.y}, ${end.x} ${end.y}`; const d = `M ${start.x} ${start.y} C ${route.startControl.x} ${route.startControl.y}, ${route.endControl.x} ${route.endControl.y}, ${end.x} ${end.y}`;
path.setAttribute("d", d); path.setAttribute("d", d);
path.setAttribute("pathLength", "1");
path.setAttribute("class", "story-exp-connection");
path.style.strokeWidth = String(0.9 + Math.min(weight, 100) / 62); path.style.strokeWidth = String(0.9 + Math.min(weight, 100) / 62);
group.append(path);
group.querySelector(".story-exp-connection-label-group")?.remove();
if (label) { if (label) {
const labelPosition = relationshipLabelPosition(start, route, end, occupiedRects); let displayLabel = label;
let labelBox = relationshipLabelBox(displayLabel);
let labelPosition = relationshipLabelPosition(start, route, end, occupiedRects, labelBox);
if (!labelPosition) {
displayLabel = compactPhrase(label, 24);
labelBox = relationshipLabelBox(displayLabel);
labelPosition = relationshipLabelPosition(start, route, end, occupiedRects, labelBox);
}
if (!labelPosition) { if (!labelPosition) {
dom.connections.append(group);
return group; return group;
} }
const labelGroup = document.createElementNS("http://www.w3.org/2000/svg", "g"); const labelGroup = document.createElementNS("http://www.w3.org/2000/svg", "g");
labelGroup.setAttribute("class", "story-exp-connection-label-group"); labelGroup.setAttribute("class", "story-exp-connection-label-group");
const labelNode = document.createElementNS("http://www.w3.org/2000/svg", "text"); const labelNode = document.createElementNS("http://www.w3.org/2000/svg", "foreignObject");
labelNode.setAttribute("class", "story-exp-connection-label"); labelNode.setAttribute("class", "story-exp-connection-label-wrap");
labelNode.setAttribute("x", String(labelPosition.x)); labelNode.setAttribute("x", String(labelPosition.x - labelBox.width / 2));
labelNode.setAttribute("y", String(labelPosition.y)); labelNode.setAttribute("y", String(labelPosition.y - labelBox.height / 2));
labelNode.setAttribute("text-anchor", "middle"); labelNode.setAttribute("width", String(labelBox.width));
labelNode.textContent = label; labelNode.setAttribute("height", String(labelBox.height));
labelNode.setAttribute("title", label);
const labelDiv = document.createElement("div");
labelDiv.className = "story-exp-connection-label";
labelDiv.title = label;
labelDiv.textContent = displayLabel;
labelNode.append(labelDiv);
labelGroup.append(labelNode); labelGroup.append(labelNode);
group.append(labelGroup); group.append(labelGroup);
occupiedRects.push({ occupiedRects.push(labelRect(labelPosition, labelBox));
left: labelPosition.x - 78,
right: labelPosition.x + 78,
top: labelPosition.y - 22,
bottom: labelPosition.y + 10
});
window.requestAnimationFrame(() => fitConnectionLabelBackground(labelGroup));
} }
dom.connections.append(group); if (isNew) group.classList.add("is-entering");
return group; return group;
} }
@ -832,29 +946,48 @@
}; };
} }
function relationshipLabelPosition(start, route, end, occupiedRects) { function relationshipLabelBox(label) {
const length = String(label || "").length;
const width = length > 42 ? 168 : length > 24 ? 144 : 118;
const lines = Math.min(3, Math.max(1, Math.ceil(length / 24)));
return { width, height: 18 + lines * 15 };
}
function relationshipLabelPosition(start, route, end, occupiedRects, labelBox) {
const sign = Math.sign(route.offset || 1); const sign = Math.sign(route.offset || 1);
const candidates = [0.42, 0.5, 0.58, 0.34, 0.66].flatMap((t) => { const bounds = dom.visualStage?.getBoundingClientRect();
const boundary = {
left: 6,
top: 6,
right: Math.max(10, bounds?.width || 1000) - 6,
bottom: Math.max(10, bounds?.height || 560) - 6
};
const candidates = [0.5, 0.42, 0.58, 0.32, 0.68, 0.24, 0.76].flatMap((t) => {
const point = pointOnCubic(start, route.startControl, route.endControl, end, t); const point = pointOnCubic(start, route.startControl, route.endControl, end, t);
return [34, 54, 76, -42, -64].map((distance) => ({ return [38, 62, 88, -42, -68, 112, -96].map((distance) => ({
x: point.x + route.normal.x * sign * distance, x: point.x + route.normal.x * sign * distance,
y: point.y + route.normal.y * sign * distance - 5 y: point.y + route.normal.y * sign * distance - 5
})); }));
}); });
const best = candidates.reduce((current, candidate) => { const best = candidates.reduce((current, candidate) => {
const score = occupiedRects.reduce((sum, rect) => sum + labelRectPenalty(candidate, rect), 0); const candidateRect = labelRect(candidate, labelBox);
const boundaryPenalty = candidateRect.left < boundary.left || candidateRect.right > boundary.right || candidateRect.top < boundary.top || candidateRect.bottom > boundary.bottom ? 2000 : 0;
const score = boundaryPenalty + occupiedRects.reduce((sum, rect) => sum + labelRectPenalty(candidateRect, rect), 0);
return score < current.score ? { point: candidate, score } : current; return score < current.score ? { point: candidate, score } : current;
}, { point: candidates[0], score: Number.POSITIVE_INFINITY }); }, { point: candidates[0], score: Number.POSITIVE_INFINITY });
return best.score >= 1000 ? null : best.point; return best.score >= 1000 ? null : best.point;
} }
function labelRectPenalty(point, rect) { function labelRect(point, labelBox) {
const labelRect = { return {
left: point.x - 70, left: point.x - labelBox.width / 2,
right: point.x + 70, right: point.x + labelBox.width / 2,
top: point.y - 18, top: point.y - labelBox.height / 2,
bottom: point.y + 8 bottom: point.y + labelBox.height / 2
}; };
}
function labelRectPenalty(labelRect, rect) {
if (rectsIntersect(labelRect, rect)) return 1000; if (rectsIntersect(labelRect, rect)) return 1000;
const dx = Math.max(rect.left - labelRect.right, 0, labelRect.left - rect.right); const dx = Math.max(rect.left - labelRect.right, 0, labelRect.left - rect.right);
const dy = Math.max(rect.top - labelRect.bottom, 0, labelRect.top - rect.bottom); const dy = Math.max(rect.top - labelRect.bottom, 0, labelRect.top - rect.bottom);
@ -865,20 +998,6 @@
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
} }
function fitConnectionLabelBackground(group) {
const label = group.querySelector(".story-exp-connection-label");
if (!label || group.querySelector("rect")) return;
const box = label.getBBox();
const background = document.createElementNS("http://www.w3.org/2000/svg", "rect");
background.setAttribute("class", "story-exp-connection-label-bg");
background.setAttribute("x", String(box.x - 8));
background.setAttribute("y", String(box.y - 4));
background.setAttribute("width", String(box.width + 16));
background.setAttribute("height", String(box.height + 8));
background.setAttribute("rx", "7");
group.insertBefore(background, label);
}
function pointRectPenalty(point, rect) { function pointRectPenalty(point, rect) {
if (point.x >= rect.left && point.x <= rect.right && point.y >= rect.top && point.y <= rect.bottom) return 1000; if (point.x >= rect.left && point.x <= rect.right && point.y >= rect.top && point.y <= rect.bottom) return 1000;
const dx = Math.max(rect.left - point.x, 0, point.x - rect.right); const dx = Math.max(rect.left - point.x, 0, point.x - rect.right);
@ -949,7 +1068,7 @@
function startTimer() { function startTimer() {
stopTimer(); stopTimer();
if (!state.playing) return; if (!state.playing || !simulationMode) return;
state.timer = window.setInterval(() => { state.timer = window.setInterval(() => {
const next = state.index >= scenes.length - 1 ? 0 : state.index + 1; const next = state.index >= scenes.length - 1 ? 0 : state.index + 1;
transitionToNextState(next); transitionToNextState(next);
@ -963,6 +1082,254 @@
} }
} }
function startPolling() {
if (!realMode || !snapshotUrl || state.terminal) return;
stopPolling();
state.pollingStoppedReason = "";
state.pollTimer = window.setInterval(pollSnapshot, document.hidden ? 15000 : 7000);
}
function stopPolling(reason) {
if (state.pollTimer) {
window.clearInterval(state.pollTimer);
state.pollTimer = null;
}
if (reason) state.pollingStoppedReason = reason;
}
async function pollSnapshot() {
if (state.polling || document.hidden || state.terminal) return;
state.polling = true;
const requestStarted = performance.now();
try {
const response = await fetch(snapshotUrl, { headers: { Accept: "application/json" } });
state.lastSnapshotHttpStatus = String(response.status);
if (!response.ok) {
state.consecutiveFailedPolls += 1;
updateDiagnostics();
return;
}
const snapshot = await response.json();
state.consecutiveFailedPolls = 0;
state.lastSuccessfulSnapshotFetch = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
if (snapshot.changeToken && snapshot.changeToken !== state.changeToken) {
state.consecutiveUnchangedPolls = 0;
replaceModel(snapshot, Math.round(performance.now() - requestStarted));
} else if (snapshot.isTerminal) {
state.terminal = true;
stopPolling("terminal snapshot received");
updateDiagnostics();
} else {
state.consecutiveUnchangedPolls += 1;
updateDiagnostics();
}
} catch {
state.lastSnapshotHttpStatus = "fetch failed";
state.consecutiveFailedPolls += 1;
updateDiagnostics();
} finally {
state.polling = false;
}
}
function compareSnapshots(previousSnapshot, nextSnapshot) {
if (!previousSnapshot) return [{ type: "InitialRender" }];
const operations = [];
const previousScene = activeScene(previousSnapshot);
const nextScene = activeScene(nextSnapshot);
if ((previousSnapshot.changeToken || "") !== (nextSnapshot.changeToken || "")) {
operations.push({ type: "SnapshotChanged" });
}
if ((previousSnapshot.runStatus || "") !== (nextSnapshot.runStatus || "")) {
operations.push({ type: "StageChanged" });
}
if (!!previousSnapshot.isTerminal !== !!nextSnapshot.isTerminal && nextSnapshot.isTerminal) {
operations.push({ type: "RunCompleted" });
}
if (!previousScene || !nextScene) return operations;
operations.push(...compareScenes(previousScene, nextScene));
return compactOperations(operations);
}
function compareScenes(previousScene, nextScene) {
if (!previousScene || !nextScene) return [{ type: "SceneChanged" }];
const operations = [];
if (previousScene.progressPercent !== nextScene.progressPercent) operations.push({ type: "ProgressChanged" });
if (previousScene.stage !== nextScene.stage) operations.push({ type: "StageChanged" });
if (previousScene.id !== nextScene.id || previousScene.sceneNumber !== nextScene.sceneNumber) operations.push({ type: "SceneChanged" });
if ((previousScene.scenesAnalysed || 0) !== (nextScene.scenesAnalysed || 0)) operations.push({ type: "TimelineAdvanced" });
if (previousScene.location?.id !== nextScene.location?.id) operations.push({ type: "LocationChanged" });
compareEntityLists(previousScene.characters, nextScene.characters, "Character", operations, (before, after) => {
if (Math.abs((before.weight || 0) - (after.weight || 0)) >= 5) operations.push({ type: "CharacterImportanceChanged", id: after.id });
if ((before.imagePath || "") !== (after.imagePath || "")) operations.push({ type: "CharacterImageChanged", id: after.id });
});
compareEntityLists(previousScene.assets, nextScene.assets, "Asset", operations, (before, after) => {
if (Math.abs((before.weight || 0) - (after.weight || 0)) >= 5) operations.push({ type: "AssetImportanceChanged", id: after.id });
});
compareEntityLists(previousScene.relationships, nextScene.relationships, "Relationship", operations, (before, after) => {
if (Math.abs((before.weight || 0) - (after.weight || 0)) >= 5) operations.push({ type: "RelationshipStrengthChanged", id: after.id });
});
compareTextItems(previousScene.knowledgeThreads, nextScene.knowledgeThreads, "KnowledgeThread", operations);
compareTextItems(previousScene.observations, nextScene.observations, "Observation", operations);
compareStrings(previousScene.latestDiscoveries, nextScene.latestDiscoveries, "DiscoveryAdded", operations);
return compactOperations(operations);
}
function compareEntityLists(previousItems, nextItems, prefix, operations, comparePersisted) {
const previousById = keyedById(previousItems);
const nextById = keyedById(nextItems);
nextById.forEach((item, id) => {
if (!previousById.has(id)) {
operations.push({ type: `${prefix}Added`, id });
} else {
comparePersisted(previousById.get(id), item);
}
});
previousById.forEach((item, id) => {
if (!nextById.has(id)) operations.push({ type: `${prefix}Removed`, id });
});
}
function compareTextItems(previousItems, nextItems, prefix, operations) {
const previousById = keyedById(previousItems);
const nextById = keyedById(nextItems);
nextById.forEach((item, id) => {
if (!previousById.has(id)) operations.push({ type: `${prefix}Added`, id });
});
previousById.forEach((item, id) => {
if (!nextById.has(id)) operations.push({ type: prefix === "KnowledgeThread" ? "KnowledgeThreadResolved" : `${prefix}Removed`, id });
});
}
function compareStrings(previousItems, nextItems, type, operations) {
const previousSet = new Set(previousItems || []);
(nextItems || []).forEach((item) => {
if (!previousSet.has(item)) operations.push({ type, id: stableHash(item) });
});
}
function activeScene(snapshot) {
const snapshotScenes = snapshot?.scenes || [];
return snapshotScenes.length ? snapshotScenes[snapshotScenes.length - 1] : null;
}
function keyedById(items) {
return new Map((items || []).filter((item) => item?.id).map((item) => [item.id, item]));
}
function compactOperations(operations) {
const seen = new Set();
return operations.filter((operation) => {
const key = `${operation.type}:${operation.id || ""}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function queueOperations(operations) {
const nextOperations = operations?.length ? operations : [{ type: "NoVisualChange" }];
state.operationQueue = nextOperations;
state.lastOperations = nextOperations.map((operation) => operation.type);
}
function updateDiagnostics() {
if (!dom.diagnostics) return;
text(dom.diagnosticsCurrent, state.currentVersion);
text(dom.diagnosticsPrevious, state.previousVersion);
text(dom.diagnosticsToken, state.changeToken || "none");
text(dom.diagnosticsQueue, state.operationQueue.length);
text(dom.diagnosticsRefresh, state.lastRefreshTime);
text(dom.diagnosticsBuild, `${state.snapshotBuildMs}ms`);
text(dom.diagnosticsOperations, state.lastOperations.join(", "));
const diagnostics = model.diagnostics || {};
dom.diagnosticValues.forEach((node) => {
const key = node.dataset.diagnosticKey;
const value = pollingDiagnosticValue(key, diagnostics);
text(node, value);
});
}
function pollingDiagnosticValue(key, diagnostics) {
if (key === "lastSuccessfulSnapshotFetch") return state.lastSuccessfulSnapshotFetch;
if (key === "lastSnapshotHttpStatus") return state.lastSnapshotHttpStatus;
if (key === "consecutiveUnchangedPolls") return state.consecutiveUnchangedPolls;
if (key === "consecutiveFailedPolls") return state.consecutiveFailedPolls;
if (key === "pollingActive") return state.pollTimer ? "active" : "stopped";
if (key === "pollingStoppedReason") return state.pollingStoppedReason || "none";
if (key === "currentPollingInterval") return realMode ? `${document.hidden ? 15000 : 7000}ms` : `${state.intervalMs}ms`;
if (key === "latestSnapshotAge") return latestSnapshotAge();
const value = diagnostics[key];
return value === null || value === undefined || value === "" ? "none" : value;
}
function latestSnapshotAge() {
if (!model.generatedUtc) return "unknown";
const generated = Date.parse(model.generatedUtc);
if (Number.isNaN(generated)) return "unknown";
const seconds = Math.max(0, Math.round((Date.now() - generated) / 1000));
return seconds < 60 ? `${seconds}s` : `${Math.round(seconds / 60)}m`;
}
function animateNumber(node, nextValue, suffix) {
if (!node) return;
const previousValue = Number.parseInt(node.textContent, 10);
if (reducedMotion || Number.isNaN(previousValue)) {
text(node, `${nextValue}${suffix}`);
return;
}
const start = performance.now();
const duration = 720;
const delta = nextValue - previousValue;
if (delta === 0) {
text(node, `${nextValue}${suffix}`);
return;
}
const tick = (time) => {
const progress = clamp((time - start) / duration, 0, 1);
const eased = 1 - (1 - progress) ** 3;
text(node, `${Math.round(previousValue + delta * eased)}${suffix}`);
if (progress < 1) window.requestAnimationFrame(tick);
};
window.requestAnimationFrame(tick);
}
function removeInactive(container, selector, datasetKey, activeIds) {
container.querySelectorAll(selector).forEach((node) => {
if (activeIds.has(node.dataset[datasetKey])) return;
node.classList.add("is-removing");
window.setTimeout(() => node.remove(), reducedMotion ? 0 : 420);
});
}
function removeInactiveSvgConnections(activeIds) {
dom.connections.querySelectorAll("[data-connection-id]").forEach((node) => {
if (activeIds.has(node.dataset.connectionId)) return;
node.classList.add("is-removing");
window.setTimeout(() => node.remove(), reducedMotion ? 0 : 420);
});
}
function settleEntering(node) {
if (reducedMotion) {
node.classList.remove("is-entering");
return;
}
window.requestAnimationFrame(() => node.classList.remove("is-entering"));
}
function stableHash(value) {
const textValue = String(value || "");
let hash = 0;
for (let index = 0; index < textValue.length; index += 1) {
hash = ((hash << 5) - hash + textValue.charCodeAt(index)) | 0;
}
return Math.abs(hash).toString(36);
}
function text(node, value) { function text(node, value) {
if (node) node.textContent = value ?? ""; if (node) node.textContent = value ?? "";
} }
@ -982,31 +1349,42 @@
} }
root.querySelector("[data-control='restart']")?.addEventListener("click", () => { root.querySelector("[data-control='restart']")?.addEventListener("click", () => {
if (!simulationMode) return;
transitionToNextState(0); transitionToNextState(0);
startTimer(); startTimer();
}); });
root.querySelector("[data-control='previous']")?.addEventListener("click", () => { root.querySelector("[data-control='previous']")?.addEventListener("click", () => {
if (!simulationMode) return;
transitionToNextState(state.index <= 0 ? scenes.length - 1 : state.index - 1); transitionToNextState(state.index <= 0 ? scenes.length - 1 : state.index - 1);
startTimer(); startTimer();
}); });
root.querySelector("[data-control='next']")?.addEventListener("click", () => { root.querySelector("[data-control='next']")?.addEventListener("click", () => {
if (!simulationMode) return;
transitionToNextState(state.index >= scenes.length - 1 ? 0 : state.index + 1); transitionToNextState(state.index >= scenes.length - 1 ? 0 : state.index + 1);
startTimer(); startTimer();
}); });
dom.play?.addEventListener("click", () => { dom.play?.addEventListener("click", () => {
if (!simulationMode) return;
state.playing = !state.playing; state.playing = !state.playing;
text(dom.play, state.playing ? "Pause" : "Play"); text(dom.play, state.playing ? "Pause" : "Play");
if (state.playing) startTimer(); else stopTimer(); if (state.playing) startTimer(); else stopTimer();
}); });
dom.speed?.addEventListener("input", () => { dom.speed?.addEventListener("input", () => {
if (!simulationMode) return;
state.intervalMs = Number.parseInt(dom.speed.value, 10) || 6200; state.intervalMs = Number.parseInt(dom.speed.value, 10) || 6200;
startTimer(); startTimer();
}); });
document.addEventListener("visibilitychange", () => {
if (!realMode || state.terminal) return;
startPolling();
if (!document.hidden) pollSnapshot();
});
window.addEventListener("resize", () => { window.addEventListener("resize", () => {
const current = loadSceneState(state.index); const current = loadSceneState(state.index);
const layout = sceneLayout(current); const layout = sceneLayout(current);
@ -1027,6 +1405,8 @@
observer.observe(dom.visualStage); observer.observe(dom.visualStage);
} }
transitionToNextState(0); transitionToNextState(realMode ? Math.max(0, scenes.length - 1) : 0);
startTimer(); startTimer();
startPolling();
if (realMode && !state.terminal) pollSnapshot();
})(); })();

View File

@ -0,0 +1,124 @@
# Phase 21F - Story Intelligence Visualisation Snapshot
## Snapshot Architecture
Phase 21F adds a read-only real-data snapshot path for the existing Story Intelligence Experience prototype. The browser contract remains the existing `StoryIntelligenceExperiencePrototypeViewModel` shape so the renderer can display either simulation data or persisted Story Intelligence run data.
## Data Sources
The snapshot service reads:
- `StoryIntelligenceSavedRun`
- `StoryIntelligenceSavedSceneResult`
- parsed `SceneIntelligenceScene` JSON
- existing Illustration Library resolution through `IIllustrationLibraryService.ApplyApprovedIllustrationsAsync`
It deliberately does not expose source manuscript text, raw AI responses, prompts, or import write operations.
## DTO Contract
The model adds snapshot metadata:
- `mode`
- `runId`
- `runStatus`
- `changeToken`
- `generatedUtc`
- `isTerminal`
- `snapshotMessage`
- `availableRuns`
The renderer continues to consume `scenes`, `characters`, `location`, `assets`, `relationships`, `knowledgeThreads`, `observations`, `latestDiscoveries`, and `activityFeed`.
## Stable Visual IDs
Stable IDs are deterministic:
- scenes: `scene-result-{SceneResultID}`
- waiting scene: `run-{RunID}-waiting`
- characters: `character-result-{normalised-name}`
- locations: `location-result-{normalised-name}`
- assets: `asset-result-{normalised-name}`
- relationships: `relationship-result-{character-a}-{character-b}-{signal}`
- knowledge: `knowledge-result-{recipient}-{knowledge-item}` or `question-result-{question}`
- observations: `observation-result-{type}-{subject}-{predicate}`
Normalised names are lower-case, alphanumeric slug keys.
## Current Scene Selection
The current scene is the latest valid parsed scene at or below the run's completed-scene count. If no parsed scenes exist, the snapshot returns a waiting scene so the visual shell remains intact.
## Character Rules
The current scene maps parsed characters and point-of-view data. Explicit POV receives weight `100`. Mentioned-only characters are lower weight. No POV is invented if the parsed data does not provide one.
## Location Rules
The snapshot chooses one active location, preferring present, specific, high-confidence locations. If none exists, it returns a graceful "Location not yet identified" placeholder.
## Asset Rules
The snapshot returns up to five significant current-scene assets. Mentioned-only and clearly low-value/background assets are filtered.
## Relationship Rules
Relationships are limited to the current scene parsed relationships. They use deterministic IDs and confidence-derived weights. The renderer may suppress inline labels if there is no clean visual space.
## Knowledge Mapping
Questions raised and knowledge changes feed the Knowledge Threads panel. Parsed observations feed the Story Observations panel. Wording is concise and user-facing.
## Illustration Resolution
The snapshot assigns starter Illustration Library stable codes using simple non-manuscript heuristics and then reuses the existing Illustration Library resolver. It does not generate images.
## API Route
`GET /api/story-intelligence/runs/{runId}/visualisation-snapshot`
The endpoint is authenticated and returns only runs owned by the signed-in user in this phase.
## Development Route
- Simulation: `/Development/StoryIntelligenceExperience?mode=simulation`
- Real run: `/Development/StoryIntelligenceExperience?runId={runId}`
- Selector: `/Development/StoryIntelligenceExperience`
The selector is development/admin-only.
## Refresh Mechanism
Real mode uses restrained polling of the snapshot endpoint. It polls about every 7 seconds while visible, backs off while hidden, avoids overlapping requests, and stops once the run is terminal.
## Completion Behaviour
Completed and completed-with-warning runs show the final provisional snapshot and stop polling. Failed and cancelled runs show the available snapshot plus a clear status message.
## Performance Notes
The service parses scene result JSON once per snapshot request, limits central collections, does not load manuscript source text, and logs snapshot build duration.
## Tests
Added coverage for:
- simulation mode remaining available
- real snapshot contract metadata
- absence of raw manuscript text, raw AI responses, and prompts from the visualisation contract
Existing build and Story Intelligence/Illustration tests continue to run.
## Known Gaps
- Collaborator/project access beyond run ownership is not yet expanded for this development endpoint.
- Canonical uploaded entity images are not resolved ahead of Illustration Library images yet.
- The route is not linked from the production Story Intelligence progress workflow.
- Relationship/knowledge mapping uses current scene parsed data, not a durable replay history.
## Recommended Phase 21G Work
- Broaden access checks to full project collaborator semantics.
- Resolve canonical entity images before library fallbacks where canonical entity matches are available.
- Connect the real Story Intelligence workflow to this visualisation at the appropriate handoff point.
- Add a lightweight status-token endpoint if polling full snapshots becomes too heavy.

View File

@ -0,0 +1,92 @@
# Phase 21G - Live Story Intelligence Visualisation Engine
## Purpose
Phase 21G changes the Story Intelligence Experience from a periodically refreshed view into a live visualisation that reconciles incoming snapshots in the browser. It does not change the Story Intelligence pipeline, Illustration Library, prototype layout, prompts, data schema, or approval workflow.
## Animation Architecture
The browser now follows a small live rendering pipeline:
1. Snapshot loader polls the existing visualisation snapshot endpoint.
2. Snapshot comparator compares the current snapshot with the next snapshot.
3. Visual operation queue records the detected changes.
4. Renderer updates keyed DOM elements and SVG connections in place.
5. CSS transitions animate changed properties, inserted elements, and removed elements.
Polling still uses the Phase 21F endpoint and still ignores unchanged change tokens. When a new token arrives, the page preserves the previous snapshot, stores the next snapshot, derives visual operations, and then updates only the affected visual surface.
## Snapshot Reconciliation
The comparator detects operations including:
- `ProgressChanged`
- `StageChanged`
- `SceneChanged`
- `CharacterAdded`
- `CharacterRemoved`
- `CharacterImportanceChanged`
- `CharacterImageChanged`
- `LocationChanged`
- `AssetAdded`
- `AssetRemoved`
- `AssetImportanceChanged`
- `RelationshipAdded`
- `RelationshipRemoved`
- `RelationshipStrengthChanged`
- `KnowledgeThreadAdded`
- `KnowledgeThreadResolved`
- `ObservationAdded`
- `ObservationRemoved`
- `TimelineAdvanced`
- `DiscoveryAdded`
- `RunCompleted`
The current implementation compares the active scene represented by the latest scene in each snapshot, plus run status, terminal state, and progress metadata.
## DOM Reuse Strategy
Stable IDs are used as reconciliation keys for:
- Characters
- Assets
- Relationship insight cards
- Knowledge cards
- Observation cards
- Scene ribbon cards
- Timeline markers
- Latest discoveries
- Live activity items
- SVG connection groups
Matching nodes are retained and have their text, image, class, position, and path attributes updated. New nodes enter with `is-entering`; removed nodes receive `is-removing` and are deleted after their exit animation.
## Performance Notes
The renderer avoids emptying large panels on every snapshot. It reuses images, entity cards, list rows, scene cards, timeline markers, and SVG paths where stable IDs match. Connection drawing remains scheduled through `requestAnimationFrame`, and polling remains paused while the tab is hidden.
Progress, elapsed time, and the progress ring animate rather than jumping directly to the next value. Reduced-motion preferences are respected by shortening transition timing and disabling animated number interpolation.
## Development Diagnostics
The development Story Intelligence Experience page now includes a collapsible diagnostics panel showing:
- Current snapshot version
- Previous snapshot version
- Change token
- Animation queue length
- Last refresh time
- Snapshot fetch/build time from the browser perspective
- Operations detected
The page remains guarded by the existing Development controller environment check.
## Known Limitations
The operation model is currently client-side only and is derived from snapshot comparison. It does not yet receive event-level updates from the analysis worker. Relationship SVG paths are reused by connection ID, but their labels are still re-laid out on each draw to preserve collision avoidance.
The active-scene strategy follows the latest scene in the snapshot. If future phases expose a richer run cursor, the comparator should use that cursor instead.
## Future SignalR Migration Notes
Phase 21H can migrate snapshot delivery from polling to SignalR without changing the reconciliation model. SignalR messages should carry either a full next snapshot or a server-side change token that instructs the browser to fetch the snapshot endpoint. The existing comparator can remain the boundary between incoming data and visual animation.

View File

@ -0,0 +1,71 @@
# Phase 21H - Book-Level Story Intelligence Import Sessions
## Purpose
Phase 21H changes the Story Intelligence Experience from following one chapter run to following one book-level import session.
The implementation reuses the existing `StoryIntelligenceBookPipelines` table as the import-session abstraction. A session represents one project/book and owns the visualisation state across many chapter runs.
## Architecture
The browser now opens the visualisation with:
`/Development/StoryIntelligenceExperience?importSessionId={StoryIntelligenceBookPipelineID}`
The snapshot endpoint is:
`/api/story-intelligence/import-sessions/{importSessionId}/visualisation-snapshot`
The older run route remains available temporarily:
`/Development/StoryIntelligenceExperience?runId={runId}`
and:
`/api/story-intelligence/runs/{runId}/visualisation-snapshot`
This preserves compatibility while moving the development selector and live visualisation to sessions.
## Relationship To Chapter Runs
A session is backed by `StoryIntelligenceBookPipelines`.
Chapter analysis still creates normal `StoryIntelligenceRuns`. The session snapshot gathers all runs for the session book, orders them by chapter order, and builds one continuous visualisation. The browser does not need to know which chapter run is currently active.
Newly queued chapter runs update the book pipeline to `InProgress`, and historical runs are backfilled into session rows by the Phase 21H SQL migration.
## Snapshot Changes
The new session snapshot:
- loads the import session by `StoryIntelligenceBookPipelineID`
- loads all chapter runs for the session book
- parses scene results from every run
- skips malformed scene results without blocking later valid results
- selects the active run as the first non-terminal chapter run, falling back to the latest parsed run
- produces one scene list across the whole book
- keeps `RunId` only as a diagnostic/current-active-run field
## Progress Calculation
Progress is now based on the aggregate book view:
- scenes analysed: parsed scene results across all session runs
- scenes detected: sum of detected/persisted scene counts across all session runs
- chapters analysed: terminal chapter runs
- chapter total: known chapter runs in the session
- percentage: aggregate scenes analysed divided by aggregate scenes detected
Progress reaches 100% only when the import session is complete.
## Completion Rules
Polling stops only when the import session snapshot is terminal. A completed chapter run no longer stops the visualisation. The session is terminal when all known chapter runs are terminal and the book pipeline status is `Complete`.
## Migration Strategy
The migration backfills `StoryIntelligenceBookPipelines` for books that already have Story Intelligence runs. Existing chapter runs are not modified or orphaned. Current and future queue paths record the book pipeline as `InProgress` when a chapter run is queued.
## Future Production Integration
The current implementation is still development-facing. A production version should introduce explicit user-facing import-session screens, richer chapter totals from the actual book outline, and SignalR events keyed by import session rather than chapter run.