PlotDirector/PlotLine/Services/StoryIntelligenceService.cs

402 lines
18 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.RelationshipReview => "Waiting for Relationship Review",
StoryIntelligencePipelineStages.AssetReview => "Waiting for Asset Review",
StoryIntelligencePipelineStages.LocationReview => "Waiting for Location Review",
StoryIntelligencePipelineStages.CharacterReview => "Waiting for Character Review",
StoryIntelligencePipelineStages.SceneReview => "Needs Scene Review",
_ when StoryIntelligenceReadyForRelationships(state) => "Waiting for Relationship 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.RelationshipReview => "Review detected relationships and decide what to create or link.",
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 StoryIntelligenceReadyForRelationships(state) => "Review detected relationships and decide what to create or link.",
_ 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 StoryIntelligenceReadyForRelationships(StoryIntelligenceBookPipelineState state)
=> string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.AssetImport, StringComparison.OrdinalIgnoreCase)
|| state.CurrentStage is StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport;
private static bool StoryIntelligenceReadyForAssets(StoryIntelligenceBookPipelineState state)
=> string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.LocationImport, StringComparison.OrdinalIgnoreCase)
|| state.CurrentStage is StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport
or StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport
|| (string.Equals(state.CurrentStage, StoryIntelligencePipelineStages.Complete, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.RelationshipImport, StringComparison.OrdinalIgnoreCase));
private static bool StoryIntelligenceReadyForLocations(StoryIntelligenceBookPipelineState state)
=> string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.CharacterImport, StringComparison.OrdinalIgnoreCase)
|| state.CurrentStage is StoryIntelligencePipelineStages.LocationReview or StoryIntelligencePipelineStages.LocationImport
or StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport
or StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport;
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));
}