278 lines
10 KiB
C#
278 lines
10 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,
|
|
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 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"
|
|
}
|
|
: 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 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));
|
|
}
|