Phase 21H - Book-Level Story Intelligence Import Sessions
This commit is contained in:
parent
34fdfb77fe
commit
8985efafbc
@ -14,16 +14,27 @@ public sealed class DevelopmentController(
|
||||
IStoryIntelligenceVisualisationSnapshotService snapshots) : Controller
|
||||
{
|
||||
[HttpGet("StoryIntelligenceExperience")]
|
||||
public async Task<IActionResult> StoryIntelligenceExperience([FromQuery] string? mode, [FromQuery] int? runId)
|
||||
public async Task<IActionResult> 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."
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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<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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ namespace PlotLine.Data;
|
||||
|
||||
public interface IStoryIntelligencePipelineRepository
|
||||
{
|
||||
Task<StoryIntelligenceBookPipelineState?> GetByIdForUserAsync(int importSessionId, int userId);
|
||||
Task<StoryIntelligenceBookPipelineState?> GetByBookAsync(int bookId);
|
||||
Task<StoryIntelligenceBookPipelineState?> GetByBookForUserAsync(int bookId, int userId);
|
||||
Task<IReadOnlyList<StoryIntelligenceBookPipelineState>> ListForUserAsync(int userId);
|
||||
@ -15,6 +16,15 @@ public interface 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)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
|
||||
@ -30,6 +30,7 @@ public interface IStoryIntelligenceResultRepository
|
||||
Task<int> SaveChapterResultAsync(int runId, StoryIntelligenceChapterResultSaveRequest chapter);
|
||||
Task<int> SaveSceneResultAsync(int runId, int? chapterResultId, StoryIntelligenceSceneResultSaveRequest scene);
|
||||
Task<IReadOnlyList<StoryIntelligenceSavedRunListItem>> ListRunsAsync();
|
||||
Task<IReadOnlyList<StoryIntelligenceSavedRun>> ListRunsByBookForUserAsync(int bookId, int userId);
|
||||
Task<StoryIntelligenceSavedRun?> GetRunAsync(int runId);
|
||||
Task<StoryIntelligenceSavedChapterResult?> GetChapterResultAsync(int runId);
|
||||
Task<IReadOnlyList<StoryIntelligenceSavedSceneResult>> ListSceneResultsAsync(int runId);
|
||||
@ -350,6 +351,16 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
|
||||
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)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
|
||||
@ -13,6 +13,7 @@ public sealed class StoryIntelligenceExistingChapterQueueService(
|
||||
IStoryIntelligenceSourceRepository sources,
|
||||
IStoryIntelligenceResultRepository runs,
|
||||
IStoryIntelligenceClient client,
|
||||
IStoryIntelligencePipelineStateService pipelines,
|
||||
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.";
|
||||
@ -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}",
|
||||
|
||||
@ -16,6 +16,7 @@ public interface IStoryIntelligenceFullChapterTestService
|
||||
|
||||
public sealed class StoryIntelligenceFullChapterTestService(
|
||||
IStoryIntelligenceResultRepository runs,
|
||||
IStoryIntelligencePipelineStateService pipelines,
|
||||
IOptions<StoryIntelligenceOptions> options,
|
||||
IOptions<StoryIntelligencePricingOptions> pricingOptions,
|
||||
ILogger<StoryIntelligenceFullChapterTestService> 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}",
|
||||
|
||||
@ -8,6 +8,7 @@ public interface IStoryIntelligencePipelineStateService
|
||||
Task<StoryIntelligenceBookPipelineState?> GetForBookAsync(int bookId, int userId);
|
||||
Task<IReadOnlyList<StoryIntelligenceBookPipelineState>> ListForUserAsync(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 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
|
||||
|
||||
@ -11,11 +11,14 @@ namespace PlotLine.Services;
|
||||
public interface IStoryIntelligenceVisualisationSnapshotService
|
||||
{
|
||||
Task<StoryIntelligenceExperiencePrototypeViewModel?> BuildSnapshotAsync(int runId, int userId);
|
||||
Task<StoryIntelligenceExperiencePrototypeViewModel?> BuildImportSessionSnapshotAsync(int importSessionId, int userId);
|
||||
Task<IReadOnlyList<StoryIntelligenceExperienceRunOption>> ListRunOptionsAsync(int userId);
|
||||
Task<IReadOnlyList<StoryIntelligenceExperienceRunOption>> ListImportSessionOptionsAsync(int userId);
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
IStoryIntelligenceResultRepository repository,
|
||||
IStoryIntelligencePipelineRepository pipelines,
|
||||
IIllustrationLibraryService illustrationLibrary,
|
||||
ILogger<StoryIntelligenceVisualisationSnapshotService> logger) : IStoryIntelligenceVisualisationSnapshotService
|
||||
{
|
||||
@ -43,6 +46,23 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<StoryIntelligenceExperienceRunOption>> 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<StoryIntelligenceExperiencePrototypeViewModel?> BuildSnapshotAsync(int runId, int userId)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
@ -90,18 +110,121 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
return model;
|
||||
}
|
||||
|
||||
public async Task<StoryIntelligenceExperiencePrototypeViewModel?> 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<RunSnapshot>();
|
||||
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<StoryIntelligenceExperienceSceneState>();
|
||||
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<StoryIntelligenceExperienceSceneState> BuildSceneStates(
|
||||
StoryIntelligenceSavedRun run,
|
||||
IReadOnlyList<ParsedSceneResult> allResults,
|
||||
IReadOnlyList<ParsedSceneResult> 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<RunSnapshot> 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<RunSnapshot> 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<RunSnapshot> 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<RunSnapshot> 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<RunSnapshot> 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<RunSnapshot> 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<StoryIntelligenceSavedSceneResult> 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<StoryIntelligenceSavedSceneResult> Results,
|
||||
IReadOnlyList<ParsedSceneResult> Parsed,
|
||||
IReadOnlyList<ParsedSceneResult> Completed,
|
||||
ParsedSceneResult? Current);
|
||||
|
||||
private sealed record ParsedSceneResult(StoryIntelligenceSavedSceneResult Result, SceneIntelligenceScene? Scene, string? ParseError);
|
||||
|
||||
private sealed record CharacterCodeProfile(
|
||||
|
||||
@ -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
|
||||
@ -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; }
|
||||
|
||||
@ -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()">
|
||||
<header class="story-exp-header" aria-label="Story Intelligence status">
|
||||
@ -40,11 +41,11 @@
|
||||
|
||||
@if (Model.AvailableRuns.Count > 0)
|
||||
{
|
||||
<nav class="story-exp-run-selector" aria-label="Development Story Intelligence runs">
|
||||
<strong>Real Story Intelligence runs</strong>
|
||||
<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?runId=@run.RunId">@run.Label</a>
|
||||
<a href="/Development/StoryIntelligenceExperience?importSessionId=@run.ImportSessionId">@run.Label</a>
|
||||
}
|
||||
</nav>
|
||||
}
|
||||
|
||||
@ -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.
|
||||
Loading…
x
Reference in New Issue
Block a user