diff --git a/PlotLine/Controllers/DevelopmentController.cs b/PlotLine/Controllers/DevelopmentController.cs index ddbd40b..552e488 100644 --- a/PlotLine/Controllers/DevelopmentController.cs +++ b/PlotLine/Controllers/DevelopmentController.cs @@ -14,16 +14,27 @@ public sealed class DevelopmentController( IStoryIntelligenceVisualisationSnapshotService snapshots) : Controller { [HttpGet("StoryIntelligenceExperience")] - public async Task StoryIntelligenceExperience([FromQuery] string? mode, [FromQuery] int? runId) + public async Task StoryIntelligenceExperience([FromQuery] string? mode, [FromQuery] int? importSessionId, [FromQuery] int? runId) { if (!environment.IsDevelopment()) { return NotFound(); } - if (!string.Equals(mode, "simulation", StringComparison.OrdinalIgnoreCase) && runId.HasValue && currentUser.UserId is int userId) + if (!string.Equals(mode, "simulation", StringComparison.OrdinalIgnoreCase) && importSessionId.HasValue && currentUser.UserId is int userId) { - var realModel = await snapshots.BuildSnapshotAsync(runId.Value, userId); + var realModel = await snapshots.BuildImportSessionSnapshotAsync(importSessionId.Value, userId); + if (realModel is null) + { + return NotFound(); + } + + return View(realModel); + } + + if (!string.Equals(mode, "simulation", StringComparison.OrdinalIgnoreCase) && runId.HasValue && currentUser.UserId is int legacyUserId) + { + var realModel = await snapshots.BuildSnapshotAsync(runId.Value, legacyUserId); if (realModel is null) { return NotFound(); @@ -39,11 +50,11 @@ public sealed class DevelopmentController( model = new StoryIntelligenceExperiencePrototypeViewModel { Mode = "selector", - AvailableRuns = await snapshots.ListRunOptionsAsync(selectorUserId), + AvailableRuns = await snapshots.ListImportSessionOptionsAsync(selectorUserId), ProjectName = model.ProjectName, BookTitle = model.BookTitle, Scenes = model.Scenes, - SnapshotMessage = "Choose a persisted Story Intelligence run, or open simulation mode." + SnapshotMessage = "Choose a Story Intelligence import session, or open simulation mode." }; } diff --git a/PlotLine/Controllers/StoryIntelligenceVisualisationController.cs b/PlotLine/Controllers/StoryIntelligenceVisualisationController.cs index d860784..ff0287c 100644 --- a/PlotLine/Controllers/StoryIntelligenceVisualisationController.cs +++ b/PlotLine/Controllers/StoryIntelligenceVisualisationController.cs @@ -21,4 +21,16 @@ public sealed class StoryIntelligenceVisualisationController( var snapshot = await snapshots.BuildSnapshotAsync(runId, userId); return snapshot is null ? NotFound() : Ok(snapshot); } + + [HttpGet("import-sessions/{importSessionId:int}/visualisation-snapshot")] + public async Task 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); + } } diff --git a/PlotLine/Data/StoryIntelligencePipelineRepository.cs b/PlotLine/Data/StoryIntelligencePipelineRepository.cs index 9d14205..996b56e 100644 --- a/PlotLine/Data/StoryIntelligencePipelineRepository.cs +++ b/PlotLine/Data/StoryIntelligencePipelineRepository.cs @@ -6,6 +6,7 @@ namespace PlotLine.Data; public interface IStoryIntelligencePipelineRepository { + Task GetByIdForUserAsync(int importSessionId, int userId); Task GetByBookAsync(int bookId); Task GetByBookForUserAsync(int bookId, int userId); Task> ListForUserAsync(int userId); @@ -15,6 +16,15 @@ public interface IStoryIntelligencePipelineRepository public sealed class StoryIntelligencePipelineRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligencePipelineRepository { + public async Task GetByIdForUserAsync(int importSessionId, int userId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.StoryIntelligenceBookPipeline_GetByIDForUser", + new { StoryIntelligenceBookPipelineID = importSessionId, UserID = userId }, + commandType: CommandType.StoredProcedure); + } + public async Task GetByBookAsync(int bookId) { using var connection = connectionFactory.CreateConnection(); diff --git a/PlotLine/Data/StoryIntelligenceResultRepository.cs b/PlotLine/Data/StoryIntelligenceResultRepository.cs index c8c1673..d85670b 100644 --- a/PlotLine/Data/StoryIntelligenceResultRepository.cs +++ b/PlotLine/Data/StoryIntelligenceResultRepository.cs @@ -30,6 +30,7 @@ public interface IStoryIntelligenceResultRepository Task SaveChapterResultAsync(int runId, StoryIntelligenceChapterResultSaveRequest chapter); Task SaveSceneResultAsync(int runId, int? chapterResultId, StoryIntelligenceSceneResultSaveRequest scene); Task> ListRunsAsync(); + Task> ListRunsByBookForUserAsync(int bookId, int userId); Task GetRunAsync(int runId); Task GetChapterResultAsync(int runId); Task> ListSceneResultsAsync(int runId); @@ -350,6 +351,16 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn return rows.ToList(); } + public async Task> ListRunsByBookForUserAsync(int bookId, int userId) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + "dbo.StoryIntelligenceRun_ListByBookForUser", + new { BookID = bookId, UserID = userId }, + commandType: CommandType.StoredProcedure); + return rows.ToList(); + } + public async Task GetRunAsync(int runId) { using var connection = connectionFactory.CreateConnection(); diff --git a/PlotLine/Services/StoryIntelligenceExistingChapterQueueService.cs b/PlotLine/Services/StoryIntelligenceExistingChapterQueueService.cs index 157ab26..6c7e416 100644 --- a/PlotLine/Services/StoryIntelligenceExistingChapterQueueService.cs +++ b/PlotLine/Services/StoryIntelligenceExistingChapterQueueService.cs @@ -13,6 +13,7 @@ public sealed class StoryIntelligenceExistingChapterQueueService( IStoryIntelligenceSourceRepository sources, IStoryIntelligenceResultRepository runs, IStoryIntelligenceClient client, + IStoryIntelligencePipelineStateService pipelines, ILogger 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."; @@ -77,6 +78,7 @@ public sealed class StoryIntelligenceExistingChapterQueueService( clientStatus.SceneIntelligenceModel), PromptVersionsSummary = "Chapter=Chapter-Structure-Prompt-V2; Scene=Scene-Prompt-V2" }); + await pipelines.RecordChapterAnalysisQueuedAsync(selected.ProjectID, selected.BookID, runId); logger.LogInformation( "Queued Story Intelligence run {StoryIntelligenceRunID} from existing chapter {ChapterID}. ProjectID={ProjectID} BookID={BookID} SourceWordCount={SourceWordCount} SourceCharacterCount={SourceCharacterCount}", diff --git a/PlotLine/Services/StoryIntelligenceFullChapterTestService.cs b/PlotLine/Services/StoryIntelligenceFullChapterTestService.cs index 2040745..22682d4 100644 --- a/PlotLine/Services/StoryIntelligenceFullChapterTestService.cs +++ b/PlotLine/Services/StoryIntelligenceFullChapterTestService.cs @@ -16,6 +16,7 @@ public interface IStoryIntelligenceFullChapterTestService public sealed class StoryIntelligenceFullChapterTestService( IStoryIntelligenceResultRepository runs, + IStoryIntelligencePipelineStateService pipelines, IOptions options, IOptions pricingOptions, ILogger logger) : IStoryIntelligenceFullChapterTestService @@ -104,6 +105,10 @@ public sealed class StoryIntelligenceFullChapterTestService( Model = StoryIntelligenceOptions.BuildModelSummary(modelSelection.ChapterStructureModel, modelSelection.SceneIntelligenceModel), PromptVersionsSummary = PromptVersionsSummary }); + if (form.ProjectID is int projectId && form.BookID is int bookId) + { + await pipelines.RecordChapterAnalysisQueuedAsync(projectId, bookId, runId); + } 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}", diff --git a/PlotLine/Services/StoryIntelligencePipelineStateService.cs b/PlotLine/Services/StoryIntelligencePipelineStateService.cs index 63156c0..474f816 100644 --- a/PlotLine/Services/StoryIntelligencePipelineStateService.cs +++ b/PlotLine/Services/StoryIntelligencePipelineStateService.cs @@ -8,6 +8,7 @@ public interface IStoryIntelligencePipelineStateService Task GetForBookAsync(int bookId, int userId); Task> ListForUserAsync(int userId); Task EnsureForBookAsync(int bookId, int userId); + Task RecordChapterAnalysisQueuedAsync(int projectId, int bookId, int runId); Task RecordSceneImportAsync(int projectId, int bookId, int runId); Task RecordCharacterImportAsync(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) { await pipelines.UpsertAsync(new StoryIntelligenceBookPipelineSaveRequest diff --git a/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs b/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs index 5c026a8..0175c6a 100644 --- a/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs +++ b/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs @@ -11,11 +11,14 @@ namespace PlotLine.Services; public interface IStoryIntelligenceVisualisationSnapshotService { Task BuildSnapshotAsync(int runId, int userId); + Task BuildImportSessionSnapshotAsync(int importSessionId, int userId); Task> ListRunOptionsAsync(int userId); + Task> ListImportSessionOptionsAsync(int userId); } public sealed class StoryIntelligenceVisualisationSnapshotService( IStoryIntelligenceResultRepository repository, + IStoryIntelligencePipelineRepository pipelines, IIllustrationLibraryService illustrationLibrary, ILogger logger) : IStoryIntelligenceVisualisationSnapshotService { @@ -43,6 +46,23 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( .ToList(); } + public async Task> ListImportSessionOptionsAsync(int userId) + { + var sessions = await pipelines.ListForUserAsync(userId); + return sessions + .OrderByDescending(session => session.UpdatedUtc) + .Take(12) + .Select(session => new StoryIntelligenceExperienceRunOption + { + ImportSessionId = session.StoryIntelligenceBookPipelineID, + RunId = session.LastRunID ?? 0, + Status = session.Status, + CreatedUtc = session.CreatedUtc, + Label = $"{DisplayTitle(session.ProjectName, "Project")} / {DisplayTitle(session.BookDisplayTitle, "Book")} - {session.Status} - started {session.CreatedUtc:u}" + }) + .ToList(); + } + public async Task BuildSnapshotAsync(int runId, int userId) { var stopwatch = Stopwatch.StartNew(); @@ -90,18 +110,121 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( return model; } + public async Task BuildImportSessionSnapshotAsync(int importSessionId, int userId) + { + var stopwatch = Stopwatch.StartNew(); + var session = await pipelines.GetByIdForUserAsync(importSessionId, userId); + if (session is null) + { + return null; + } + + var runs = (await repository.ListRunsByBookForUserAsync(session.BookID, userId)) + .OrderBy(run => run.ChapterNumber ?? decimal.MaxValue) + .ThenBy(run => run.ChapterID ?? int.MaxValue) + .ThenBy(run => run.StoryIntelligenceRunID) + .ToList(); + if (runs.Count == 0) + { + return null; + } + + var runSnapshots = new List(); + foreach (var run in runs) + { + var results = (await repository.ListSceneResultsAsync(run.StoryIntelligenceRunID)).ToList(); + var parsed = results + .Select(result => ReadSceneResult(run.StoryIntelligenceRunID, result)) + .OrderBy(item => SceneSortChapter(item)) + .ThenBy(item => SceneSortNumber(item)) + .ThenBy(item => item.Result.SceneResultID) + .ToList(); + var completed = parsed.Where(item => item.Scene is not null).ToList(); + runSnapshots.Add(new(run, results, parsed, completed, PickCurrentScene(run, completed))); + } + + var activeSnapshot = PickActiveRunSnapshot(runSnapshots) ?? runSnapshots.Last(); + var current = activeSnapshot.Current ?? runSnapshots.LastOrDefault(snapshot => snapshot.Completed.Count > 0)?.Current; + var totalPersistedResults = runSnapshots.Sum(snapshot => snapshot.Results.Count); + var parsedScenes = runSnapshots.Sum(snapshot => snapshot.Completed.Count); + var totalScenes = Math.Max( + runSnapshots.Sum(snapshot => Math.Max(snapshot.Run.TotalDetectedScenes ?? 0, snapshot.Results.Count)), + parsedScenes); + var completedChapterRuns = runSnapshots.Count(snapshot => IsTerminal(snapshot.Run.Status)); + var isTerminal = runSnapshots.All(snapshot => IsTerminal(snapshot.Run.Status)) + && string.Equals(session.Status, StoryIntelligencePipelineStatuses.Complete, StringComparison.OrdinalIgnoreCase); + var scenes = new List(); + var nextSceneNumber = 1; + foreach (var snapshot in runSnapshots) + { + var runScenes = BuildSceneStates( + snapshot.Run, + snapshot.Parsed, + snapshot.Completed, + current, + continuousSceneNumberStart: nextSceneNumber, + overallCompletedScenes: parsedScenes, + overallTotalScenes: totalScenes, + overallChaptersAnalysed: completedChapterRuns, + overallChapterTotal: runs.Count, + overallStage: OverallStage(session, activeSnapshot.Run), + overallRemaining: EstimateSessionRemaining(runSnapshots, parsedScenes, totalScenes), + continuousSceneNumbering: true, + overallTerminal: isTerminal) + .ToList(); + scenes.AddRange(runScenes); + nextSceneNumber += snapshot.Completed.Count; + } + + if (scenes.Count == 0) + { + scenes.Add(BuildEmptyScene(activeSnapshot.Run)); + } + + var model = new StoryIntelligenceExperiencePrototypeViewModel + { + Mode = "real", + ImportSessionId = session.StoryIntelligenceBookPipelineID, + RunId = activeSnapshot.Run.StoryIntelligenceRunID, + RunStatus = SessionStatus(session, runSnapshots), + GeneratedUtc = DateTime.UtcNow, + ChangeToken = SessionChangeToken(session, runSnapshots), + IsTerminal = isTerminal, + SnapshotMessage = SessionSnapshotMessage(session, runSnapshots), + Diagnostics = BuildSessionDiagnostics(session, runSnapshots, activeSnapshot, current, parsedScenes, totalPersistedResults), + ProjectName = DisplayTitle(session.ProjectName, "Untitled project"), + BookTitle = DisplayTitle(session.BookDisplayTitle, "Untitled book"), + Scenes = scenes + }; + + await illustrationLibrary.ApplyApprovedIllustrationsAsync(model); + logger.LogInformation("Built Story Intelligence import session visualisation snapshot for session {ImportSessionId} in {ElapsedMs}ms.", importSessionId, stopwatch.ElapsedMilliseconds); + return model; + } + private static IEnumerable BuildSceneStates( StoryIntelligenceSavedRun run, IReadOnlyList allResults, IReadOnlyList completed, - ParsedSceneResult? current) + ParsedSceneResult? current, + int continuousSceneNumberStart = 1, + int? overallCompletedScenes = null, + int? overallTotalScenes = null, + int? overallChaptersAnalysed = null, + int? overallChapterTotal = null, + string? overallStage = null, + string? overallRemaining = null, + bool continuousSceneNumbering = false, + bool overallTerminal = false) { var totalScenes = Math.Max(run.TotalDetectedScenes ?? 0, allResults.Count); var completedScenes = run.CompletedScenes ?? completed.Count; + var displayIndex = 0; foreach (var item in completed) { var scene = item.Scene!; - var sceneNumber = SceneNumber(item); + var sourceSceneNumber = SceneNumber(item); + var sceneNumber = continuousSceneNumbering ? continuousSceneNumberStart + displayIndex++ : sourceSceneNumber; var isCurrent = current?.Result.SceneResultID == item.Result.SceneResultID; yield return new StoryIntelligenceExperienceSceneState { @@ -109,15 +232,17 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( SceneNumber = sceneNumber, Title = SceneTitle(scene, item.Result, sceneNumber), Summary = Trim(FirstConfigured(scene.Summary?.Short, scene.Summary?.Detailed, run.CurrentMessage, "Scene analysed."), 180), - Stage = StageDisplay(run), + Stage = overallStage ?? StageDisplay(run), TimelineLabel = TimelineLabel(scene, item.Result), TimelineEvent = Trim(FirstConfigured(scene.Summary?.Short, scene.ScenePurpose?.ObservedFunction, "Scene analysed"), 42), - ProgressPercent = Progress(run, completedScenes, totalScenes), - ChaptersAnalysed = ChapterCount(completed), - ChapterTotal = Math.Max(1, run.SourceChapterCount ?? ChapterCount(completed)), - ScenesAnalysed = completedScenes, - SceneTotal = Math.Max(1, totalScenes), - EstimatedRemaining = EstimateRemaining(run, completedScenes, totalScenes), + ProgressPercent = overallTotalScenes.HasValue + ? OverallProgress(overallCompletedScenes ?? completedScenes, overallTotalScenes.Value, overallTerminal) + : Progress(run, completedScenes, totalScenes), + ChaptersAnalysed = overallChaptersAnalysed ?? ChapterCount(completed), + ChapterTotal = Math.Max(1, overallChapterTotal ?? run.SourceChapterCount ?? ChapterCount(completed)), + ScenesAnalysed = overallCompletedScenes ?? completedScenes, + SceneTotal = Math.Max(1, overallTotalScenes ?? totalScenes), + EstimatedRemaining = overallRemaining ?? EstimateRemaining(run, completedScenes, totalScenes), PovCharacterId = CharacterId(scene.PointOfView?.CharacterName), Location = BuildLocation(scene), Characters = BuildCharacters(scene), @@ -417,6 +542,108 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)))[..16].ToLowerInvariant(); } + private static string SessionChangeToken(StoryIntelligenceBookPipelineState session, IReadOnlyList snapshots) + { + var raw = string.Join('|', + session.StoryIntelligenceBookPipelineID, + session.Status, + session.CurrentStage, + session.LastCompletedStage, + session.CurrentReviewStage, + session.UpdatedUtc.ToUniversalTime().Ticks, + string.Join("~", snapshots.Select(snapshot => ChangeToken(snapshot.Run, snapshot.Results)))); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)))[..16].ToLowerInvariant(); + } + + private static RunSnapshot? PickActiveRunSnapshot(IReadOnlyList snapshots) + => snapshots.FirstOrDefault(snapshot => !IsTerminal(snapshot.Run.Status)) + ?? snapshots.LastOrDefault(snapshot => snapshot.Completed.Count > 0) + ?? snapshots.LastOrDefault(); + + private static string OverallStage(StoryIntelligenceBookPipelineState session, StoryIntelligenceSavedRun activeRun) + { + if (!IsTerminal(activeRun.Status)) + { + return FirstConfigured(activeRun.CurrentStage, activeRun.Status); + } + + return session.CurrentStage switch + { + StoryIntelligencePipelineStages.Complete => "Ready for Review", + StoryIntelligencePipelineStages.Chapters => "Reading Chapters", + _ => session.CurrentStage + }; + } + + private static string EstimateSessionRemaining(IReadOnlyList snapshots, int completedScenes, int totalScenes) + { + if (totalScenes <= 0 || completedScenes <= 0) + { + return snapshots.All(snapshot => IsTerminal(snapshot.Run.Status)) ? "Ready to review" : "Calculating"; + } + + if (completedScenes >= totalScenes && snapshots.All(snapshot => IsTerminal(snapshot.Run.Status))) + { + return "Ready to review"; + } + + var elapsedMs = snapshots.Sum(snapshot => snapshot.Run.TotalDurationMs ?? Math.Max(0, Convert.ToInt64((DateTime.UtcNow - snapshot.Run.StartedUtc).TotalMilliseconds))); + var averageMs = elapsedMs / Math.Max(1, completedScenes); + return FormatDuration(averageMs * Math.Max(0, totalScenes - completedScenes)); + } + + private static string SessionStatus(StoryIntelligenceBookPipelineState session, IReadOnlyList snapshots) + { + if (snapshots.Any(snapshot => !IsTerminal(snapshot.Run.Status))) + { + return StoryIntelligenceRunStatuses.Running; + } + + return string.Equals(session.Status, StoryIntelligencePipelineStatuses.Complete, StringComparison.OrdinalIgnoreCase) + ? StoryIntelligenceRunStatuses.Completed + : StoryIntelligenceRunStatuses.CompletedWithWarnings; + } + + private static string SessionSnapshotMessage(StoryIntelligenceBookPipelineState session, IReadOnlyList snapshots) + { + var active = snapshots.FirstOrDefault(snapshot => !IsTerminal(snapshot.Run.Status)); + if (active is not null) + { + return FirstConfigured(active.Run.CurrentMessage, $"Analysing chapter {active.Run.ChapterNumber:0.##}."); + } + + return string.Equals(session.Status, StoryIntelligencePipelineStatuses.Complete, StringComparison.OrdinalIgnoreCase) + ? "Book analysis complete and ready for review." + : "All known chapter runs are complete. Awaiting the next book import stage."; + } + + private static StoryIntelligenceExperienceSnapshotDiagnostics BuildSessionDiagnostics( + StoryIntelligenceBookPipelineState session, + IReadOnlyList snapshots, + RunSnapshot activeSnapshot, + ParsedSceneResult? current, + int parsedScenes, + int totalPersistedResults) + { + var failed = snapshots.Sum(snapshot => snapshot.Parsed.Count(item => item.Scene is null)); + var warning = failed == 0 ? null : $"{failed:N0} scene result(s) skipped because they could not be parsed."; + return new() + { + RunStatus = SessionStatus(session, snapshots), + PersistedCompletedSceneCount = snapshots.Sum(snapshot => snapshot.Run.CompletedScenes ?? snapshot.Completed.Count), + TotalPersistedSceneResultCount = totalPersistedResults, + SuccessfullyParsedSceneResultCount = parsedScenes, + FailedSceneResultCount = failed, + HighestPersistedSceneResultId = snapshots.SelectMany(snapshot => snapshot.Results).Select(result => (int?)result.SceneResultID).Max(), + HighestParsedSceneResultId = snapshots.SelectMany(snapshot => snapshot.Completed).Select(item => (int?)item.Result.SceneResultID).Max(), + SelectedCurrentSceneResultId = current?.Result.SceneResultID, + SelectedChapterAndSceneOrder = current is null ? "none" : $"Chapter {activeSnapshot.Run.ChapterNumber:0.##}, Scene {SceneSortNumber(current):0.##}", + CurrentChangeToken = SessionChangeToken(session, snapshots), + AnalysisCursor = FirstConfigured(activeSnapshot.Run.CurrentMessage, activeSnapshot.Run.CurrentStage, session.CurrentStage), + Warning = warning + }; + } + private static StoryIntelligenceExperienceSnapshotDiagnostics BuildDiagnostics( StoryIntelligenceSavedRun run, IReadOnlyList results, @@ -490,6 +717,21 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( return run.Status == StoryIntelligenceRunStatuses.Running ? 8 : 0; } + private static int OverallProgress(int completedScenes, int totalScenes, bool isTerminal) + { + if (isTerminal) + { + return 100; + } + + if (totalScenes > 0) + { + return Math.Clamp((int)Math.Round(completedScenes * 100m / totalScenes), 0, 99); + } + + return 0; + } + private static string EstimateRemaining(StoryIntelligenceSavedRun run, int completedScenes, int totalScenes) { if (IsTerminal(run.Status)) @@ -678,6 +920,13 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( return clean.Length <= max ? clean : clean[..Math.Max(0, max - 3)].TrimEnd() + "..."; } + private sealed record RunSnapshot( + StoryIntelligenceSavedRun Run, + IReadOnlyList Results, + IReadOnlyList Parsed, + IReadOnlyList Completed, + ParsedSceneResult? Current); + private sealed record ParsedSceneResult(StoryIntelligenceSavedSceneResult Result, SceneIntelligenceScene? Scene, string? ParseError); private sealed record CharacterCodeProfile( diff --git a/PlotLine/Sql/137_Phase21H_BookLevelStoryIntelligenceImportSessions.sql b/PlotLine/Sql/137_Phase21H_BookLevelStoryIntelligenceImportSessions.sql new file mode 100644 index 0000000..9432565 --- /dev/null +++ b/PlotLine/Sql/137_Phase21H_BookLevelStoryIntelligenceImportSessions.sql @@ -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 diff --git a/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs b/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs index 5da5fd2..005989a 100644 --- a/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs +++ b/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs @@ -4,6 +4,7 @@ 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; @@ -35,6 +36,7 @@ public sealed class StoryIntelligenceExperienceSnapshotDiagnostics 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; } diff --git a/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml b/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml index c0da603..34380c4 100644 --- a/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml +++ b/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml @@ -20,7 +20,8 @@ data-image-diagnostics="true" data-mode="@Model.Mode" data-run-id="@Model.RunId" - data-snapshot-url="@(Model.RunId.HasValue ? $"/api/story-intelligence/runs/{Model.RunId.Value}/visualisation-snapshot" : string.Empty)" + 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()">
@@ -40,11 +41,11 @@ @if (Model.AvailableRuns.Count > 0) { -