391 lines
17 KiB
C#
391 lines
17 KiB
C#
using Microsoft.AspNetCore.SignalR;
|
|
using PlotLine.Data;
|
|
using PlotLine.Hubs;
|
|
using PlotLine.Models;
|
|
using PlotLine.ViewModels;
|
|
|
|
namespace PlotLine.Services;
|
|
|
|
public interface IStoryIntelligenceService
|
|
{
|
|
Task<StoryIntelligenceOverviewViewModel> GetOverviewAsync();
|
|
Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid jobId);
|
|
Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid jobId);
|
|
Task<StoryIntelligenceJob?> StartAsync();
|
|
Task<StoryIntelligenceJob?> CancelAsync(Guid jobId);
|
|
Task<StoryIntelligenceDashboardViewModel> GetDashboardAsync();
|
|
Task<StoryIntelligenceJobProgress?> GetProgressForUserAsync(int userId, Guid jobId);
|
|
}
|
|
|
|
public interface IStoryIntelligenceProvider
|
|
{
|
|
Task ProcessAsync(StoryIntelligenceJob job, Func<StoryIntelligenceProviderProgress, Task> progress, CancellationToken cancellationToken);
|
|
}
|
|
|
|
public sealed class StoryIntelligenceProviderProgress
|
|
{
|
|
public string Status { get; init; } = StoryIntelligenceJobStatuses.Preparing;
|
|
public string Stage { get; init; } = StoryIntelligenceJobStatuses.Preparing;
|
|
public string Message { get; init; } = "Preparing manuscript...";
|
|
public int Percent { get; init; }
|
|
public string? EstimatedRemaining { get; init; }
|
|
}
|
|
|
|
public sealed class StubStoryIntelligenceProvider : IStoryIntelligenceProvider
|
|
{
|
|
public async Task ProcessAsync(StoryIntelligenceJob job, Func<StoryIntelligenceProviderProgress, Task> progress, CancellationToken cancellationToken)
|
|
{
|
|
var stages = new[]
|
|
{
|
|
new StoryIntelligenceProviderProgress
|
|
{
|
|
Percent = 20,
|
|
Message = "Building analysis pipeline...",
|
|
EstimatedRemaining = "Calculating"
|
|
},
|
|
new StoryIntelligenceProviderProgress
|
|
{
|
|
Percent = 55,
|
|
Message = "Preparing scene batches...",
|
|
EstimatedRemaining = "Calculating"
|
|
},
|
|
new StoryIntelligenceProviderProgress
|
|
{
|
|
Percent = 85,
|
|
Message = "Initialising Story Intelligence...",
|
|
EstimatedRemaining = "Calculating"
|
|
}
|
|
};
|
|
|
|
foreach (var stage in stages)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
await progress(stage);
|
|
await Task.Yield();
|
|
}
|
|
}
|
|
}
|
|
|
|
public sealed class StoryIntelligenceService(
|
|
IStoryIntelligenceRepository repository,
|
|
IStoryIntelligenceResultRepository persistedRuns,
|
|
IStoryIntelligencePipelineStateService pipelineState,
|
|
IOnboardingStoryIntelligenceBatchStore onboardingBatches,
|
|
IOnboardingRepository onboarding,
|
|
IProjectRepository projects,
|
|
IBookRepository books,
|
|
ICurrentUserService currentUser) : IStoryIntelligenceService
|
|
{
|
|
private const string ConsentText = "I consent to PlotDirector preparing the Story Intelligence analysis framework for this manuscript. I understand future AI analysis is optional and will use the OpenAI API.";
|
|
|
|
public async Task<StoryIntelligenceOverviewViewModel> GetOverviewAsync()
|
|
{
|
|
var state = await onboarding.GetOrCreateAsync(RequireUserId());
|
|
var latest = await repository.GetLatestForUserAsync(state.UserID);
|
|
return new StoryIntelligenceOverviewViewModel
|
|
{
|
|
ProjectID = state.ProjectID,
|
|
BookID = state.BookID,
|
|
SelectedProjectName = state.ProjectID.HasValue ? (await projects.GetForUserAsync(state.ProjectID.Value, state.UserID))?.ProjectName : null,
|
|
SelectedBookTitle = state.BookID.HasValue ? BuildBookTitle(await books.GetAsync(state.BookID.Value)) : null,
|
|
ExistingJob = latest is null ? null : ToProgress(latest)
|
|
};
|
|
}
|
|
|
|
public async Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid jobId)
|
|
{
|
|
var job = await repository.GetAsync(RequireUserId(), jobId);
|
|
return job is null
|
|
? null
|
|
: new StoryIntelligenceProgressViewModel
|
|
{
|
|
Job = ToProgress(job),
|
|
ProjectName = (await projects.GetForUserAsync(job.ProjectID, job.UserID))?.ProjectName,
|
|
BookTitle = BuildBookTitle(await books.GetAsync(job.BookID))
|
|
};
|
|
}
|
|
|
|
public async Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid jobId)
|
|
{
|
|
var job = await repository.GetAsync(RequireUserId(), jobId);
|
|
if (job is null || !job.IsCompleted)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return new StoryIntelligenceCompletionViewModel
|
|
{
|
|
Job = ToProgress(job),
|
|
ProjectID = job.ProjectID,
|
|
BookID = job.BookID,
|
|
ProjectName = (await projects.GetForUserAsync(job.ProjectID, job.UserID))?.ProjectName,
|
|
BookTitle = BuildBookTitle(await books.GetAsync(job.BookID))
|
|
};
|
|
}
|
|
|
|
public async Task<StoryIntelligenceJob?> StartAsync()
|
|
{
|
|
var state = await onboarding.GetOrCreateAsync(RequireUserId());
|
|
if (!state.ProjectID.HasValue || !state.BookID.HasValue)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return await repository.CreateWithConsentAsync(
|
|
state.UserID,
|
|
state.UserOnboardingStateID,
|
|
state.ProjectID.Value,
|
|
state.BookID.Value,
|
|
ConsentText);
|
|
}
|
|
|
|
public async Task<StoryIntelligenceJob?> CancelAsync(Guid jobId)
|
|
=> await repository.CancelAsync(RequireUserId(), jobId);
|
|
|
|
public async Task<StoryIntelligenceDashboardViewModel> GetDashboardAsync()
|
|
{
|
|
if (!currentUser.UserId.HasValue)
|
|
{
|
|
return new StoryIntelligenceDashboardViewModel();
|
|
}
|
|
|
|
var pipelineBooks = (await pipelineState.ListForUserAsync(currentUser.UserId.Value))
|
|
.Select(ToPipelineBookStatus)
|
|
.ToList();
|
|
var activeBatch = await onboardingBatches.GetLatestActiveForUserAsync(currentUser.UserId.Value);
|
|
if (activeBatch is not null)
|
|
{
|
|
var runs = new List<StoryIntelligenceSavedRun>();
|
|
foreach (var item in activeBatch.Items)
|
|
{
|
|
var run = await persistedRuns.GetRunAsync(item.RunID);
|
|
if (run is not null)
|
|
{
|
|
runs.Add(run);
|
|
}
|
|
}
|
|
|
|
var activeRun = runs
|
|
.Where(run => run.Status is StoryIntelligenceRunStatuses.Pending or StoryIntelligenceRunStatuses.Running)
|
|
.OrderBy(run => run.ChapterNumber ?? decimal.MaxValue)
|
|
.ThenBy(run => run.StoryIntelligenceRunID)
|
|
.FirstOrDefault();
|
|
if (activeRun is not null)
|
|
{
|
|
var completedChapters = runs.Count(run => run.Status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings);
|
|
var currentChapter = Math.Clamp(completedChapters + 1, 1, Math.Max(1, activeBatch.Items.Count));
|
|
return new StoryIntelligenceDashboardViewModel
|
|
{
|
|
ShouldShow = true,
|
|
ActiveBatchID = activeBatch.BatchID,
|
|
ActiveImportBookTitle = activeBatch.BookTitle,
|
|
ActiveImportProgressLabel = $"Chapter {currentChapter:N0} of {activeBatch.Items.Count:N0}",
|
|
Title = "Story Intelligence is reading",
|
|
Description = "PlotDirector is analysing your manuscript in the background.",
|
|
ButtonText = "View progress",
|
|
PipelineBooks = pipelineBooks
|
|
};
|
|
}
|
|
}
|
|
|
|
var nextPipeline = pipelineBooks.FirstOrDefault(item => !item.IsComplete) ?? pipelineBooks.FirstOrDefault();
|
|
if (nextPipeline is not null)
|
|
{
|
|
return new StoryIntelligenceDashboardViewModel
|
|
{
|
|
ShouldShow = true,
|
|
Title = nextPipeline.IsComplete ? "Story Intelligence Complete" : "Continue Story Intelligence",
|
|
Description = nextPipeline.IsComplete
|
|
? $"{nextPipeline.BookTitle} has completed the currently available Story Intelligence stages."
|
|
: $"{nextPipeline.BookTitle} is waiting at the next Story Intelligence review stage.",
|
|
ButtonText = nextPipeline.IsComplete ? "View summary" : "Continue Story Intelligence",
|
|
PipelineBooks = pipelineBooks
|
|
};
|
|
}
|
|
|
|
var state = await onboarding.GetAsync(currentUser.UserId.Value);
|
|
return state?.ProjectID.HasValue == true && state.BookID.HasValue
|
|
? new StoryIntelligenceDashboardViewModel
|
|
{
|
|
ShouldShow = true,
|
|
Title = "Import your manuscript",
|
|
Description = "Scan your manuscript with the Word Companion, review the chapters, then let PlotDirector prepare scene suggestions.",
|
|
ButtonText = "Continue manuscript import",
|
|
PipelineBooks = pipelineBooks
|
|
}
|
|
: new StoryIntelligenceDashboardViewModel();
|
|
}
|
|
|
|
public async Task<StoryIntelligenceJobProgress?> GetProgressForUserAsync(int userId, Guid jobId)
|
|
{
|
|
var job = await repository.GetAsync(userId, jobId);
|
|
return job is null ? null : ToProgress(job);
|
|
}
|
|
|
|
public static StoryIntelligenceJobProgress ToProgress(StoryIntelligenceJob job)
|
|
{
|
|
var elapsedFrom = job.StartedUtc ?? job.CreatedUtc;
|
|
return new StoryIntelligenceJobProgress
|
|
{
|
|
JobID = job.JobID,
|
|
UserID = job.UserID,
|
|
ProjectID = job.ProjectID,
|
|
BookID = job.BookID,
|
|
Status = job.Status,
|
|
CurrentStage = job.CurrentStage,
|
|
CurrentMessage = job.CurrentMessage,
|
|
ProgressPercent = job.ProgressPercent,
|
|
EstimatedRemaining = job.EstimatedRemaining,
|
|
ErrorMessage = job.ErrorMessage,
|
|
UpdatedUtc = job.UpdatedUtc,
|
|
IsActive = job.IsActive,
|
|
IsCompleted = job.IsCompleted,
|
|
ElapsedTime = FormatElapsed(DateTime.UtcNow - elapsedFrom)
|
|
};
|
|
}
|
|
|
|
private int RequireUserId()
|
|
=> currentUser.UserId ?? throw new InvalidOperationException("Sign in to use Story Intelligence.");
|
|
|
|
private static string BuildBookTitle(Book? book)
|
|
=> book is null ? "Selected book" : $"Book {book.BookNumber}: {book.BookDisplayTitle}";
|
|
|
|
private static StoryIntelligencePipelineBookStatusViewModel ToPipelineBookStatus(StoryIntelligenceBookPipelineState state)
|
|
=> new()
|
|
{
|
|
BookID = state.BookID,
|
|
BookTitle = state.BookDisplayTitle,
|
|
StatusLabel = PipelineStatusLabel(state),
|
|
Description = PipelineDescription(state),
|
|
IsComplete = state.IsComplete
|
|
};
|
|
|
|
private static string PipelineStatusLabel(StoryIntelligenceBookPipelineState state)
|
|
{
|
|
if (state.IsComplete)
|
|
{
|
|
return "Story Intelligence Complete";
|
|
}
|
|
|
|
return state.CurrentReviewStage switch
|
|
{
|
|
StoryIntelligencePipelineStages.AssetReview => "Waiting for Asset Review",
|
|
StoryIntelligencePipelineStages.LocationReview => "Waiting for Location Review",
|
|
StoryIntelligencePipelineStages.CharacterReview => "Waiting for Character Review",
|
|
StoryIntelligencePipelineStages.SceneReview => "Needs Scene Review",
|
|
_ when StoryIntelligenceReadyForAssets(state) => "Waiting for Asset Review",
|
|
_ when StoryIntelligenceReadyForLocations(state) => "Waiting for Location Review",
|
|
_ => string.Equals(state.Status, StoryIntelligencePipelineStatuses.InProgress, StringComparison.OrdinalIgnoreCase)
|
|
? "Story Intelligence In Progress"
|
|
: "Story Intelligence Needs Review"
|
|
};
|
|
}
|
|
|
|
private static string PipelineDescription(StoryIntelligenceBookPipelineState state)
|
|
=> state.IsComplete
|
|
? "All currently available Story Intelligence stages are complete."
|
|
: state.CurrentReviewStage switch
|
|
{
|
|
StoryIntelligencePipelineStages.AssetReview => "Review detected assets and decide what to create or link.",
|
|
StoryIntelligencePipelineStages.LocationReview => "Review detected locations and decide what to create or link.",
|
|
StoryIntelligencePipelineStages.CharacterReview => "Review detected characters and decide what to create or link.",
|
|
StoryIntelligencePipelineStages.SceneReview => "Review prepared scenes before creating them.",
|
|
_ when StoryIntelligenceReadyForAssets(state) => "Review detected assets and decide what to create or link.",
|
|
_ when StoryIntelligenceReadyForLocations(state) => "Review detected locations and decide what to create or link.",
|
|
_ => "Continue from the next Story Intelligence stage."
|
|
};
|
|
|
|
private static bool StoryIntelligenceReadyForAssets(StoryIntelligenceBookPipelineState state)
|
|
=> string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.LocationImport, StringComparison.OrdinalIgnoreCase)
|
|
|| state.CurrentStage is StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport
|
|
|| (string.Equals(state.CurrentStage, StoryIntelligencePipelineStages.Complete, StringComparison.OrdinalIgnoreCase)
|
|
&& !string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.AssetImport, StringComparison.OrdinalIgnoreCase));
|
|
|
|
private static bool StoryIntelligenceReadyForLocations(StoryIntelligenceBookPipelineState state)
|
|
=> string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.CharacterImport, StringComparison.OrdinalIgnoreCase)
|
|
|| state.CurrentStage is StoryIntelligencePipelineStages.LocationReview or StoryIntelligencePipelineStages.LocationImport;
|
|
|
|
private static string FormatElapsed(TimeSpan elapsed)
|
|
=> elapsed.TotalMinutes < 1
|
|
? $"{Math.Max(0, (int)elapsed.TotalSeconds)} sec"
|
|
: $"{(int)elapsed.TotalMinutes} min {elapsed.Seconds:D2} sec";
|
|
}
|
|
|
|
public sealed class StoryIntelligenceWorker(
|
|
IServiceScopeFactory scopeFactory,
|
|
IHubContext<StoryIntelligenceHub> hub,
|
|
ILogger<StoryIntelligenceWorker> logger) : BackgroundService
|
|
{
|
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
{
|
|
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(2));
|
|
while (!stoppingToken.IsCancellationRequested)
|
|
{
|
|
await ProcessNextAsync(stoppingToken);
|
|
await timer.WaitForNextTickAsync(stoppingToken);
|
|
}
|
|
}
|
|
|
|
private async Task ProcessNextAsync(CancellationToken stoppingToken)
|
|
{
|
|
using var scope = scopeFactory.CreateScope();
|
|
var repository = scope.ServiceProvider.GetRequiredService<IStoryIntelligenceRepository>();
|
|
var provider = scope.ServiceProvider.GetRequiredService<IStoryIntelligenceProvider>();
|
|
var job = await repository.ClaimNextPendingAsync();
|
|
if (job is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
await NotifyAsync(job);
|
|
try
|
|
{
|
|
await provider.ProcessAsync(job, async update =>
|
|
{
|
|
var updated = await repository.UpdateProgressAsync(
|
|
job.JobID,
|
|
update.Status,
|
|
update.Percent,
|
|
update.Stage,
|
|
update.Message,
|
|
update.EstimatedRemaining);
|
|
if (updated is not null)
|
|
{
|
|
await NotifyAsync(updated);
|
|
if (updated.IsCancelled)
|
|
{
|
|
throw new OperationCanceledException();
|
|
}
|
|
}
|
|
}, stoppingToken);
|
|
|
|
var completed = await repository.CompleteAsync(job.JobID);
|
|
if (completed is not null)
|
|
{
|
|
await NotifyAsync(completed);
|
|
}
|
|
}
|
|
catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested)
|
|
{
|
|
var cancelled = await repository.CancelAsync(job.UserID, job.JobID);
|
|
if (cancelled is not null)
|
|
{
|
|
await NotifyAsync(cancelled);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Story Intelligence job {JobID} failed.", job.JobID);
|
|
var failed = await repository.FailAsync(job.JobID, ex.Message);
|
|
if (failed is not null)
|
|
{
|
|
await NotifyAsync(failed);
|
|
}
|
|
}
|
|
}
|
|
|
|
private Task NotifyAsync(StoryIntelligenceJob job)
|
|
=> hub.Clients.Group(StoryIntelligenceHub.UserGroup(job.UserID))
|
|
.SendAsync("StoryIntelligenceProgressChanged", StoryIntelligenceService.ToProgress(job));
|
|
}
|