727 lines
30 KiB
C#
727 lines
30 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Text.Json;
|
|
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<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAllAsync(Guid batchId);
|
|
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportCharactersAsync(Guid batchId, StoryIntelligenceCharacterImportForm form);
|
|
Task<StoryIntelligenceCharacterImportResultViewModel?> GetCharacterImportResultAsync(Guid batchId);
|
|
Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId);
|
|
Task<OnboardingStoryIntelligenceResumeTarget?> ResumeBookAsync(int bookId);
|
|
}
|
|
|
|
public sealed class OnboardingStoryIntelligenceService(
|
|
IOnboardingRepository onboardingRepository,
|
|
IOnboardingService onboarding,
|
|
IManuscriptScanPreviewStore scanStore,
|
|
IStoryIntelligenceResultRepository runs,
|
|
IStoryIntelligenceImportCommitService commits,
|
|
IStoryIntelligenceCharacterImportService characterImport,
|
|
IStoryIntelligencePipelineStateService pipelineState,
|
|
IStoryIntelligenceClient client,
|
|
IOnboardingStoryIntelligenceBatchStore batchStore,
|
|
IStoryIntelligenceProgressNotifier notifier,
|
|
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
|
|
});
|
|
|
|
await notifier.PublishAsync(new StoryIntelligenceRunProgressEvent
|
|
{
|
|
RunID = runId,
|
|
UserID = userId,
|
|
ProjectID = preview.ProjectID,
|
|
BookID = preview.BookID,
|
|
ChapterID = chapterId,
|
|
ChapterNumber = chapter.Decision.ChapterNumber,
|
|
Status = StoryIntelligenceRunStatuses.Pending,
|
|
CurrentStage = "Queued",
|
|
CurrentMessage = $"{chapter.Decision.Title} is queued for analysis.",
|
|
EventType = "Import started",
|
|
ElapsedTime = "0 sec",
|
|
EstimatedRemaining = "Calculating",
|
|
IsActive = true
|
|
});
|
|
}
|
|
|
|
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;
|
|
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
|
|
|
|
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,
|
|
SourceWordCount = run.SourceWordCount,
|
|
TotalTokens = run.TotalTokens,
|
|
TotalDurationMs = run.TotalDurationMs,
|
|
EstimatedCostUSD = run.EstimatedCostUSD,
|
|
EstimatedCostGBP = run.EstimatedCostGBP,
|
|
FailureStage = run.FailureStage,
|
|
ErrorMessage = run.ErrorMessage,
|
|
LatestSceneSummary = LatestSceneSummary(sceneResults),
|
|
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 ?? [],
|
|
Scenes = confirmation is null
|
|
? []
|
|
: BuildScenePreviews(confirmation.Scenes)
|
|
});
|
|
}
|
|
|
|
var characterReview = await characterImport.BuildReviewAsync(batch);
|
|
return new StoryIntelligenceProgressViewModel
|
|
{
|
|
BatchID = batch.BatchID,
|
|
PreviewID = batch.PreviewID,
|
|
ProjectID = batch.ProjectID,
|
|
BookID = batch.BookID,
|
|
ProjectName = batch.ProjectName,
|
|
BookTitle = batch.BookTitle,
|
|
Chapters = chapters,
|
|
CharacterReview = characterReview,
|
|
PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel
|
|
{
|
|
ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete),
|
|
ScenesImported = chapters.Sum(chapter => chapter.ScenesCreated),
|
|
CharactersIdentified = characterReview.Candidates.Count + batch.CharacterDecisions.Count,
|
|
CharactersCreatedOrLinked = batch.CharacterDecisions.Count(decision => decision.CreatedOrLinked),
|
|
CharacterStageComplete = batch.CharacterStageComplete || characterReview.IsComplete
|
|
}
|
|
};
|
|
}
|
|
|
|
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<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAllAsync(Guid batchId)
|
|
{
|
|
var progress = await GetProgressAsync(batchId);
|
|
if (progress is null)
|
|
{
|
|
return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." });
|
|
}
|
|
|
|
var userId = RequireUserId();
|
|
var created = 0;
|
|
foreach (var chapter in progress.Chapters.OrderBy(chapter => chapter.ChapterNumber).Where(chapter => chapter.CanCommit))
|
|
{
|
|
var result = await commits.CommitAsync(chapter.RunID, userId);
|
|
if (!result.Success)
|
|
{
|
|
return (await GetProgressAsync(batchId), new StoryIntelligenceImportCommitResult
|
|
{
|
|
Success = false,
|
|
Message = $"Scene creation stopped. Chapter {chapter.ChapterNumber:N0} could not be imported. Reason: {result.Message}",
|
|
ScenesCreated = created
|
|
});
|
|
}
|
|
|
|
created += result.ScenesCreated;
|
|
}
|
|
|
|
await onboarding.MarkCompletedAsync();
|
|
return (await GetProgressAsync(batchId), new StoryIntelligenceImportCommitResult
|
|
{
|
|
Success = true,
|
|
ScenesCreated = created,
|
|
Message = $"Scenes successfully created. {created:N0} scene(s) added to PlotDirector."
|
|
});
|
|
}
|
|
|
|
public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportCharactersAsync(Guid batchId, StoryIntelligenceCharacterImportForm form)
|
|
{
|
|
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
|
|
if (batch is null)
|
|
{
|
|
return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." });
|
|
}
|
|
|
|
var result = await characterImport.ImportAsync(batch, form);
|
|
return (await GetProgressAsync(batchId), result);
|
|
}
|
|
|
|
public async Task<StoryIntelligenceCharacterImportResultViewModel?> GetCharacterImportResultAsync(Guid batchId)
|
|
{
|
|
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
|
|
if (batch is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return new StoryIntelligenceCharacterImportResultViewModel
|
|
{
|
|
BatchID = batch.BatchID,
|
|
ProjectID = batch.ProjectID,
|
|
BookID = batch.BookID,
|
|
CharactersCreated = batch.LastCharacterImportResult?.CharactersCreated ?? 0,
|
|
CharactersLinked = batch.LastCharacterImportResult?.CharactersLinked ?? 0,
|
|
CharactersIgnored = batch.LastCharacterImportResult?.CharactersIgnored ?? 0,
|
|
PovLinksResolved = batch.LastCharacterImportResult?.PovLinksResolved ?? 0,
|
|
ScenePeoplePanelsUpdated = batch.LastCharacterImportResult?.ScenePeoplePanelsUpdated ?? 0
|
|
};
|
|
}
|
|
|
|
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,
|
|
FirstChapterID = progress.Chapters.OrderBy(chapter => chapter.ChapterNumber).FirstOrDefault()?.ChapterID,
|
|
ProjectName = progress.ProjectName,
|
|
BookTitle = progress.BookTitle,
|
|
ChaptersCommitted = progress.CommittedChapterCount,
|
|
ScenesCreated = progress.Chapters.Sum(chapter => chapter.ScenesCreated),
|
|
CharactersCreatedOrLinked = progress.PipelineDashboard.CharactersCreatedOrLinked,
|
|
CharacterStageComplete = progress.PipelineDashboard.CharacterStageComplete
|
|
};
|
|
}
|
|
|
|
public async Task<OnboardingStoryIntelligenceResumeTarget?> ResumeBookAsync(int bookId)
|
|
{
|
|
var userId = RequireUserId();
|
|
var state = await pipelineState.GetForBookAsync(bookId, userId);
|
|
if (state is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var committedRuns = await pipelineState.ListCommittedRunsByBookAsync(bookId, userId);
|
|
if (committedRuns.Count == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var batch = new OnboardingStoryIntelligenceBatch
|
|
{
|
|
UserID = userId,
|
|
OnboardingID = 0,
|
|
PreviewID = Guid.Empty,
|
|
ProjectID = state.ProjectID,
|
|
BookID = state.BookID,
|
|
ProjectName = state.ProjectName,
|
|
BookTitle = state.BookDisplayTitle,
|
|
Items = committedRuns
|
|
.OrderBy(run => run.ChapterNumber)
|
|
.ThenBy(run => run.StoryIntelligenceRunID)
|
|
.Select(run => new OnboardingStoryIntelligenceBatchItem
|
|
{
|
|
TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID}",
|
|
ChapterNumber = Convert.ToInt32(run.ChapterNumber),
|
|
ChapterTitle = run.ChapterTitle,
|
|
ChapterID = run.ChapterID,
|
|
RunID = run.StoryIntelligenceRunID
|
|
})
|
|
.ToList()
|
|
};
|
|
|
|
if (state.IsComplete)
|
|
{
|
|
batch.CharacterStageComplete = true;
|
|
}
|
|
|
|
await batchStore.SaveAsync(batch);
|
|
|
|
var route = state.CurrentStage switch
|
|
{
|
|
StoryIntelligencePipelineStages.Complete => StoryIntelligenceResumeRoutes.Complete,
|
|
StoryIntelligencePipelineStages.CharacterReview => StoryIntelligenceResumeRoutes.Characters,
|
|
StoryIntelligencePipelineStages.CharacterImport => StoryIntelligenceResumeRoutes.Characters,
|
|
StoryIntelligencePipelineStages.SceneReview => StoryIntelligenceResumeRoutes.SceneReview,
|
|
StoryIntelligencePipelineStages.SceneImport => StoryIntelligenceResumeRoutes.SceneReview,
|
|
_ => StoryIntelligenceResumeRoutes.SceneReview
|
|
};
|
|
|
|
return new OnboardingStoryIntelligenceResumeTarget(batch.BatchID, route);
|
|
}
|
|
|
|
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 static string? LatestSceneSummary(IReadOnlyList<StoryIntelligenceSavedSceneResult> sceneResults)
|
|
{
|
|
foreach (var scene in sceneResults
|
|
.Where(scene => scene.ValidationErrorsCount == 0 && !string.IsNullOrWhiteSpace(scene.ParsedJson))
|
|
.OrderByDescending(scene => scene.TemporarySceneNumber))
|
|
{
|
|
var summary = ExtractShortSummary(scene.ParsedJson);
|
|
if (!string.IsNullOrWhiteSpace(summary))
|
|
{
|
|
return Trim(summary, 220);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static IReadOnlyList<StoryIntelligenceOnboardingScenePreviewViewModel> BuildScenePreviews(IReadOnlyList<StoryIntelligenceSceneImportItem> importScenes)
|
|
=> importScenes
|
|
.OrderBy(scene => scene.TemporarySceneNumber)
|
|
.Select(scene => new StoryIntelligenceOnboardingScenePreviewViewModel
|
|
{
|
|
SceneNumber = scene.TemporarySceneNumber,
|
|
Summary = Trim(FirstConfigured(scene.ParsedScene.Summary?.Short, scene.ParsedScene.Summary?.Detailed, $"Scene {scene.TemporarySceneNumber:N0}")!, 180),
|
|
Pov = scene.PovCharacterID.HasValue ? FirstConfigured(scene.ParsedScene.PointOfView?.CharacterName, scene.ParsedScene.PointOfView?.NarrativeMode) : null,
|
|
Setting = BuildSetting(scene.ParsedScene.Setting),
|
|
Confidence = scene.ParsedScene.Summary?.Confidence ?? scene.ParsedScene.Setting?.Confidence,
|
|
HasWarnings = false
|
|
})
|
|
.ToList();
|
|
|
|
private static SceneIntelligenceScene? TryReadScene(string json)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(json))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
try
|
|
{
|
|
var direct = JsonSerializer.Deserialize<SceneIntelligenceScene>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
|
if (!string.IsNullOrWhiteSpace(direct?.SchemaVersion))
|
|
{
|
|
return direct;
|
|
}
|
|
|
|
using var document = JsonDocument.Parse(json);
|
|
if (document.RootElement.TryGetProperty("parsedScene", out var parsedScene))
|
|
{
|
|
return parsedScene.Deserialize<SceneIntelligenceScene>(new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
|
}
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static string? BuildSetting(SceneIntelligenceSetting? setting)
|
|
=> setting is null
|
|
? null
|
|
: FirstConfigured(
|
|
setting.LocationName,
|
|
setting.GenericRoomType,
|
|
setting.LocationType,
|
|
setting.DateOrTimeReference,
|
|
setting.TimeOfDay);
|
|
|
|
private static string? FirstConfigured(params string?[] values)
|
|
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim();
|
|
|
|
private static string? ExtractShortSummary(string json)
|
|
{
|
|
try
|
|
{
|
|
using var document = JsonDocument.Parse(json);
|
|
var root = document.RootElement;
|
|
if (TryReadShortSummary(root, out var direct))
|
|
{
|
|
return direct;
|
|
}
|
|
|
|
if (root.TryGetProperty("parsedScene", out var parsedScene)
|
|
&& TryReadShortSummary(parsedScene, out var wrapped))
|
|
{
|
|
return wrapped;
|
|
}
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static bool TryReadShortSummary(JsonElement element, out string? summary)
|
|
{
|
|
summary = null;
|
|
if (element.ValueKind != JsonValueKind.Object
|
|
|| !element.TryGetProperty("summary", out var summaryElement)
|
|
|| summaryElement.ValueKind != JsonValueKind.Object)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (TryReadSummaryValue(summaryElement, "short", out summary))
|
|
{
|
|
return !string.IsNullOrWhiteSpace(summary);
|
|
}
|
|
|
|
return TryReadSummaryValue(summaryElement, "detailed", out summary)
|
|
&& !string.IsNullOrWhiteSpace(summary);
|
|
}
|
|
|
|
private static bool TryReadSummaryValue(JsonElement summaryElement, string propertyName, out string? value)
|
|
{
|
|
value = null;
|
|
if (summaryElement.TryGetProperty(propertyName, out var textElement)
|
|
&& textElement.ValueKind == JsonValueKind.String)
|
|
{
|
|
value = textElement.GetString();
|
|
return !string.IsNullOrWhiteSpace(value);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static string Trim(string value, int maxLength)
|
|
{
|
|
var trimmed = value.Trim();
|
|
return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength].TrimEnd();
|
|
}
|
|
|
|
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);
|
|
Task<OnboardingStoryIntelligenceBatch?> GetLatestActiveForUserAsync(int userId);
|
|
}
|
|
|
|
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 Task<OnboardingStoryIntelligenceBatch?> GetLatestActiveForUserAsync(int userId)
|
|
=> Task.FromResult(batches.Values
|
|
.Where(batch => batch.UserID == userId)
|
|
.OrderByDescending(batch => batch.CreatedUtc)
|
|
.FirstOrDefault(batch => batch.Items.Count > 0));
|
|
}
|
|
|
|
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 List<OnboardingStoryIntelligenceCharacterDecision> CharacterDecisions { get; } = [];
|
|
public StoryIntelligenceCharacterImportBatchResult? LastCharacterImportResult { get; set; }
|
|
public bool CharacterStageComplete { get; set; }
|
|
}
|
|
|
|
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; }
|
|
}
|
|
|
|
public sealed class OnboardingStoryIntelligenceCharacterDecision
|
|
{
|
|
public string Key { get; init; } = string.Empty;
|
|
public string Action { get; init; } = string.Empty;
|
|
public string? CharacterName { get; init; }
|
|
public int? CharacterID { get; init; }
|
|
public bool CreatedOrLinked { get; init; }
|
|
}
|
|
|
|
public sealed class StoryIntelligenceCharacterImportBatchResult
|
|
{
|
|
public int CharactersCreated { get; init; }
|
|
public int CharactersLinked { get; init; }
|
|
public int CharactersIgnored { get; init; }
|
|
public int PovLinksResolved { get; init; }
|
|
public int ScenePeoplePanelsUpdated { get; init; }
|
|
}
|
|
|
|
public static class StoryIntelligenceResumeRoutes
|
|
{
|
|
public const string SceneReview = "SceneReview";
|
|
public const string Characters = "Characters";
|
|
public const string Complete = "Complete";
|
|
}
|
|
|
|
public sealed record OnboardingStoryIntelligenceResumeTarget(Guid BatchID, string Route);
|