Phase 20AA – Connect Onboarding Wizard To Story Intelligence Scene Import
This commit is contained in:
parent
aefa9aaadc
commit
4b7af17b42
@ -8,7 +8,7 @@ namespace PlotLine.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[Route("onboarding")]
|
||||
public sealed class OnboardingController(IOnboardingService onboarding, IStoryIntelligenceService storyIntelligence) : Controller
|
||||
public sealed class OnboardingController(IOnboardingService onboarding, IOnboardingStoryIntelligenceService storyIntelligence) : Controller
|
||||
{
|
||||
[HttpGet("")]
|
||||
public async Task<IActionResult> Index()
|
||||
@ -45,11 +45,11 @@ public sealed class OnboardingController(IOnboardingService onboarding, IStoryIn
|
||||
var job = await storyIntelligence.StartAsync();
|
||||
if (job is null)
|
||||
{
|
||||
TempData["ArchiveError"] = "Choose a project and book before starting Story Intelligence.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
TempData["OnboardingStoryIntelligenceError"] = "Review the manuscript scan before starting Story Intelligence.";
|
||||
return RedirectToAction(nameof(StoryIntelligence));
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(StoryIntelligenceProgress), new { jobId = job.JobID });
|
||||
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId = job.BatchID });
|
||||
}
|
||||
|
||||
[HttpPost("story-intelligence/skip")]
|
||||
@ -62,30 +62,50 @@ public sealed class OnboardingController(IOnboardingService onboarding, IStoryIn
|
||||
}
|
||||
|
||||
[HttpGet("story-intelligence/progress")]
|
||||
public async Task<IActionResult> StoryIntelligenceProgress(Guid jobId)
|
||||
public async Task<IActionResult> StoryIntelligenceProgress(Guid batchId)
|
||||
{
|
||||
var model = await storyIntelligence.GetProgressAsync(jobId);
|
||||
var model = await storyIntelligence.GetProgressAsync(batchId);
|
||||
return model is null ? NotFound() : View(model);
|
||||
}
|
||||
|
||||
[HttpPost("story-intelligence/cancel")]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> CancelStoryIntelligence(Guid jobId)
|
||||
public async Task<IActionResult> CancelStoryIntelligence(Guid batchId)
|
||||
{
|
||||
var job = await storyIntelligence.CancelAsync(jobId);
|
||||
if (job is null)
|
||||
var progress = await storyIntelligence.CancelAsync(batchId);
|
||||
if (progress is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
TempData["ArchiveMessage"] = "Story Intelligence setup was cancelled.";
|
||||
return RedirectToAction("Index", "Projects");
|
||||
TempData["OnboardingStoryIntelligenceMessage"] = "Analysis cancellation requested.";
|
||||
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId });
|
||||
}
|
||||
|
||||
[HttpPost("story-intelligence/commit")]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> CommitStoryIntelligence(Guid batchId, int runId)
|
||||
{
|
||||
var (progress, result) = await storyIntelligence.CommitAsync(batchId, runId);
|
||||
if (progress is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
|
||||
if (result.Success && progress.AllCommitted)
|
||||
{
|
||||
TempData["WriterWorkspaceMessage"] = $"{progress.CommittedChapterCount} chapter(s) and {progress.Chapters.Sum(chapter => chapter.ScenesCreated)} scene(s) created from your manuscript.";
|
||||
return RedirectToAction("Index", "Writer", new { projectId = progress.ProjectID });
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId });
|
||||
}
|
||||
|
||||
[HttpGet("story-intelligence/complete")]
|
||||
public async Task<IActionResult> StoryIntelligenceComplete(Guid jobId)
|
||||
public async Task<IActionResult> StoryIntelligenceComplete(Guid batchId)
|
||||
{
|
||||
var model = await storyIntelligence.GetCompletionAsync(jobId);
|
||||
var model = await storyIntelligence.GetCompletionAsync(batchId);
|
||||
return model is null ? NotFound() : View(model);
|
||||
}
|
||||
|
||||
@ -103,7 +123,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IStoryIn
|
||||
}
|
||||
|
||||
TempData["OnboardingReviewMessage"] = readyToImport
|
||||
? "Review saved. Next: import into PlotDirector."
|
||||
? "Review saved. Next: prepare chapters and analyse manuscript."
|
||||
: "Review choices saved.";
|
||||
return readyToImport
|
||||
? RedirectToAction(nameof(Index))
|
||||
|
||||
@ -75,6 +75,7 @@ public sealed class ManuscriptScanChapterPreview
|
||||
public int ChapterNumber { get; init; }
|
||||
public string Title { get; init; } = string.Empty;
|
||||
public int WordCount { get; init; }
|
||||
public string? ChapterText { get; init; }
|
||||
public int? StartPosition { get; init; }
|
||||
public int? ExistingChapterID { get; init; }
|
||||
}
|
||||
|
||||
@ -186,8 +186,10 @@ public class Program
|
||||
builder.Services.AddScoped<IWordCompanionService, WordCompanionService>();
|
||||
builder.Services.AddSingleton<IWordCompanionPresenceService, WordCompanionPresenceService>();
|
||||
builder.Services.AddSingleton<IManuscriptScanPreviewStore, ManuscriptScanPreviewStore>();
|
||||
builder.Services.AddSingleton<IOnboardingStoryIntelligenceBatchStore, OnboardingStoryIntelligenceBatchStore>();
|
||||
builder.Services.AddHostedService<WordCompanionPresenceMonitor>();
|
||||
builder.Services.AddScoped<IOnboardingService, OnboardingService>();
|
||||
builder.Services.AddScoped<IOnboardingStoryIntelligenceService, OnboardingStoryIntelligenceService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceService, StoryIntelligenceService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceProvider, StubStoryIntelligenceProvider>();
|
||||
builder.Services.AddSingleton<IStoryPromptRepository, StoryPromptRepository>();
|
||||
|
||||
@ -198,24 +198,15 @@ public sealed class OnboardingService(
|
||||
var (userId, preview, review, _, _, _) = context.Value;
|
||||
if (!string.Equals(review.Status, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("Review the scan and choose Save and continue before building the project structure.");
|
||||
throw new InvalidOperationException("Review the scan and choose Save and continue before preparing chapters.");
|
||||
}
|
||||
|
||||
await PublishBuildProgress(userId, preview.PreviewID, "Creating chapters...", 10, progress);
|
||||
ValidateScanReview(review, preview);
|
||||
|
||||
var chapterDecisions = review.Chapters.Where(chapter => chapter.Include).OrderBy(chapter => chapter.ChapterNumber).ToList();
|
||||
var chapterKeys = chapterDecisions.Select(chapter => chapter.TemporaryChapterKey).ToHashSet(StringComparer.Ordinal);
|
||||
var sceneDecisions = review.Scenes
|
||||
.Where(scene => scene.Include && chapterKeys.Contains(scene.TemporaryChapterKey))
|
||||
.OrderBy(scene => chapterDecisions.FindIndex(chapter => chapter.TemporaryChapterKey == scene.TemporaryChapterKey))
|
||||
.ThenBy(scene => scene.SceneNumberWithinChapter)
|
||||
.ToList();
|
||||
var characterDecisions = review.Characters
|
||||
.Where(character => character.Include && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
await PublishBuildProgress(userId, preview.PreviewID, "Creating scenes...", 35, progress);
|
||||
await PublishBuildProgress(userId, preview.PreviewID, "Preparing chapters for analysis...", 35, progress);
|
||||
var request = new OnboardingManuscriptBuildRequest
|
||||
{
|
||||
PreviewID = preview.PreviewID,
|
||||
@ -229,25 +220,34 @@ public sealed class OnboardingService(
|
||||
SortOrder = (index + 1) * 10,
|
||||
WordCount = preview.Chapters.FirstOrDefault(item => item.TemporaryChapterKey == chapter.TemporaryChapterKey)?.WordCount ?? 0
|
||||
}).ToList(),
|
||||
Scenes = sceneDecisions.Select((scene, index) => new OnboardingBuildSceneRequest
|
||||
{
|
||||
TemporarySceneKey = scene.TemporarySceneKey,
|
||||
TemporaryChapterKey = scene.TemporaryChapterKey,
|
||||
Title = string.IsNullOrWhiteSpace(scene.Title) ? $"Scene {scene.SceneNumberWithinChapter}" : scene.Title!,
|
||||
SortOrder = (index + 1) * 10,
|
||||
WordCount = preview.Scenes.FirstOrDefault(item => item.TemporarySceneKey == scene.TemporarySceneKey)?.WordCount ?? 0
|
||||
}).ToList(),
|
||||
Characters = characterDecisions.Select(character => new OnboardingBuildCharacterRequest
|
||||
{
|
||||
TemporaryCharacterKey = character.TemporaryCharacterKey,
|
||||
Name = character.Name,
|
||||
ExistingCharacterID = character.ExistingCharacterID
|
||||
}).ToList()
|
||||
Scenes = [],
|
||||
Characters = []
|
||||
};
|
||||
|
||||
await PublishBuildProgress(userId, preview.PreviewID, "Creating characters...", 60, progress);
|
||||
await PublishBuildProgress(userId, preview.PreviewID, "Saving chapters...", 60, progress);
|
||||
var result = await builds.BuildAsync(userId, request)
|
||||
?? throw new InvalidOperationException($"The project structure could not be built. Approved chapters: {request.Chapters.Count}, scenes: {request.Scenes.Count}, characters: {request.Characters.Count}. Return to review, save your selections, and try Build Project again.");
|
||||
?? throw new InvalidOperationException($"The project chapters could not be prepared. Approved chapters: {request.Chapters.Count}. Return to review, save your selections, and try again.");
|
||||
result = new OnboardingManuscriptBuildResult
|
||||
{
|
||||
BuildID = result.BuildID,
|
||||
PreviewID = result.PreviewID,
|
||||
ProjectID = result.ProjectID,
|
||||
BookID = result.BookID,
|
||||
ManuscriptDocumentID = result.ManuscriptDocumentID,
|
||||
Status = result.Status,
|
||||
MarkerStatus = result.MarkerStatus,
|
||||
ChaptersCreated = result.ChaptersCreated,
|
||||
ScenesCreated = result.ScenesCreated,
|
||||
CharactersCreated = result.CharactersCreated,
|
||||
CharactersReused = result.CharactersReused,
|
||||
SceneAppearancesCreated = result.SceneAppearancesCreated,
|
||||
AlreadyBuilt = result.AlreadyBuilt,
|
||||
Message = "Approved chapters are ready for Story Intelligence.",
|
||||
ChapterMappings = result.ChapterMappings,
|
||||
SceneMappings = result.SceneMappings,
|
||||
CharacterMappings = result.CharacterMappings,
|
||||
MarkerWarning = result.MarkerWarning
|
||||
};
|
||||
await scanPreviews.SaveBuildResultAsync(userId, result);
|
||||
await PublishBuildProgress(userId, preview.PreviewID, "Finalising project...", 90, progress);
|
||||
await PublishBuildProgress(userId, preview.PreviewID, result.Message, 100, progress, result);
|
||||
|
||||
382
PlotLine/Services/OnboardingStoryIntelligenceService.cs
Normal file
382
PlotLine/Services/OnboardingStoryIntelligenceService.cs
Normal file
@ -0,0 +1,382 @@
|
||||
using System.Collections.Concurrent;
|
||||
using PlotLine.Data;
|
||||
using PlotLine.Models;
|
||||
using PlotLine.ViewModels;
|
||||
|
||||
namespace PlotLine.Services;
|
||||
|
||||
public interface IOnboardingStoryIntelligenceService
|
||||
{
|
||||
Task<StoryIntelligenceOverviewViewModel> GetOverviewAsync();
|
||||
Task<OnboardingStoryIntelligenceBatch?> StartAsync();
|
||||
Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid batchId);
|
||||
Task<StoryIntelligenceProgressViewModel?> CancelAsync(Guid batchId);
|
||||
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAsync(Guid batchId, int runId);
|
||||
Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId);
|
||||
}
|
||||
|
||||
public sealed class OnboardingStoryIntelligenceService(
|
||||
IOnboardingRepository onboardingRepository,
|
||||
IOnboardingService onboarding,
|
||||
IManuscriptScanPreviewStore scanStore,
|
||||
IStoryIntelligenceResultRepository runs,
|
||||
IStoryIntelligenceImportCommitService commits,
|
||||
IStoryIntelligenceClient client,
|
||||
IOnboardingStoryIntelligenceBatchStore batchStore,
|
||||
ICurrentUserService currentUser,
|
||||
ILogger<OnboardingStoryIntelligenceService> logger) : IOnboardingStoryIntelligenceService
|
||||
{
|
||||
private const string PromptVersionsSummary = "Chapter=Chapter-Structure-Prompt-V2; Scene=Scene-Prompt-V2";
|
||||
private const string SourceType = "OnboardingWordCompanionChapter";
|
||||
|
||||
public async Task<StoryIntelligenceOverviewViewModel> GetOverviewAsync()
|
||||
{
|
||||
var userId = RequireUserId();
|
||||
var context = await GetContextAsync(userId);
|
||||
if (context is null)
|
||||
{
|
||||
return new StoryIntelligenceOverviewViewModel
|
||||
{
|
||||
Message = "Finish the Word Companion scan and review before analysing your manuscript."
|
||||
};
|
||||
}
|
||||
|
||||
var (_, preview, review, build, wizard) = context.Value;
|
||||
var included = IncludedChapters(preview, review);
|
||||
var existingBatch = await batchStore.GetLatestForPreviewAsync(userId, preview.PreviewID);
|
||||
var missingTextCount = included.Count(chapter => string.IsNullOrWhiteSpace(chapter.Preview.ChapterText));
|
||||
return new StoryIntelligenceOverviewViewModel
|
||||
{
|
||||
PreviewID = preview.PreviewID,
|
||||
ProjectID = preview.ProjectID,
|
||||
BookID = preview.BookID,
|
||||
SelectedProjectName = wizard.SelectedProjectName,
|
||||
SelectedBookTitle = wizard.SelectedBookTitle,
|
||||
ApprovedChapterCount = included.Count,
|
||||
ApprovedWordCount = included.Sum(chapter => chapter.Preview.WordCount),
|
||||
MissingChapterTextCount = missingTextCount,
|
||||
ExistingBatchID = existingBatch?.BatchID,
|
||||
CanStart = included.Count > 0 && missingTextCount == 0,
|
||||
Message = build is null
|
||||
? "PlotDirector will prepare the approved chapters, then analyse them with Story Intelligence."
|
||||
: "The approved chapters are ready to analyse."
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<OnboardingStoryIntelligenceBatch?> StartAsync()
|
||||
{
|
||||
var userId = RequireUserId();
|
||||
var context = await GetContextAsync(userId);
|
||||
if (context is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var (state, preview, review, build, wizard) = context.Value;
|
||||
var existing = await batchStore.GetLatestForPreviewAsync(userId, preview.PreviewID);
|
||||
if (existing is not null)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
build ??= await onboarding.BuildApprovedStructureAsync(preview.PreviewID)
|
||||
?? throw new InvalidOperationException("The approved chapters could not be prepared for analysis.");
|
||||
|
||||
var chapterIds = build.ChapterMappings.ToDictionary(chapter => chapter.TemporaryChapterKey, chapter => chapter.ChapterID, StringComparer.Ordinal);
|
||||
var included = IncludedChapters(preview, review);
|
||||
if (included.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Choose at least one chapter before analysing the manuscript.");
|
||||
}
|
||||
|
||||
if (included.Any(chapter => string.IsNullOrWhiteSpace(chapter.Preview.ChapterText)))
|
||||
{
|
||||
throw new InvalidOperationException("The Word Companion scan did not include chapter text. Scan the manuscript again before analysing it.");
|
||||
}
|
||||
|
||||
var clientStatus = client.GetConfigurationStatus();
|
||||
var modelSummary = StoryIntelligenceOptions.BuildModelSummary(
|
||||
clientStatus.ChapterStructureModel,
|
||||
clientStatus.SceneIntelligenceModel);
|
||||
var batchItems = new List<OnboardingStoryIntelligenceBatchItem>();
|
||||
|
||||
foreach (var chapter in included)
|
||||
{
|
||||
if (!chapterIds.TryGetValue(chapter.Preview.TemporaryChapterKey, out var chapterId))
|
||||
{
|
||||
chapterId = chapter.Preview.ExistingChapterID ?? 0;
|
||||
}
|
||||
|
||||
if (chapterId <= 0)
|
||||
{
|
||||
throw new InvalidOperationException($"PlotDirector could not prepare chapter '{chapter.Decision.Title}' for analysis.");
|
||||
}
|
||||
|
||||
var text = chapter.Preview.ChapterText!.Trim();
|
||||
var paragraphs = StoryIntelligenceParagraphs.Split(text);
|
||||
var runId = await runs.QueueAdminTextAsync(new StoryIntelligenceRunQueueRequest
|
||||
{
|
||||
UserID = userId,
|
||||
ProjectID = preview.ProjectID,
|
||||
BookID = preview.BookID,
|
||||
ChapterID = chapterId,
|
||||
ChapterNumber = chapter.Decision.ChapterNumber,
|
||||
SourceType = SourceType,
|
||||
SourceLabel = $"{wizard.SelectedBookTitle}: {chapter.Decision.Title}",
|
||||
ChapterText = text,
|
||||
SourceFileName = preview.DocumentTitle,
|
||||
SourceWordCount = CountWords(text),
|
||||
SourceCharacterCount = text.Length,
|
||||
SourceParagraphCount = paragraphs.Count,
|
||||
SourceChapterCount = 1,
|
||||
Model = modelSummary,
|
||||
PromptVersionsSummary = PromptVersionsSummary
|
||||
});
|
||||
|
||||
batchItems.Add(new OnboardingStoryIntelligenceBatchItem
|
||||
{
|
||||
TemporaryChapterKey = chapter.Preview.TemporaryChapterKey,
|
||||
ChapterNumber = chapter.Decision.ChapterNumber,
|
||||
ChapterTitle = chapter.Decision.Title,
|
||||
ChapterID = chapterId,
|
||||
RunID = runId
|
||||
});
|
||||
}
|
||||
|
||||
var batch = new OnboardingStoryIntelligenceBatch
|
||||
{
|
||||
UserID = userId,
|
||||
OnboardingID = state.UserOnboardingStateID,
|
||||
PreviewID = preview.PreviewID,
|
||||
ProjectID = preview.ProjectID,
|
||||
BookID = preview.BookID,
|
||||
ProjectName = wizard.SelectedProjectName,
|
||||
BookTitle = wizard.SelectedBookTitle,
|
||||
Items = batchItems
|
||||
};
|
||||
|
||||
await batchStore.SaveAsync(batch);
|
||||
logger.LogInformation(
|
||||
"Queued onboarding Story Intelligence batch {BatchID}. UserID={UserID} ProjectID={ProjectID} BookID={BookID} Chapters={ChapterCount}",
|
||||
batch.BatchID,
|
||||
userId,
|
||||
preview.ProjectID,
|
||||
preview.BookID,
|
||||
batchItems.Count);
|
||||
|
||||
return batch;
|
||||
}
|
||||
|
||||
public async Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid batchId)
|
||||
{
|
||||
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
|
||||
if (batch is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var chapters = new List<StoryIntelligenceOnboardingChapterViewModel>();
|
||||
foreach (var item in batch.Items)
|
||||
{
|
||||
var run = await runs.GetRunAsync(item.RunID);
|
||||
if (run is null)
|
||||
{
|
||||
chapters.Add(new StoryIntelligenceOnboardingChapterViewModel
|
||||
{
|
||||
TemporaryChapterKey = item.TemporaryChapterKey,
|
||||
ChapterNumber = item.ChapterNumber,
|
||||
ChapterTitle = item.ChapterTitle,
|
||||
ChapterID = item.ChapterID,
|
||||
RunID = item.RunID,
|
||||
Status = "Missing",
|
||||
ErrorMessage = "This analysis run could not be found."
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
var commit = await runs.GetImportCommitAsync(item.RunID);
|
||||
var confirmation = run.Status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings
|
||||
? await commits.BuildConfirmationAsync(item.RunID)
|
||||
: null;
|
||||
|
||||
chapters.Add(new StoryIntelligenceOnboardingChapterViewModel
|
||||
{
|
||||
TemporaryChapterKey = item.TemporaryChapterKey,
|
||||
ChapterNumber = item.ChapterNumber,
|
||||
ChapterTitle = item.ChapterTitle,
|
||||
ChapterID = item.ChapterID,
|
||||
RunID = item.RunID,
|
||||
Status = run.Status,
|
||||
CurrentStage = run.CurrentStage,
|
||||
CurrentMessage = run.CurrentMessage,
|
||||
TotalDetectedScenes = run.TotalDetectedScenes,
|
||||
CompletedScenes = run.CompletedScenes,
|
||||
FailedScenes = run.FailedScenes,
|
||||
TotalTokens = run.TotalTokens,
|
||||
TotalDurationMs = run.TotalDurationMs,
|
||||
FailureStage = run.FailureStage,
|
||||
ErrorMessage = run.ErrorMessage,
|
||||
HasCompletedCommit = commit is not null && string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase),
|
||||
CanCommit = confirmation?.CanCommit == true,
|
||||
ScenesCreated = commit?.ScenesCreated ?? 0,
|
||||
CommitMessage = commit?.Notes,
|
||||
Blockers = confirmation?.Blockers ?? [],
|
||||
Warnings = confirmation?.Warnings ?? []
|
||||
});
|
||||
}
|
||||
|
||||
return new StoryIntelligenceProgressViewModel
|
||||
{
|
||||
BatchID = batch.BatchID,
|
||||
PreviewID = batch.PreviewID,
|
||||
ProjectID = batch.ProjectID,
|
||||
BookID = batch.BookID,
|
||||
ProjectName = batch.ProjectName,
|
||||
BookTitle = batch.BookTitle,
|
||||
Chapters = chapters
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<StoryIntelligenceProgressViewModel?> CancelAsync(Guid batchId)
|
||||
{
|
||||
var progress = await GetProgressAsync(batchId);
|
||||
if (progress is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var chapter in progress.Chapters.Where(chapter => chapter.IsActive))
|
||||
{
|
||||
await runs.RequestCancelAsync(chapter.RunID);
|
||||
}
|
||||
|
||||
return await GetProgressAsync(batchId);
|
||||
}
|
||||
|
||||
public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAsync(Guid batchId, int runId)
|
||||
{
|
||||
var progress = await GetProgressAsync(batchId);
|
||||
if (progress is null || progress.Chapters.All(chapter => chapter.RunID != runId))
|
||||
{
|
||||
return (progress, new StoryIntelligenceImportCommitResult { Success = false, Message = "This chapter analysis could not be found." });
|
||||
}
|
||||
|
||||
var result = await commits.CommitAsync(runId, RequireUserId());
|
||||
return (await GetProgressAsync(batchId), result);
|
||||
}
|
||||
|
||||
public async Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId)
|
||||
{
|
||||
var progress = await GetProgressAsync(batchId);
|
||||
if (progress is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new StoryIntelligenceCompletionViewModel
|
||||
{
|
||||
BatchID = progress.BatchID,
|
||||
ProjectID = progress.ProjectID,
|
||||
BookID = progress.BookID,
|
||||
ProjectName = progress.ProjectName,
|
||||
BookTitle = progress.BookTitle,
|
||||
ChaptersCommitted = progress.CommittedChapterCount,
|
||||
ScenesCreated = progress.Chapters.Sum(chapter => chapter.ScenesCreated)
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<(UserOnboardingState State, ManuscriptScanPreview Preview, ManuscriptScanReviewDecision Review, OnboardingManuscriptBuildResult? Build, OnboardingWizardViewModel Wizard)?> GetContextAsync(int userId)
|
||||
{
|
||||
var state = await onboardingRepository.GetAsync(userId);
|
||||
if (state is null || !state.ProjectID.HasValue || !state.BookID.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var preview = await scanStore.GetLatestPreviewAsync(userId, state.UserOnboardingStateID);
|
||||
if (preview is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var review = await scanStore.GetReviewAsync(userId, preview.PreviewID);
|
||||
if (review is null || !string.Equals(review.Status, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var build = await scanStore.GetBuildResultAsync(userId, preview.PreviewID);
|
||||
var wizard = await onboarding.GetWizardAsync();
|
||||
return (state, preview, review, build, wizard);
|
||||
}
|
||||
|
||||
private int RequireUserId()
|
||||
=> currentUser.UserId ?? throw new InvalidOperationException("Sign in before using Story Intelligence.");
|
||||
|
||||
private static IReadOnlyList<IncludedChapter> IncludedChapters(ManuscriptScanPreview preview, ManuscriptScanReviewDecision review)
|
||||
{
|
||||
var chapters = preview.Chapters.ToDictionary(chapter => chapter.TemporaryChapterKey, StringComparer.Ordinal);
|
||||
return review.Chapters
|
||||
.Where(chapter => chapter.Include && chapters.ContainsKey(chapter.TemporaryChapterKey))
|
||||
.OrderBy(chapter => chapter.ChapterNumber)
|
||||
.Select(chapter => new IncludedChapter(chapter, chapters[chapter.TemporaryChapterKey]))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static int CountWords(string value)
|
||||
=> string.IsNullOrWhiteSpace(value)
|
||||
? 0
|
||||
: value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
|
||||
|
||||
private sealed record IncludedChapter(ManuscriptScanChapterReviewDecision Decision, ManuscriptScanChapterPreview Preview);
|
||||
}
|
||||
|
||||
public interface IOnboardingStoryIntelligenceBatchStore
|
||||
{
|
||||
Task SaveAsync(OnboardingStoryIntelligenceBatch batch);
|
||||
Task<OnboardingStoryIntelligenceBatch?> GetAsync(int userId, Guid batchId);
|
||||
Task<OnboardingStoryIntelligenceBatch?> GetLatestForPreviewAsync(int userId, Guid previewId);
|
||||
}
|
||||
|
||||
public sealed class OnboardingStoryIntelligenceBatchStore : IOnboardingStoryIntelligenceBatchStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<Guid, OnboardingStoryIntelligenceBatch> batches = new();
|
||||
|
||||
public Task SaveAsync(OnboardingStoryIntelligenceBatch batch)
|
||||
{
|
||||
batches[batch.BatchID] = batch;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<OnboardingStoryIntelligenceBatch?> GetAsync(int userId, Guid batchId)
|
||||
=> Task.FromResult(batches.TryGetValue(batchId, out var batch) && batch.UserID == userId ? batch : null);
|
||||
|
||||
public Task<OnboardingStoryIntelligenceBatch?> GetLatestForPreviewAsync(int userId, Guid previewId)
|
||||
=> Task.FromResult(batches.Values
|
||||
.Where(batch => batch.UserID == userId && batch.PreviewID == previewId)
|
||||
.OrderByDescending(batch => batch.CreatedUtc)
|
||||
.FirstOrDefault());
|
||||
}
|
||||
|
||||
public sealed class OnboardingStoryIntelligenceBatch
|
||||
{
|
||||
public Guid BatchID { get; init; } = Guid.NewGuid();
|
||||
public int UserID { get; init; }
|
||||
public int OnboardingID { get; init; }
|
||||
public Guid PreviewID { get; init; }
|
||||
public int ProjectID { get; init; }
|
||||
public int BookID { get; init; }
|
||||
public string? ProjectName { get; init; }
|
||||
public string? BookTitle { get; init; }
|
||||
public DateTime CreatedUtc { get; init; } = DateTime.UtcNow;
|
||||
public IReadOnlyList<OnboardingStoryIntelligenceBatchItem> Items { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class OnboardingStoryIntelligenceBatchItem
|
||||
{
|
||||
public string TemporaryChapterKey { get; init; } = string.Empty;
|
||||
public int ChapterNumber { get; init; }
|
||||
public string ChapterTitle { get; init; } = string.Empty;
|
||||
public int ChapterID { get; init; }
|
||||
public int RunID { get; init; }
|
||||
}
|
||||
@ -106,28 +106,79 @@ public sealed class OnboardingNudgeViewModel
|
||||
|
||||
public sealed class StoryIntelligenceOverviewViewModel
|
||||
{
|
||||
public Guid? PreviewID { get; init; }
|
||||
public int? ProjectID { get; init; }
|
||||
public int? BookID { get; init; }
|
||||
public string? SelectedProjectName { get; init; }
|
||||
public string? SelectedBookTitle { get; init; }
|
||||
public int ApprovedChapterCount { get; init; }
|
||||
public int ApprovedWordCount { get; init; }
|
||||
public int MissingChapterTextCount { get; init; }
|
||||
public string? Message { get; init; }
|
||||
public Guid? ExistingBatchID { get; init; }
|
||||
public StoryIntelligenceJobProgress? ExistingJob { get; init; }
|
||||
public bool CanStart => ProjectID.HasValue && BookID.HasValue && ExistingJob?.IsActive != true;
|
||||
public bool CanStart { get; init; }
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceProgressViewModel
|
||||
{
|
||||
public StoryIntelligenceJobProgress Job { get; init; } = new();
|
||||
public Guid? BatchID { get; init; }
|
||||
public Guid? PreviewID { get; init; }
|
||||
public int ProjectID { get; init; }
|
||||
public int BookID { get; init; }
|
||||
public string? ProjectName { get; init; }
|
||||
public string? BookTitle { get; init; }
|
||||
public IReadOnlyList<StoryIntelligenceOnboardingChapterViewModel> Chapters { get; init; } = [];
|
||||
public int ChapterCount => Chapters.Count;
|
||||
public int CompletedChapterCount => Chapters.Count(chapter => chapter.IsRunComplete);
|
||||
public int CommittedChapterCount => Chapters.Count(chapter => chapter.HasCompletedCommit);
|
||||
public int FailedChapterCount => Chapters.Count(chapter => chapter.IsFailed);
|
||||
public int TotalDetectedScenes => Chapters.Sum(chapter => chapter.TotalDetectedScenes ?? 0);
|
||||
public int TotalCompletedScenes => Chapters.Sum(chapter => chapter.CompletedScenes ?? 0);
|
||||
public int TotalFailedScenes => Chapters.Sum(chapter => chapter.FailedScenes ?? 0);
|
||||
public bool HasActiveRuns => Chapters.Any(chapter => chapter.IsActive);
|
||||
public bool AllCommitted => Chapters.Count > 0 && Chapters.All(chapter => chapter.HasCompletedCommit);
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceCompletionViewModel
|
||||
{
|
||||
public StoryIntelligenceJobProgress Job { get; init; } = new();
|
||||
public Guid? BatchID { get; init; }
|
||||
public int ProjectID { get; init; }
|
||||
public int BookID { get; init; }
|
||||
public string? ProjectName { get; init; }
|
||||
public string? BookTitle { get; init; }
|
||||
public int ChaptersCommitted { get; init; }
|
||||
public int ScenesCreated { get; init; }
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceOnboardingChapterViewModel
|
||||
{
|
||||
public string TemporaryChapterKey { get; init; } = string.Empty;
|
||||
public int ChapterNumber { get; init; }
|
||||
public string ChapterTitle { get; init; } = string.Empty;
|
||||
public int ChapterID { get; init; }
|
||||
public int RunID { get; init; }
|
||||
public string Status { get; init; } = string.Empty;
|
||||
public string? CurrentStage { get; init; }
|
||||
public string? CurrentMessage { get; init; }
|
||||
public int? TotalDetectedScenes { get; init; }
|
||||
public int? CompletedScenes { get; init; }
|
||||
public int? FailedScenes { get; init; }
|
||||
public int? TotalTokens { get; init; }
|
||||
public long? TotalDurationMs { get; init; }
|
||||
public string? FailureStage { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
public bool HasCompletedCommit { get; init; }
|
||||
public bool CanCommit { get; init; }
|
||||
public int ScenesCreated { get; init; }
|
||||
public string? CommitMessage { get; init; }
|
||||
public IReadOnlyList<string> Blockers { get; init; } = [];
|
||||
public IReadOnlyList<string> Warnings { get; init; } = [];
|
||||
public bool IsActive => Status is StoryIntelligenceRunStatuses.Pending or StoryIntelligenceRunStatuses.Running;
|
||||
public bool IsRunComplete => Status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings;
|
||||
public bool IsFailed => Status is StoryIntelligenceRunStatuses.Failed or StoryIntelligenceRunStatuses.Cancelled;
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceDashboardViewModel
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
@model OnboardingManuscriptBuildResult
|
||||
@{
|
||||
ViewData["Title"] = "Project structure created";
|
||||
ViewData["Title"] = "Chapters prepared";
|
||||
}
|
||||
|
||||
<section class="onboarding-shell" aria-labelledby="build-complete-title">
|
||||
<div class="onboarding-panel onboarding-review-panel">
|
||||
<ol class="onboarding-stepper onboarding-stepper--complete" aria-label="Story setup journey">
|
||||
@foreach (var label in new[] { "Welcome", "Writing Preferences", "Project", "Book", "Connect Word", "Scan", "Review", "Build Project", "Complete" })
|
||||
@foreach (var label in new[] { "Welcome", "Writing Preferences", "Project", "Book", "Connect Word", "Scan", "Review", "Prepare Chapters", "Complete" })
|
||||
{
|
||||
<li class="is-complete">
|
||||
<span></span>
|
||||
@ -15,12 +15,12 @@
|
||||
}
|
||||
</ol>
|
||||
<div class="onboarding-copy">
|
||||
<p class="eyebrow">Build Project</p>
|
||||
<p class="eyebrow">Prepare Chapters</p>
|
||||
<div class="onboarding-success-heading">
|
||||
<span aria-hidden="true">✓</span>
|
||||
<h1 id="build-complete-title">Your project structure has been created.</h1>
|
||||
<h1 id="build-complete-title">Your chapters are ready.</h1>
|
||||
</div>
|
||||
<p>@(Model.AlreadyBuilt ? "This manuscript has already been built into PlotDirector, so nothing was duplicated." : "PlotDirector created the approved chapters, scenes, characters and scene appearances.")</p>
|
||||
<p>@(Model.AlreadyBuilt ? "These chapters have already been prepared in PlotDirector, so nothing was duplicated." : "PlotDirector prepared the approved chapters. Story Intelligence will create scenes after you review the analysis.")</p>
|
||||
@if (!string.IsNullOrWhiteSpace(Model.MarkerWarning))
|
||||
{
|
||||
<p class="text-warning">@Model.MarkerWarning</p>
|
||||
@ -33,28 +33,28 @@
|
||||
<strong>@Model.ChaptersCreated</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Scenes</span>
|
||||
<span>Scenes created now</span>
|
||||
<strong>@Model.ScenesCreated</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Characters</span>
|
||||
<span>Characters created now</span>
|
||||
<strong>@(Model.CharactersCreated + Model.CharactersReused)</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Scene appearances</span>
|
||||
<span>Scene appearances now</span>
|
||||
<strong>@Model.SceneAppearancesCreated</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="onboarding-complete-next">
|
||||
<h2>Ready for your next pass</h2>
|
||||
<p>Your project now has a working story map. Open the overview to inspect the structure, or head straight into the writer workspace to keep drafting.</p>
|
||||
<h2>Next: analyse manuscript</h2>
|
||||
<p>Story Intelligence will detect scenes from the approved chapter text. You will review the results before PlotDirector creates scenes.</p>
|
||||
</section>
|
||||
|
||||
<div class="onboarding-actions">
|
||||
<a class="btn btn-primary" asp-controller="Projects" asp-action="Details" asp-route-id="@Model.ProjectID">Open Project Overview</a>
|
||||
<a class="btn btn-outline-primary" asp-controller="Writer" asp-action="Index" asp-route-projectId="@Model.ProjectID">Open Writer Workspace</a>
|
||||
<a class="btn btn-outline-secondary" asp-controller="Onboarding" asp-action="StoryIntelligence">Continue to Story Intelligence</a>
|
||||
<a class="btn btn-primary" asp-controller="Onboarding" asp-action="StoryIntelligence">Analyse manuscript</a>
|
||||
<a class="btn btn-outline-primary" asp-controller="Projects" asp-action="Details" asp-route-id="@Model.ProjectID">Open Project Overview</a>
|
||||
<a class="btn btn-outline-secondary" asp-controller="Writer" asp-action="Index" asp-route-projectId="@Model.ProjectID">Open Writer Workspace</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
new { Label = "Connect Word", Order = 5 },
|
||||
new { Label = "Scan", Order = 6 },
|
||||
new { Label = "Review", Order = 7 },
|
||||
new { Label = "Build Project", Order = 8 },
|
||||
new { Label = "Prepare Chapters", Order = 8 },
|
||||
new { Label = "Complete", Order = 9 }
|
||||
};
|
||||
}
|
||||
@ -340,7 +340,7 @@
|
||||
<div class="onboarding-build-panel" data-onboarding-build-panel>
|
||||
<div>
|
||||
<p class="onboarding-scan-next">Review saved. PlotDirector can now create the approved structure.</p>
|
||||
<p class="onboarding-build-message" data-onboarding-build-message>Ready to build chapters, scenes, characters and scene appearances.</p>
|
||||
<p class="onboarding-build-message" data-onboarding-build-message>Ready to prepare approved chapters for Story Intelligence.</p>
|
||||
</div>
|
||||
<strong data-onboarding-build-percent></strong>
|
||||
<div class="onboarding-scan-progress onboarding-build-progress" aria-hidden="true">
|
||||
@ -351,7 +351,7 @@
|
||||
type="button"
|
||||
data-onboarding-build-start
|
||||
data-preview-id="@Model.ScanState.PreviewID">
|
||||
Build Project
|
||||
Prepare Chapters
|
||||
</button>
|
||||
}
|
||||
</section>
|
||||
|
||||
@ -23,8 +23,8 @@
|
||||
<div class="onboarding-copy">
|
||||
<p class="eyebrow">@Model.SelectedBookTitle</p>
|
||||
<h1 id="scan-review-title">Review manuscript scan</h1>
|
||||
<p>Nothing has been added to your project yet. Choose the chapters, scenes and character candidates that should become your first PlotDirector structure.</p>
|
||||
<p>You can keep this light now and refine everything later inside the project.</p>
|
||||
<p>Nothing has been added to your story map yet. Choose the chapters Story Intelligence should analyse for scene creation.</p>
|
||||
<p>Scene and character scan details are shown for context only in this phase.</p>
|
||||
</div>
|
||||
|
||||
@if (TempData["OnboardingReviewMessage"] is string reviewMessage)
|
||||
@ -145,7 +145,7 @@
|
||||
<h2>Character candidates</h2>
|
||||
@if (!probableCharacters.Any() && !possibleCharacters.Any() && !relationshipTitles.Any())
|
||||
{
|
||||
<p>No repeated character names were found yet. You can still build the project structure and add characters later.</p>
|
||||
<p>No repeated character names were found yet. You can still prepare chapters and add characters later.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@ -1,68 +1,75 @@
|
||||
@model StoryIntelligenceOverviewViewModel
|
||||
@{
|
||||
ViewData["Title"] = "Story Intelligence";
|
||||
ViewData["Title"] = "Analyse manuscript";
|
||||
}
|
||||
|
||||
<section class="onboarding-shell" aria-labelledby="story-intelligence-title">
|
||||
<div class="onboarding-panel">
|
||||
<div class="onboarding-copy">
|
||||
<p class="eyebrow">Story Intelligence</p>
|
||||
<h1 id="story-intelligence-title">PlotDirector can now analyse your manuscript to automatically build much of your project.</h1>
|
||||
<p>This step is optional. In this phase, PlotDirector only prepares the analysis framework; no AI analysis will run yet.</p>
|
||||
<p class="eyebrow">@Model.SelectedBookTitle</p>
|
||||
<h1 id="story-intelligence-title">Analyse chapters with Story Intelligence</h1>
|
||||
<p>PlotDirector will read the approved chapter text, detect scene boundaries, and prepare scenes for your review before anything is created.</p>
|
||||
</div>
|
||||
|
||||
@if (Model.ExistingJob?.IsActive == true)
|
||||
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
|
||||
{
|
||||
<section class="onboarding-story-card">
|
||||
<h2>Story Intelligence is already preparing</h2>
|
||||
<p>@Model.ExistingJob.CurrentMessage</p>
|
||||
<a class="btn btn-primary" asp-action="StoryIntelligenceProgress" asp-route-jobId="@Model.ExistingJob.JobID">View progress</a>
|
||||
</section>
|
||||
<div class="alert alert-danger">@error</div>
|
||||
}
|
||||
else if (Model.ExistingJob?.IsCompleted == true)
|
||||
@if (TempData["OnboardingStoryIntelligenceMessage"] is string message)
|
||||
{
|
||||
<div class="alert alert-success">@message</div>
|
||||
}
|
||||
|
||||
@if (Model.ExistingBatchID.HasValue)
|
||||
{
|
||||
<section class="onboarding-story-card">
|
||||
<h2>Story Intelligence is ready</h2>
|
||||
<p>The framework has already been prepared for this manuscript.</p>
|
||||
<a class="btn btn-primary" asp-action="StoryIntelligenceComplete" asp-route-jobId="@Model.ExistingJob.JobID">View completion</a>
|
||||
<h2>Analysis has already started</h2>
|
||||
<p>You can continue reviewing progress for the approved chapters.</p>
|
||||
<a class="btn btn-primary" asp-action="StoryIntelligenceProgress" asp-route-batchId="@Model.ExistingBatchID">Review scenes</a>
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="onboarding-story-facts">
|
||||
<div>
|
||||
<span>Optional</span>
|
||||
<strong>You stay in control</strong>
|
||||
<p>You can skip this now and continue using the project structure you already built.</p>
|
||||
<span>Chapters</span>
|
||||
<strong>@Model.ApprovedChapterCount</strong>
|
||||
<p>Approved chapters from your manuscript review.</p>
|
||||
</div>
|
||||
<div>
|
||||
<span>Uses AI later</span>
|
||||
<strong>Explicit consent required</strong>
|
||||
<p>Future manuscript analysis will only run after you choose to start it.</p>
|
||||
<span>Words</span>
|
||||
<strong>@Model.ApprovedWordCount.ToString("N0")</strong>
|
||||
<p>Chapter text ready for analysis.</p>
|
||||
</div>
|
||||
<div>
|
||||
<span>Privacy</span>
|
||||
<strong>OpenAI API content is not used to train AI models</strong>
|
||||
<p>Manuscript content is processed securely when AI analysis is enabled in a later phase.</p>
|
||||
<span>Creates</span>
|
||||
<strong>Scenes only</strong>
|
||||
<p>Characters, locations, assets and relationships will not be imported yet.</p>
|
||||
</div>
|
||||
<div>
|
||||
<span>Estimate</span>
|
||||
<strong>About 5-15 minutes</strong>
|
||||
<p>Credit use will be shown here before charging is enabled. Placeholder: included setup credits.</p>
|
||||
<span>Review</span>
|
||||
<strong>You approve creation</strong>
|
||||
<p>Scenes are created only after the analysis completes and you choose Create scenes.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Model.Message))
|
||||
{
|
||||
<section class="onboarding-form-section">
|
||||
<h2>Consent</h2>
|
||||
<p>By choosing Analyse my manuscript, you consent to PlotDirector preparing the Story Intelligence job for @Model.SelectedBookTitle in @Model.SelectedProjectName. This phase does not call OpenAI or analyse manuscript content.</p>
|
||||
<p>@Model.Message</p>
|
||||
@if (Model.MissingChapterTextCount > 0)
|
||||
{
|
||||
<p class="text-danger">@Model.MissingChapterTextCount chapter(s) do not include scanned text. Scan the manuscript again before analysing.</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
<div class="onboarding-actions">
|
||||
<form asp-action="SkipStoryIntelligence" method="post">
|
||||
<button class="btn btn-outline-secondary" type="submit">Skip for now</button>
|
||||
</form>
|
||||
<form asp-action="StartStoryIntelligence" method="post">
|
||||
<button class="btn btn-primary" type="submit" disabled="@(!Model.CanStart)">Analyse my manuscript</button>
|
||||
<button class="btn btn-primary" type="submit" disabled="@(!Model.CanStart)">Analyse manuscript</button>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
@model StoryIntelligenceCompletionViewModel
|
||||
@{
|
||||
ViewData["Title"] = "Story Intelligence ready";
|
||||
ViewData["Title"] = "Scenes created";
|
||||
}
|
||||
|
||||
<section class="onboarding-shell" aria-labelledby="story-complete-title">
|
||||
@ -9,38 +9,33 @@
|
||||
<p class="eyebrow">@Model.BookTitle</p>
|
||||
<div class="onboarding-success-heading">
|
||||
<span aria-hidden="true">✓</span>
|
||||
<h1 id="story-complete-title">Story Intelligence is ready.</h1>
|
||||
<h1 id="story-complete-title">Your scenes have been created.</h1>
|
||||
</div>
|
||||
<p>The analysis engine is prepared. In the next phase PlotDirector will begin analysing your manuscript.</p>
|
||||
<p>PlotDirector created scenes from the approved manuscript chapters. Characters, locations, assets and relationships were left untouched.</p>
|
||||
</div>
|
||||
|
||||
<section class="onboarding-complete-next">
|
||||
<h2>Temporary completion page</h2>
|
||||
<p>This page confirms the framework job, consent, background processing and progress plumbing are working. Later phases will replace it with real analysis results.</p>
|
||||
</section>
|
||||
|
||||
<div class="onboarding-scan-counts onboarding-review-counts">
|
||||
<div>
|
||||
<span>Status</span>
|
||||
<strong>@Model.Job.Status</strong>
|
||||
<span>Chapters</span>
|
||||
<strong>@Model.ChaptersCommitted</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Progress</span>
|
||||
<strong>@Model.Job.ProgressPercent%</strong>
|
||||
<span>Scenes created</span>
|
||||
<strong>@Model.ScenesCreated</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Elapsed</span>
|
||||
<strong>@Model.Job.ElapsedTime</strong>
|
||||
<span>Project</span>
|
||||
<strong>@Model.ProjectName</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Stage</span>
|
||||
<strong>@Model.Job.CurrentStage</strong>
|
||||
<span>Book</span>
|
||||
<strong>@Model.BookTitle</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="onboarding-actions">
|
||||
<a class="btn btn-primary" asp-controller="Projects" asp-action="Details" asp-route-id="@Model.ProjectID">Open Project Overview</a>
|
||||
<a class="btn btn-outline-primary" asp-controller="Writer" asp-action="Index" asp-route-projectId="@Model.ProjectID">Open Writer Workspace</a>
|
||||
<a class="btn btn-primary" asp-controller="Writer" asp-action="Index" asp-route-projectId="@Model.ProjectID">Open Writer Workspace</a>
|
||||
<a class="btn btn-outline-primary" asp-controller="Projects" asp-action="Details" asp-route-id="@Model.ProjectID">Open Project Overview</a>
|
||||
<a class="btn btn-outline-secondary" asp-controller="Projects" asp-action="Index">Back to Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,60 +1,170 @@
|
||||
@model StoryIntelligenceProgressViewModel
|
||||
@{
|
||||
ViewData["Title"] = "Preparing Story Intelligence";
|
||||
ViewData["Title"] = "Review scenes";
|
||||
}
|
||||
|
||||
<section class="onboarding-shell" aria-labelledby="story-progress-title">
|
||||
<div class="onboarding-panel"
|
||||
data-story-intelligence-progress
|
||||
data-story-intelligence-job-id="@Model.Job.JobID">
|
||||
<div class="onboarding-panel onboarding-review-panel">
|
||||
@if (Model.HasActiveRuns)
|
||||
{
|
||||
<meta http-equiv="refresh" content="10" />
|
||||
}
|
||||
|
||||
<div class="onboarding-copy">
|
||||
<p class="eyebrow">@Model.BookTitle</p>
|
||||
<h1 id="story-progress-title">Preparing Story Intelligence</h1>
|
||||
<p>PlotDirector is preparing the analysis engine. This phase does not run AI analysis or generate story suggestions.</p>
|
||||
<h1 id="story-progress-title">Review scenes</h1>
|
||||
<p>PlotDirector is analysing the approved chapters. When a chapter is ready, you can create its scenes in PlotDirector.</p>
|
||||
</div>
|
||||
|
||||
<section class="onboarding-story-progress" aria-label="Story Intelligence progress">
|
||||
<div class="onboarding-scan-status">
|
||||
<span data-story-intelligence-message>@Model.Job.CurrentMessage</span>
|
||||
<strong data-story-intelligence-percent>@Model.Job.ProgressPercent%</strong>
|
||||
</div>
|
||||
<div class="onboarding-scan-progress" aria-hidden="true">
|
||||
<span data-story-intelligence-progress-bar style="width:@Model.Job.ProgressPercent%"></span>
|
||||
</div>
|
||||
<div class="onboarding-scan-counts">
|
||||
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
|
||||
{
|
||||
<div class="alert alert-danger">@error</div>
|
||||
}
|
||||
@if (TempData["OnboardingStoryIntelligenceMessage"] is string message)
|
||||
{
|
||||
<div class="alert alert-success">@message</div>
|
||||
}
|
||||
|
||||
<div class="onboarding-scan-counts onboarding-review-counts">
|
||||
<div>
|
||||
<span>Stage</span>
|
||||
<strong data-story-intelligence-stage>@Model.Job.CurrentStage</strong>
|
||||
<span>Chapters</span>
|
||||
<strong>@Model.CompletedChapterCount / @Model.ChapterCount</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Scenes detected</span>
|
||||
<strong>@Model.TotalDetectedScenes</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Scenes analysed</span>
|
||||
<strong>@Model.TotalCompletedScenes</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Failures</span>
|
||||
<strong>@(Model.FailedChapterCount + Model.TotalFailedScenes)</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="onboarding-review-grid" aria-label="Chapter analysis progress">
|
||||
<div class="onboarding-review-section">
|
||||
<h2>Chapters</h2>
|
||||
<div class="onboarding-review-chapters">
|
||||
@foreach (var chapter in Model.Chapters.OrderBy(chapter => chapter.ChapterNumber))
|
||||
{
|
||||
<details class="onboarding-review-chapter" open>
|
||||
<summary>
|
||||
<span>@chapter.ChapterNumber. @chapter.ChapterTitle</span>
|
||||
<strong>@ChapterStatusLabel(chapter)</strong>
|
||||
</summary>
|
||||
<div class="onboarding-scan-counts onboarding-review-counts">
|
||||
<div>
|
||||
<span>Status</span>
|
||||
<strong data-story-intelligence-status>@Model.Job.Status</strong>
|
||||
<strong>@chapter.Status</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Elapsed</span>
|
||||
<strong data-story-intelligence-elapsed>@Model.Job.ElapsedTime</strong>
|
||||
<span>Scenes</span>
|
||||
<strong>@((chapter.CompletedScenes ?? 0).ToString("N0")) / @((chapter.TotalDetectedScenes ?? 0).ToString("N0"))</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Remaining</span>
|
||||
<strong data-story-intelligence-remaining>@(Model.Job.EstimatedRemaining ?? "Calculating")</strong>
|
||||
<span>Failed</span>
|
||||
<strong>@((chapter.FailedScenes ?? 0).ToString("N0"))</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Tokens</span>
|
||||
<strong>@(chapter.TotalTokens?.ToString("N0") ?? "-")</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(chapter.CurrentMessage))
|
||||
{
|
||||
<p>@chapter.CurrentMessage</p>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(chapter.ErrorMessage))
|
||||
{
|
||||
<p class="text-danger">@chapter.ErrorMessage</p>
|
||||
}
|
||||
@if (chapter.Warnings.Any())
|
||||
{
|
||||
<ul>
|
||||
@foreach (var warning in chapter.Warnings)
|
||||
{
|
||||
<li>@warning</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
@if (chapter.Blockers.Any())
|
||||
{
|
||||
<ul>
|
||||
@foreach (var blocker in chapter.Blockers)
|
||||
{
|
||||
<li class="text-danger">@blocker</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
<div class="onboarding-actions">
|
||||
@if (chapter.HasCompletedCommit)
|
||||
{
|
||||
<span class="btn btn-outline-secondary disabled">@chapter.ScenesCreated scene(s) created</span>
|
||||
}
|
||||
else if (chapter.CanCommit && Model.BatchID.HasValue)
|
||||
{
|
||||
<form asp-action="CommitStoryIntelligence" method="post">
|
||||
<input type="hidden" name="batchId" value="@Model.BatchID" />
|
||||
<input type="hidden" name="runId" value="@chapter.RunID" />
|
||||
<button class="btn btn-primary" type="submit">Create scenes</button>
|
||||
</form>
|
||||
}
|
||||
else if (chapter.IsRunComplete)
|
||||
{
|
||||
<span class="btn btn-outline-secondary disabled">Review needed</span>
|
||||
}
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="onboarding-actions">
|
||||
@if (Model.BatchID.HasValue)
|
||||
{
|
||||
<form asp-action="CancelStoryIntelligence" method="post">
|
||||
<input type="hidden" name="jobId" value="@Model.Job.JobID" />
|
||||
<button class="btn btn-outline-secondary" type="submit" disabled="@(!Model.Job.IsActive)">Cancel</button>
|
||||
<input type="hidden" name="batchId" value="@Model.BatchID" />
|
||||
<button class="btn btn-outline-secondary" type="submit" disabled="@(!Model.HasActiveRuns)">Cancel analysis</button>
|
||||
</form>
|
||||
<a class="btn btn-primary @(Model.Job.IsCompleted ? string.Empty : "disabled")"
|
||||
data-story-intelligence-complete-link
|
||||
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceProgress" asp-route-batchId="@Model.BatchID">Refresh</a>
|
||||
<a class="btn btn-primary @(Model.AllCommitted ? string.Empty : "disabled")"
|
||||
asp-action="StoryIntelligenceComplete"
|
||||
asp-route-jobId="@Model.Job.JobID"
|
||||
aria-disabled="@(!Model.Job.IsCompleted)">Continue</a>
|
||||
asp-route-batchId="@Model.BatchID"
|
||||
aria-disabled="@(!Model.AllCommitted)">Continue</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/story-intelligence-progress.js" asp-append-version="true"></script>
|
||||
@functions {
|
||||
private static string ChapterStatusLabel(StoryIntelligenceOnboardingChapterViewModel chapter)
|
||||
{
|
||||
if (chapter.HasCompletedCommit)
|
||||
{
|
||||
return "Scenes created";
|
||||
}
|
||||
|
||||
if (chapter.CanCommit)
|
||||
{
|
||||
return "Ready to create";
|
||||
}
|
||||
|
||||
if (chapter.IsActive)
|
||||
{
|
||||
return "Analysing";
|
||||
}
|
||||
|
||||
if (chapter.IsFailed)
|
||||
{
|
||||
return "Needs attention";
|
||||
}
|
||||
|
||||
return "Reviewing";
|
||||
}
|
||||
}
|
||||
|
||||
@ -70,11 +70,11 @@
|
||||
</div>
|
||||
@if (Model.StoryIntelligence.Job?.IsActive == true)
|
||||
{
|
||||
<a class="btn btn-primary" asp-controller="Onboarding" asp-action="StoryIntelligenceProgress" asp-route-jobId="@Model.StoryIntelligence.Job.JobID">@Model.StoryIntelligence.ButtonText</a>
|
||||
<a class="btn btn-primary" asp-controller="Onboarding" asp-action="StoryIntelligence">@Model.StoryIntelligence.ButtonText</a>
|
||||
}
|
||||
else if (Model.StoryIntelligence.Job?.IsCompleted == true)
|
||||
{
|
||||
<a class="btn btn-primary" asp-controller="Onboarding" asp-action="StoryIntelligenceComplete" asp-route-jobId="@Model.StoryIntelligence.Job.JobID">@Model.StoryIntelligence.ButtonText</a>
|
||||
<a class="btn btn-primary" asp-controller="Onboarding" asp-action="StoryIntelligence">@Model.StoryIntelligence.ButtonText</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@ -1932,6 +1932,8 @@
|
||||
chapterNumber: chapters.length + 1,
|
||||
title,
|
||||
wordCount: 0,
|
||||
chapterTextParagraphs: [],
|
||||
chapterText: "",
|
||||
startPosition: paragraph?.index ?? null,
|
||||
existingChapterID: isOpening ? null : paragraphAnchorId(paragraph, "PD-CHAPTER")
|
||||
};
|
||||
@ -1966,6 +1968,7 @@
|
||||
}
|
||||
|
||||
chapter.wordCount += words;
|
||||
chapter.chapterTextParagraphs.push(text);
|
||||
scene.wordCount += words;
|
||||
if (!scene.openingTextPreview && text && !sceneSeparatorTexts.has(text)) {
|
||||
scene.openingTextPreview = text.length > 180 ? `${text.slice(0, 177)}...` : text;
|
||||
@ -1987,6 +1990,7 @@
|
||||
startChapter(paragraph, text);
|
||||
totalWordCount += words;
|
||||
chapter.wordCount += words;
|
||||
chapter.chapterTextParagraphs.push(text);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -2018,6 +2022,13 @@
|
||||
throw new Error("No chapters were detected. Make sure your chapter headings use Heading 1, or add clear chapter headings.");
|
||||
}
|
||||
|
||||
chapters.forEach((item) => {
|
||||
item.chapterText = Array.isArray(item.chapterTextParagraphs)
|
||||
? item.chapterTextParagraphs.join("\n\n")
|
||||
: "";
|
||||
delete item.chapterTextParagraphs;
|
||||
});
|
||||
|
||||
const documentTextForDiscovery = documentText.join("\n");
|
||||
return {
|
||||
previewID: "00000000-0000-0000-0000-000000000000",
|
||||
|
||||
26
PlotLine/wwwroot/js/word-companion-host.min.js
vendored
26
PlotLine/wwwroot/js/word-companion-host.min.js
vendored
File diff suppressed because one or more lines are too long
@ -149,7 +149,7 @@
|
||||
connection.on("OnboardingScanCompleted", applyScanState);
|
||||
connection.on("OnboardingScanFailed", applyScanState);
|
||||
connection.on("OnboardingBuildProgress", (state) => {
|
||||
const message = field(state, "message", "Message") || "Building project structure...";
|
||||
const message = field(state, "message", "Message") || "Preparing chapters...";
|
||||
const percent = field(state, "percentComplete", "PercentComplete");
|
||||
const safePercent = Math.max(0, Math.min(100, Number.parseInt(percent || "0", 10) || 0));
|
||||
document.querySelectorAll("[data-onboarding-build-panel]").forEach((node) => {
|
||||
@ -207,7 +207,7 @@
|
||||
node.classList.add("is-running");
|
||||
});
|
||||
document.querySelectorAll("[data-onboarding-build-message]").forEach((node) => {
|
||||
node.textContent = "Creating the approved project structure...";
|
||||
node.textContent = "Preparing the approved chapters...";
|
||||
});
|
||||
document.querySelectorAll("[data-onboarding-build-percent]").forEach((node) => {
|
||||
node.textContent = "0%";
|
||||
@ -225,7 +225,7 @@
|
||||
node.classList.remove("is-running");
|
||||
});
|
||||
document.querySelectorAll("[data-onboarding-build-message]").forEach((node) => {
|
||||
node.textContent = error?.message || "The project structure could not be built. Check the review and try again.";
|
||||
node.textContent = error?.message || "The approved chapters could not be prepared. Check the review and try again.";
|
||||
});
|
||||
document.querySelectorAll("[data-onboarding-build-percent]").forEach((node) => {
|
||||
node.textContent = "";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user