PlotDirector/PlotLine/Services/OnboardingStoryIntelligenceService.cs

1893 lines
89 KiB
C#

using System.Collections.Concurrent;
using System.Diagnostics;
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(string? appearancePreference = null, string? primaryAgeGroup = null, string? storyEra = null, string? storyLocation = null);
Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid batchId);
Task<StoryIntelligenceProgressViewModel?> GetReviewCentreAsync(Guid batchId);
Task<StoryIntelligenceProgressViewModel?> GetCharacterReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25);
Task<StoryIntelligenceProgressViewModel?> GetLocationReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25);
Task<StoryIntelligenceProgressViewModel?> GetAssetReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25);
Task<StoryIntelligenceProgressViewModel?> GetRelationshipReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25);
Task<StoryIntelligenceProgressViewModel?> GetKnowledgeReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25);
Task<StoryIntelligenceProgressViewModel?> GetNarrativeReviewAsync(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<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportLocationsAsync(Guid batchId, StoryIntelligenceLocationImportForm form);
Task<StoryIntelligenceLocationImportResultViewModel?> GetLocationImportResultAsync(Guid batchId);
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportAssetsAsync(Guid batchId, StoryIntelligenceAssetImportForm form);
Task<StoryIntelligenceAssetImportResultViewModel?> GetAssetImportResultAsync(Guid batchId);
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportRelationshipsAsync(Guid batchId, StoryIntelligenceRelationshipImportForm form);
Task<StoryIntelligenceRelationshipImportResultViewModel?> GetRelationshipImportResultAsync(Guid batchId);
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportKnowledgeAsync(Guid batchId, StoryIntelligenceKnowledgeImportForm form);
Task<StoryIntelligenceKnowledgeImportResultViewModel?> GetKnowledgeImportResultAsync(Guid batchId);
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportNarrativeAsync(Guid batchId, StoryIntelligenceNarrativeImportForm form);
Task<StoryIntelligenceNarrativeImportResultViewModel?> GetNarrativeImportResultAsync(Guid batchId);
Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId);
Task<OnboardingStoryIntelligenceResumeTarget?> ResumeBookAsync(int bookId);
Task<OnboardingStoryIntelligenceBatch?> StartBookAsync(int bookId);
}
public sealed class OnboardingStoryIntelligenceService(
IOnboardingRepository onboardingRepository,
IOnboardingService onboarding,
IManuscriptScanPreviewStore scanStore,
IStoryIntelligenceResultRepository runs,
IStoryIntelligenceImportCommitService commits,
IStoryIntelligenceCharacterImportService characterImport,
IStoryIntelligenceLocationImportService locationImport,
IStoryIntelligenceAssetImportService assetImport,
IStoryIntelligenceRelationshipImportService relationshipImport,
IStoryIntelligenceKnowledgeImportService knowledgeImport,
IStoryIntelligenceNarrativeReviewService narrativeReview,
IWholeBookPlotIntelligenceService wholeBookPlotIntelligence,
ICharacterEnrichmentService characterEnrichment,
IStoryIntelligenceSourceRepository sources,
IStoryIntelligencePipelineStateService pipelineState,
IStoryIntelligenceReviewCandidateService reviewCandidates,
IStoryMemoryRepository storyMemory,
IBookRepository books,
IChapterRepository chapters,
ICharacterRepository characters,
IStoryIntelligenceClient client,
IOnboardingStoryIntelligenceBatchStore batchStore,
IStoryIntelligenceProgressNotifier notifier,
ICurrentUserService currentUser,
IStoryIntelligenceEntitlementService entitlement,
ILogger<OnboardingStoryIntelligenceService> logger) : IOnboardingStoryIntelligenceService
{
private const string PromptVersionsSummary = "Chapter=Chapter-Structure-Prompt-V2; Scene=Scene-Prompt-V2";
private const string SourceType = "OnboardingWordCompanionChapter";
private const string BookSourceType = "BookCanonicalSceneSource";
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));
const int storySummaryStep = 10;
const int storyContextStep = 10;
const int totalWizardSteps = 11;
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,
WizardCurrentStep = storySummaryStep,
WizardContextStep = storyContextStep,
WizardTotalSteps = totalWizardSteps,
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(string? appearancePreference = null, string? primaryAgeGroup = null, string? storyEra = null, string? storyLocation = null)
{
var userId = RequireUserId();
await entitlement.EnsureCanQueueAsync(userId);
var context = await GetContextAsync(userId);
if (context is null)
{
return null;
}
var (state, preview, review, build, wizard) = context.Value;
var book = await books.GetAsync(preview.BookID);
var existing = await batchStore.GetLatestForPreviewAsync(userId, preview.PreviewID);
if (existing is not null)
{
return existing;
}
if (build is null || build.SceneMappings.Count == 0)
{
throw new InvalidOperationException("Import the manuscript structure before starting Story Intelligence.");
}
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.");
}
var clientStatus = client.GetConfigurationStatus();
var modelSummary = StoryIntelligenceOptions.BuildModelSummary(
clientStatus.ChapterStructureModel,
clientStatus.SceneIntelligenceModel);
var batchItems = new List<OnboardingStoryIntelligenceBatchItem>();
var knownCharactersJson = await BuildCanonicalKnownCharacterContextJsonAsync(preview, review, build);
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 durableSource = await sources.GetChapterSourceAsync(chapterId);
var text = durableSource?.SourceText?.Trim();
if (string.IsNullOrWhiteSpace(text))
{
text = chapter.Preview.ChapterText?.Trim();
}
if (string.IsNullOrWhiteSpace(text))
{
throw new InvalidOperationException($"PlotDirector does not have persisted scene source text for chapter '{chapter.Decision.Title}'.");
}
var paragraphs = StoryIntelligenceParagraphs.Split(text);
var canonicalChapterTitle = CanonicalChapterTitle(await chapters.GetAsync(chapterId), chapter.Decision.ChapterNumber);
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}: {canonicalChapterTitle}",
ChapterText = text,
SourceFileName = preview.DocumentTitle,
SourceWordCount = durableSource?.SourceWordCount ?? CountWords(text),
SourceCharacterCount = durableSource?.SourceCharacterCount ?? text.Length,
SourceParagraphCount = paragraphs.Count,
SourceChapterCount = 1,
Model = modelSummary,
PromptVersionsSummary = PromptVersionsSummary,
KnownCharactersJson = knownCharactersJson
});
batchItems.Add(new OnboardingStoryIntelligenceBatchItem
{
TemporaryChapterKey = chapter.Preview.TemporaryChapterKey,
ChapterNumber = chapter.Decision.ChapterNumber,
ChapterTitle = canonicalChapterTitle,
ChapterID = chapterId,
RunID = runId
});
await pipelineState.RecordChapterAnalysisQueuedAsync(preview.ProjectID, preview.BookID, 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,
AppearancePreference = NormaliseAppearancePreference(appearancePreference),
PrimaryAgeGroup = CleanContext(primaryAgeGroup),
StoryEra = CleanContext(storyEra) is { Length: > 0 } suppliedEra
? suppliedEra
: CleanContext(BookContextOptions.StoryEraLabel(book?.StoryEra)),
StoryLocation = CleanContext(storyLocation) is { Length: > 0 } suppliedLocation
? suppliedLocation
: CleanContext(BookContextOptions.PrimaryLocaleLabel(book?.PrimaryLocaleCode)),
Items = batchItems
};
if (await pipelineState.GetForBookAsync(preview.BookID, userId) is { } pipeline)
{
await storyMemory.SetImportContextAsync(
pipeline.StoryIntelligenceBookPipelineID,
batch.AppearancePreference,
batch.PrimaryAgeGroup,
batch.StoryEra,
batch.StoryLocation);
}
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<OnboardingStoryIntelligenceBatch?> StartBookAsync(int bookId)
{
var userId = RequireUserId();
await entitlement.EnsureCanQueueAsync(userId);
var book = await books.GetAsync(bookId);
if (book is null)
{
return null;
}
var chapterRows = (await chapters.ListByBookAsync(bookId))
.Where(chapter => !chapter.IsArchived)
.OrderBy(chapter => chapter.SortOrder)
.ThenBy(chapter => chapter.ChapterNumber)
.ThenBy(chapter => chapter.ChapterID)
.ToList();
if (chapterRows.Count == 0)
{
throw new InvalidOperationException("Add or import chapters before analysing this book.");
}
var clientStatus = client.GetConfigurationStatus();
var modelSummary = StoryIntelligenceOptions.BuildModelSummary(
clientStatus.ChapterStructureModel,
clientStatus.SceneIntelligenceModel);
var knownCharactersJson = await BuildCanonicalKnownCharacterContextJsonAsync(book.ProjectID, new Dictionary<int, IReadOnlyList<int>>());
var batchItems = new List<OnboardingStoryIntelligenceBatchItem>();
foreach (var chapter in chapterRows)
{
var durableSource = await sources.GetChapterSourceAsync(chapter.ChapterID);
var text = durableSource?.SourceText?.Trim();
if (string.IsNullOrWhiteSpace(text))
{
continue;
}
var paragraphs = StoryIntelligenceParagraphs.Split(text);
var runId = await runs.QueueAdminTextAsync(new StoryIntelligenceRunQueueRequest
{
UserID = userId,
ProjectID = book.ProjectID,
BookID = book.BookID,
ChapterID = chapter.ChapterID,
ChapterNumber = chapter.ChapterNumber,
SourceType = BookSourceType,
SourceLabel = $"{book.BookDisplayTitle}: {CanonicalChapterTitle(chapter, chapter.ChapterNumber)}",
ChapterText = text,
SourceFileName = durableSource?.BookTitle ?? book.BookDisplayTitle,
SourceWordCount = durableSource?.SourceWordCount ?? CountWords(text),
SourceCharacterCount = durableSource?.SourceCharacterCount ?? text.Length,
SourceParagraphCount = paragraphs.Count,
SourceChapterCount = 1,
Model = modelSummary,
PromptVersionsSummary = PromptVersionsSummary,
KnownCharactersJson = knownCharactersJson
});
batchItems.Add(new OnboardingStoryIntelligenceBatchItem
{
TemporaryChapterKey = $"book-{book.BookID}-chapter-{chapter.ChapterID}",
ChapterNumber = Convert.ToInt32(chapter.ChapterNumber),
ChapterTitle = CanonicalChapterTitle(chapter, chapter.ChapterNumber),
ChapterID = chapter.ChapterID,
RunID = runId
});
await pipelineState.RecordChapterAnalysisQueuedAsync(book.ProjectID, book.BookID, runId);
}
if (batchItems.Count == 0)
{
throw new InvalidOperationException("PlotDirector could not find saved manuscript text for this book. Complete the core manuscript import before analysing it.");
}
var batch = new OnboardingStoryIntelligenceBatch
{
UserID = userId,
OnboardingID = 0,
PreviewID = Guid.Empty,
ProjectID = book.ProjectID,
BookID = book.BookID,
ProjectName = string.Empty,
BookTitle = book.BookDisplayTitle,
StoryEra = CleanContext(BookContextOptions.StoryEraLabel(book.StoryEra)),
StoryLocation = CleanContext(BookContextOptions.PrimaryLocaleLabel(book.PrimaryLocaleCode)),
Items = batchItems
};
await batchStore.SaveAsync(batch);
logger.LogInformation(
"Queued book Story Intelligence batch {BatchID}. UserID={UserID} ProjectID={ProjectID} BookID={BookID} Chapters={ChapterCount}",
batch.BatchID,
userId,
book.ProjectID,
book.BookID,
batchItems.Count);
return batch;
}
private static string NormaliseAppearancePreference(string? value)
=> value?.Trim() switch
{
StoryMemoryAppearancePreferences.ExplicitEvidenceOnly => StoryMemoryAppearancePreferences.ExplicitEvidenceOnly,
StoryMemoryAppearancePreferences.Balanced => StoryMemoryAppearancePreferences.Balanced,
StoryMemoryAppearancePreferences.PredominantlyLight => StoryMemoryAppearancePreferences.PredominantlyLight,
StoryMemoryAppearancePreferences.PredominantlyMedium => StoryMemoryAppearancePreferences.PredominantlyMedium,
StoryMemoryAppearancePreferences.PredominantlyDark => StoryMemoryAppearancePreferences.PredominantlyDark,
StoryMemoryAppearancePreferences.CustomMix => StoryMemoryAppearancePreferences.CustomMix,
_ => StoryMemoryAppearancePreferences.Balanced
};
private static string CleanContext(string? value)
{
var clean = value?.Trim();
return string.IsNullOrWhiteSpace(clean) ? string.Empty : clean[..Math.Min(clean.Length, 160)];
}
private async Task<string?> BuildCanonicalKnownCharacterContextJsonAsync(
ManuscriptScanPreview preview,
ManuscriptScanReviewDecision review,
OnboardingManuscriptBuildResult build)
{
if (build.SceneMappings.Count == 0)
{
return BuildKnownCharacterContextJson(preview, review);
}
var allCharacters = (await characters.ListCharactersAsync(preview.ProjectID))
.Where(character => !character.IsArchived)
.OrderBy(character => character.CharacterName, StringComparer.OrdinalIgnoreCase)
.ToList();
if (allCharacters.Count == 0)
{
return BuildKnownCharacterContextJson(preview, review);
}
var sceneIds = build.SceneMappings.Select(mapping => mapping.SceneID).Distinct().ToList();
var sceneIdsByCharacter = new Dictionary<int, HashSet<int>>();
foreach (var sceneId in sceneIds)
{
foreach (var sceneCharacter in await characters.ListSceneCharactersAsync(sceneId))
{
if (!sceneIdsByCharacter.TryGetValue(sceneCharacter.CharacterID, out var mappedScenes))
{
mappedScenes = [];
sceneIdsByCharacter[sceneCharacter.CharacterID] = mappedScenes;
}
mappedScenes.Add(sceneId);
}
}
var context = new List<StoryIntelligenceKnownCharacterContext>();
foreach (var character in allCharacters)
{
var aliases = (await characters.ListAliasesAsync(character.CharacterID))
.Select(alias => alias.Alias)
.Where(alias => !string.IsNullOrWhiteSpace(alias))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(alias => alias, StringComparer.OrdinalIgnoreCase)
.ToList();
var mappedScenes = sceneIdsByCharacter.GetValueOrDefault(character.CharacterID)?.OrderBy(id => id).ToList() ?? [];
context.Add(new StoryIntelligenceKnownCharacterContext
{
CanonicalName = character.CharacterName,
Aliases = aliases,
SexHint = CharacterSexHintService.Normalise(character.Sex, character.CharacterName),
ExistingCharacterID = character.CharacterID,
CandidateKeys = [],
SceneIDs = mappedScenes,
MentionCount = mappedScenes.Count,
Confidence = mappedScenes.Count > 0 ? 1m : 0.8m,
Source = "CanonicalCoreImport"
});
}
return JsonSerializer.Serialize(context, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
}
private async Task<string?> BuildCanonicalKnownCharacterContextJsonAsync(int projectId, IReadOnlyDictionary<int, IReadOnlyList<int>> sceneIdsByCharacter)
{
var allCharacters = (await characters.ListCharactersAsync(projectId))
.Where(character => !character.IsArchived)
.OrderBy(character => character.CharacterName, StringComparer.OrdinalIgnoreCase)
.ToList();
if (allCharacters.Count == 0)
{
return null;
}
var context = new List<StoryIntelligenceKnownCharacterContext>();
foreach (var character in allCharacters)
{
var aliases = (await characters.ListAliasesAsync(character.CharacterID))
.Select(alias => alias.Alias)
.Where(alias => !string.IsNullOrWhiteSpace(alias))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(alias => alias, StringComparer.OrdinalIgnoreCase)
.ToList();
var mappedScenes = sceneIdsByCharacter.GetValueOrDefault(character.CharacterID)?.OrderBy(id => id).ToList() ?? [];
context.Add(new StoryIntelligenceKnownCharacterContext
{
CanonicalName = character.CharacterName,
Aliases = aliases,
SexHint = CharacterSexHintService.Normalise(character.Sex, character.CharacterName),
ExistingCharacterID = character.CharacterID,
CandidateKeys = [],
SceneIDs = mappedScenes,
MentionCount = mappedScenes.Count,
Confidence = mappedScenes.Count > 0 ? 1m : 0.8m,
Source = "CanonicalBook"
});
}
return JsonSerializer.Serialize(context, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
}
public static string? BuildKnownCharacterContextJson(ManuscriptScanPreview preview, ManuscriptScanReviewDecision review)
{
var candidates = preview.CharacterCandidates.ToDictionary(candidate => candidate.TemporaryCharacterKey, StringComparer.Ordinal);
var decisions = review.Characters
.Where(character => character.Include && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase))
.ToDictionary(character => character.TemporaryCharacterKey, StringComparer.Ordinal);
var roots = decisions.Values
.Where(character => string.IsNullOrWhiteSpace(character.AliasTargetTemporaryCharacterKey))
.OrderBy(character => character.Name)
.ThenBy(character => character.TemporaryCharacterKey)
.ToList();
var context = new List<StoryIntelligenceKnownCharacterContext>();
foreach (var root in roots)
{
var aliasDecisions = decisions.Values
.Where(character => string.Equals(character.AliasTargetTemporaryCharacterKey, root.TemporaryCharacterKey, StringComparison.Ordinal))
.OrderBy(character => character.Name)
.ToList();
var names = new List<string>();
AddName(names, root.Name);
if (candidates.TryGetValue(root.TemporaryCharacterKey, out var rootCandidate))
{
AddName(names, rootCandidate.Name);
}
foreach (var alias in aliasDecisions)
{
AddName(names, alias.Name);
if (candidates.TryGetValue(alias.TemporaryCharacterKey, out var aliasCandidate))
{
AddName(names, aliasCandidate.Name);
}
}
var aliases = names
.Where(name => !string.Equals(name, root.Name, StringComparison.OrdinalIgnoreCase))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(name => name)
.ToList();
var allKeys = new[] { root.TemporaryCharacterKey }
.Concat(aliasDecisions.Select(alias => alias.TemporaryCharacterKey))
.ToList();
var mentionCount = allKeys
.Select(key => candidates.TryGetValue(key, out var candidate) ? candidate.MentionCount : 0)
.Sum();
var confidence = allKeys
.Select(key => candidates.TryGetValue(key, out var candidate) ? Math.Clamp(candidate.QualityScore / 100m, 0.1m, 1m) : 0.5m)
.DefaultIfEmpty(0.5m)
.Max();
var sexHint = CharacterSexHintService.Normalise(root.SexHint, root.Name);
if (string.Equals(sexHint, CharacterSexHintService.Unknown, StringComparison.Ordinal))
{
sexHint = aliasDecisions
.Select(alias => CharacterSexHintService.Normalise(alias.SexHint, alias.Name))
.FirstOrDefault(value => !string.Equals(value, CharacterSexHintService.Unknown, StringComparison.Ordinal))
?? CharacterSexHintService.Unknown;
}
context.Add(new StoryIntelligenceKnownCharacterContext
{
CanonicalName = root.Name,
Aliases = aliases,
SexHint = sexHint,
ExistingCharacterID = root.ExistingCharacterID,
CandidateKeys = allKeys,
MentionCount = mentionCount,
Confidence = confidence
});
}
return context.Count == 0
? null
: JsonSerializer.Serialize(context, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
}
private static void AddName(List<string> names, string? value)
{
var clean = CleanContext(value);
if (!string.IsNullOrWhiteSpace(clean) && !names.Contains(clean, StringComparer.OrdinalIgnoreCase))
{
names.Add(clean);
}
}
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);
var canonicalScenes = item.ChapterID > 0
? await runs.ListChapterScenesAsync(item.ChapterID)
: [];
var currentScene = CurrentCanonicalScene(run, canonicalScenes);
var currentSceneNumber = currentScene is null
? CurrentSceneNumber(run)
: Convert.ToInt32(currentScene.SceneNumber);
var currentSceneDisplayTitle = currentScene is null
? (currentSceneNumber.HasValue ? SceneTitleDisplay.Format(currentSceneNumber.Value, null) : null)
: SceneTitleDisplay.Format(currentScene.SceneNumber, currentScene.SceneTitle);
var canonicalSceneSummary = CanonicalSceneSummary(currentScene);
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,
UpdatedUtc = run.UpdatedUtc,
EstimatedCostUSD = run.EstimatedCostUSD,
EstimatedCostGBP = run.EstimatedCostGBP,
FailureStage = run.FailureStage,
ErrorMessage = run.ErrorMessage,
CurrentSceneID = currentScene?.SceneID,
CurrentSceneNumber = currentSceneNumber,
CurrentSceneDisplayTitle = currentSceneDisplayTitle,
LatestSceneSummary = currentScene is null ? LatestSceneSummary(sceneResults) : canonicalSceneSummary,
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 reviewCandidates.GetCharacterReviewAsync(batch, 1, 25);
var locationReview = await reviewCandidates.GetLocationReviewAsync(batch, 1, 25);
var assetReview = await reviewCandidates.GetAssetReviewAsync(batch, 1, 25);
var relationshipReview = await reviewCandidates.GetRelationshipReviewAsync(batch, 1, 25);
var knowledgeReview = await reviewCandidates.GetKnowledgeReviewAsync(batch, 1, 25);
var narrative = await narrativeReview.GetReviewAsync(batch);
var plotSynthesisRun = await wholeBookPlotIntelligence.GetCurrentAsync(batch.BookID);
var characterEnrichmentRun = await characterEnrichment.GetCurrentAsync(batch.BookID);
var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID);
var characterReviewComplete = pipeline?.CharacterReviewCompletedUtc is not null || characterReview.IsComplete;
var characterComplete = characterReviewComplete && characterEnrichmentRun?.Status == CharacterEnrichmentStatuses.Completed;
var locationComplete = pipeline?.LocationReviewCompletedUtc is not null || locationReview.IsComplete;
var assetComplete = pipeline?.AssetReviewCompletedUtc is not null || assetReview.IsComplete;
var relationshipComplete = pipeline?.RelationshipReviewCompletedUtc is not null || relationshipReview.IsComplete;
var knowledgeComplete = pipeline?.KnowledgeReviewCompletedUtc is not null || knowledgeReview.IsComplete;
var narrativeComplete = batch.NarrativeStageComplete || pipeline?.NarrativeReviewCompletedUtc is not null || narrative.IsComplete;
var narrativePendingCount = narrativeComplete ? 0 : narrative.Candidates.Count;
var narrativeThreadCount = narrative.Candidates.Count(candidate =>
string.Equals(candidate.CandidateType, StoryIntelligenceNarrativeCandidateTypes.Thread, StringComparison.OrdinalIgnoreCase));
var narrativeEventCount = narrative.Candidates.Sum(candidate => candidate.Events.Count);
return new StoryIntelligenceProgressViewModel
{
BatchID = batch.BatchID,
PreviewID = batch.PreviewID,
ImportSessionID = pipeline?.StoryIntelligenceBookPipelineID,
ProjectID = batch.ProjectID,
BookID = batch.BookID,
ProjectName = batch.ProjectName,
BookTitle = batch.BookTitle,
Chapters = chapters,
CharacterReview = characterReview,
LocationReview = locationReview,
AssetReview = assetReview,
RelationshipReview = relationshipReview,
KnowledgeReview = knowledgeReview,
NarrativeReview = narrative,
PlotSynthesis = plotSynthesisRun is null
? new WholeBookPlotSynthesisStatusViewModel
{
Status = string.Empty,
CandidateCount = narrativePendingCount,
ThreadCount = narrativeThreadCount,
EventCount = narrativeEventCount
}
: new WholeBookPlotSynthesisStatusViewModel
{
WholeBookPlotSynthesisRunID = plotSynthesisRun.WholeBookPlotSynthesisRunID,
Status = plotSynthesisRun.Status,
CandidateCount = narrativePendingCount,
ThreadCount = narrativeThreadCount,
EventCount = narrativeEventCount,
ErrorMessage = plotSynthesisRun.ErrorMessage
},
CharacterEnrichment = characterEnrichmentRun is null
? new CharacterEnrichmentStatusViewModel()
: new CharacterEnrichmentStatusViewModel
{
CharacterEnrichmentRunID = characterEnrichmentRun.CharacterEnrichmentRunID,
Status = characterEnrichmentRun.Status,
TotalCharacters = characterEnrichmentRun.TotalCharacters,
ProcessedCharacters = characterEnrichmentRun.ProcessedCharacters,
CurrentStage = characterEnrichmentRun.CurrentStage ?? string.Empty,
CurrentMessage = characterEnrichmentRun.CurrentMessage ?? string.Empty,
ErrorMessage = characterEnrichmentRun.ErrorMessage
},
PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel
{
ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete),
ScenesImported = Math.Max(chapters.Sum(chapter => chapter.ScenesCreated), chapters.Sum(chapter => chapter.CompletedScenes ?? 0)),
SceneAnalysisComplete = pipeline is not null && SceneAnalysisComplete(pipeline),
CharactersIdentified = characterReview.Candidates.Count + batch.CharacterDecisions.Count,
CharactersCreatedOrLinked = batch.CharacterDecisions.Count(decision => decision.CreatedOrLinked),
CharacterStageComplete = characterReviewComplete,
LocationsIdentified = locationReview.Candidates.Count + batch.LocationDecisions.Count,
LocationsCreatedOrLinked = batch.LocationDecisions.Count(decision => decision.CreatedOrLinked),
LocationStageComplete = locationComplete,
AssetsIdentified = assetReview.Candidates.Count + batch.AssetDecisions.Count,
AssetsCreatedOrLinked = batch.AssetDecisions.Count(decision => decision.CreatedOrLinked),
AssetStageComplete = assetComplete,
RelationshipsIdentified = relationshipReview.Candidates.Count + batch.RelationshipDecisions.Count,
RelationshipsCreatedOrLinked = batch.RelationshipDecisions.Count(decision => decision.CreatedOrLinked),
RelationshipStageComplete = relationshipComplete,
KnowledgeIdentified = knowledgeReview.Candidates.Count + batch.KnowledgeDecisions.Count,
KnowledgeCreatedOrLinked = batch.KnowledgeDecisions.Count(decision => decision.CreatedOrLinked),
KnowledgeStageComplete = knowledgeComplete,
NarrativeCandidatesIdentified = narrative.Candidates.Count + (batch.LastNarrativeImportResult?.TotalReviewed ?? 0),
NarrativeCandidatesApplied = (batch.LastNarrativeImportResult?.PlotLinesCreated ?? 0)
+ (batch.LastNarrativeImportResult?.PlotLinesMerged ?? 0)
+ (batch.LastNarrativeImportResult?.ThreadsCreated ?? 0),
NarrativeStageComplete = narrativeComplete
}
};
}
public async Task<StoryIntelligenceProgressViewModel?> GetReviewCentreAsync(Guid batchId)
{
var totalStopwatch = Stopwatch.StartNew();
var frame = await GetReviewFrameAsync(batchId);
if (frame is null)
{
return null;
}
logger.LogInformation(
"Story Intelligence Review Centre summary built. BatchID={BatchID} ProjectID={ProjectID} BookID={BookID} SummaryMs={SummaryMs} TotalMs={TotalMs} ActiveRunCount={ActiveRunCount} NarrativeCandidates={NarrativeCandidateCount} NarrativePending={NarrativePendingCount}",
frame.Batch.BatchID,
frame.Summary.ProjectID,
frame.Summary.BookID,
frame.SummaryElapsedMs,
totalStopwatch.ElapsedMilliseconds,
frame.Summary.ActiveRunCount,
frame.Summary.NarrativeCandidateCount,
frame.Summary.NarrativePendingCount);
return frame.Model;
}
public Task<StoryIntelligenceProgressViewModel?> GetCharacterReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25)
=> GetSelectedReviewAsync(batchId, "Characters", async frame => frame.Model.CharacterReview = await reviewCandidates.GetCharacterReviewAsync(frame.Batch, pageNumber, pageSize));
public Task<StoryIntelligenceProgressViewModel?> GetLocationReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25)
=> GetSelectedReviewAsync(batchId, "Locations", async frame => frame.Model.LocationReview = await reviewCandidates.GetLocationReviewAsync(frame.Batch, pageNumber, pageSize));
public Task<StoryIntelligenceProgressViewModel?> GetAssetReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25)
=> GetSelectedReviewAsync(batchId, "Assets", async frame => frame.Model.AssetReview = await reviewCandidates.GetAssetReviewAsync(frame.Batch, pageNumber, pageSize));
public Task<StoryIntelligenceProgressViewModel?> GetRelationshipReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25)
=> GetSelectedReviewAsync(batchId, "Relationships", async frame => frame.Model.RelationshipReview = await reviewCandidates.GetRelationshipReviewAsync(frame.Batch, pageNumber, pageSize));
public Task<StoryIntelligenceProgressViewModel?> GetKnowledgeReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25)
=> GetSelectedReviewAsync(batchId, "Knowledge", async frame => frame.Model.KnowledgeReview = await reviewCandidates.GetKnowledgeReviewAsync(frame.Batch, pageNumber, pageSize));
public Task<StoryIntelligenceProgressViewModel?> GetNarrativeReviewAsync(Guid batchId)
=> GetSelectedReviewAsync(batchId, "PlotLinesThreads", async frame =>
{
frame.Model.NarrativeReview = await narrativeReview.GetReviewAsync(frame.Batch);
frame.Model.PlotSynthesis = new WholeBookPlotSynthesisStatusViewModel
{
WholeBookPlotSynthesisRunID = frame.Model.PlotSynthesis.WholeBookPlotSynthesisRunID,
Status = frame.Model.PlotSynthesis.Status,
CandidateCount = frame.Model.NarrativeReview.Candidates.Count,
ThreadCount = frame.Model.NarrativeReview.Candidates.Count(candidate =>
string.Equals(candidate.CandidateType, StoryIntelligenceNarrativeCandidateTypes.Thread, StringComparison.OrdinalIgnoreCase)),
EventCount = frame.Model.NarrativeReview.Candidates.Sum(candidate => candidate.Events.Count),
ErrorMessage = frame.Model.PlotSynthesis.ErrorMessage
};
});
private async Task<StoryIntelligenceProgressViewModel?> GetSelectedReviewAsync(
Guid batchId,
string module,
Func<StoryIntelligenceReviewFrame, Task> loadModule)
{
var totalStopwatch = Stopwatch.StartNew();
var frame = await GetReviewFrameAsync(batchId);
if (frame is null)
{
return null;
}
var moduleStopwatch = Stopwatch.StartNew();
await loadModule(frame);
moduleStopwatch.Stop();
logger.LogInformation(
"Story Intelligence selected review module built. Module={Module} BatchID={BatchID} ProjectID={ProjectID} BookID={BookID} SummaryMs={SummaryMs} ModuleMs={ModuleMs} TotalMs={TotalMs}",
module,
frame.Batch.BatchID,
frame.Summary.ProjectID,
frame.Summary.BookID,
frame.SummaryElapsedMs,
moduleStopwatch.ElapsedMilliseconds,
totalStopwatch.ElapsedMilliseconds);
return frame.Model;
}
private async Task<StoryIntelligenceReviewFrame?> GetReviewFrameAsync(Guid batchId)
{
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
if (batch is null)
{
return null;
}
var summaryStopwatch = Stopwatch.StartNew();
var summary = await pipelineState.GetReviewCentreSummaryAsync(batch.BookID, batch.UserID);
summaryStopwatch.Stop();
if (summary is null)
{
return null;
}
var characterComplete = summary.CharacterReviewCompletedUtc is not null;
var locationComplete = summary.LocationReviewCompletedUtc is not null;
var assetComplete = summary.AssetReviewCompletedUtc is not null;
var relationshipComplete = summary.RelationshipReviewCompletedUtc is not null;
var knowledgeComplete = summary.KnowledgeReviewCompletedUtc is not null;
var narrativeComplete = summary.NarrativeReviewCompletedUtc is not null;
var sceneAnalysisComplete = SceneAnalysisComplete(summary);
var narrativePendingCount = narrativeComplete ? 0 : summary.NarrativePendingCount;
var hasCommittedScenes = summary.CompletedRunCount > 0 || summary.CompletedScenes > 0;
var characterEnrichmentRun = await characterEnrichment.GetCurrentAsync(batch.BookID);
var characterEnrichmentComplete = characterEnrichmentRun?.Status == CharacterEnrichmentStatuses.Completed;
var characterPanelComplete = characterComplete && characterEnrichmentComplete;
var model = new StoryIntelligenceProgressViewModel
{
BatchID = batch.BatchID,
PreviewID = batch.PreviewID,
ImportSessionID = summary.StoryIntelligenceBookPipelineID,
ProjectID = summary.ProjectID,
BookID = summary.BookID,
ProjectName = summary.ProjectName,
BookTitle = summary.BookDisplayTitle,
MetricValueCount = summary.MetricValueCount,
CharacterReview = new StoryIntelligenceCharacterReviewViewModel
{
HasCommittedScenes = hasCommittedScenes,
CanImport = !characterComplete,
IsComplete = characterPanelComplete,
SummaryNeedsAttentionCount = characterComplete ? 0 : ReadyPendingCount(summary.CharacterCandidateGenerationStatus, summary.CharacterPendingCount)
},
LocationReview = new StoryIntelligenceLocationReviewViewModel
{
HasCommittedScenes = hasCommittedScenes,
CanImport = !locationComplete,
IsComplete = locationComplete,
SummaryNeedsAttentionCount = locationComplete ? 0 : ReadyPendingCount(summary.LocationCandidateGenerationStatus, summary.LocationPendingCount)
},
AssetReview = new StoryIntelligenceAssetReviewViewModel
{
HasCommittedScenes = hasCommittedScenes,
CanImport = !assetComplete,
IsComplete = assetComplete,
SummaryNeedsAttentionCount = assetComplete ? 0 : ReadyPendingCount(summary.AssetCandidateGenerationStatus, summary.AssetPendingCount)
},
RelationshipReview = new StoryIntelligenceRelationshipReviewViewModel
{
HasCommittedScenes = hasCommittedScenes,
CanImport = !relationshipComplete,
IsComplete = relationshipComplete,
SummaryNeedsAttentionCount = relationshipComplete ? 0 : ReadyPendingCount(summary.RelationshipCandidateGenerationStatus, summary.RelationshipPendingCount)
},
KnowledgeReview = new StoryIntelligenceKnowledgeReviewViewModel
{
HasCommittedScenes = hasCommittedScenes,
CanImport = !knowledgeComplete,
IsComplete = knowledgeComplete,
SummaryNeedsAttentionCount = knowledgeComplete ? 0 : ReadyPendingCount(summary.KnowledgeCandidateGenerationStatus, summary.KnowledgePendingCount)
},
NarrativeReview = new StoryIntelligenceNarrativeReviewViewModel
{
HasCommittedScenes = hasCommittedScenes,
CanImport = narrativePendingCount > 0,
IsComplete = narrativeComplete,
SummaryNeedsAttentionCount = narrativePendingCount
},
PlotSynthesis = new WholeBookPlotSynthesisStatusViewModel
{
WholeBookPlotSynthesisRunID = summary.WholeBookPlotSynthesisRunID == 0 ? null : summary.WholeBookPlotSynthesisRunID,
Status = summary.WholeBookPlotSynthesisStatus ?? string.Empty,
CandidateCount = summary.NarrativeCandidateCount,
ThreadCount = summary.NarrativeThreadCount,
EventCount = summary.NarrativeEventCount,
ErrorMessage = summary.WholeBookPlotSynthesisErrorMessage
},
CharacterEnrichment = characterEnrichmentRun is null
? new CharacterEnrichmentStatusViewModel()
: new CharacterEnrichmentStatusViewModel
{
CharacterEnrichmentRunID = characterEnrichmentRun.CharacterEnrichmentRunID,
Status = characterEnrichmentRun.Status,
TotalCharacters = characterEnrichmentRun.TotalCharacters,
ProcessedCharacters = characterEnrichmentRun.ProcessedCharacters,
CurrentStage = characterEnrichmentRun.CurrentStage ?? string.Empty,
CurrentMessage = characterEnrichmentRun.CurrentMessage ?? string.Empty,
ErrorMessage = characterEnrichmentRun.ErrorMessage
},
PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel
{
ChaptersAnalysed = summary.CompletedRunCount,
ScenesImported = summary.CompletedScenes,
SceneAnalysisComplete = sceneAnalysisComplete,
CharacterStageComplete = characterComplete,
LocationStageComplete = locationComplete,
AssetStageComplete = assetComplete,
RelationshipStageComplete = relationshipComplete,
KnowledgeStageComplete = knowledgeComplete,
NarrativeCandidatesIdentified = summary.NarrativeCandidateCount,
NarrativeStageComplete = narrativeComplete
}
};
return new StoryIntelligenceReviewFrame(batch, summary, model, summaryStopwatch.ElapsedMilliseconds);
}
private static int? ReadyPendingCount(string? generationStatus, int? pendingCount)
=> string.Equals(generationStatus, StoryIntelligenceReviewCandidateGenerationStatuses.Ready, StringComparison.OrdinalIgnoreCase)
? pendingCount ?? 0
: null;
private sealed record StoryIntelligenceReviewFrame(
OnboardingStoryIntelligenceBatch Batch,
StoryIntelligenceReviewCentreSummary Summary,
StoryIntelligenceProgressViewModel Model,
long SummaryElapsedMs);
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 GetReviewCentreAsync(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 batch = await batchStore.GetAsync(RequireUserId(), batchId);
if (batch is null)
{
return (progress, 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();
await reviewCandidates.PrepareCharacterCandidatesAsync(batch);
var pendingCharacterCandidates = await reviewCandidates.CompleteModuleIfReadyAsync(batch, StoryIntelligenceReviewModules.Characters);
if (pendingCharacterCandidates == 0)
{
await QueuePostCharacterReviewAnalysisAsync(batch);
await reviewCandidates.PreparePeerCandidatesAsync(batch);
}
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, false);
if (result.Success)
{
var pendingCount = await reviewCandidates.MarkCharacterDecisionsAsync(batch.BookID, form.Characters);
if (pendingCount == 0)
{
await reviewCandidates.CompleteModuleIfReadyAsync(batch, StoryIntelligenceReviewModules.Characters);
await QueuePostCharacterReviewAnalysisAsync(batch);
await reviewCandidates.PreparePeerCandidatesAsync(batch);
}
else
{
result = WithRemainingMessage(result, pendingCount, "character");
}
}
return (await GetReviewCentreAsync(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,
CharactersAliased = batch.LastCharacterImportResult?.CharactersAliased ?? 0,
CharactersIgnored = batch.LastCharacterImportResult?.CharactersIgnored ?? 0,
PovLinksResolved = batch.LastCharacterImportResult?.PovLinksResolved ?? 0,
ScenePeoplePanelsUpdated = batch.LastCharacterImportResult?.ScenePeoplePanelsUpdated ?? 0
};
}
private async Task QueuePostCharacterReviewAnalysisAsync(OnboardingStoryIntelligenceBatch batch)
{
// Keep both post-character-review jobs independent; previous direct call was await wholeBookPlotIntelligence.QueueAfterCharacterResolutionAsync.
await Task.WhenAll(
wholeBookPlotIntelligence.QueueAfterCharacterResolutionAsync(batch.ProjectID, batch.BookID, batch.UserID),
characterEnrichment.QueueAfterCharacterResolutionAsync(batch.ProjectID, batch.BookID, batch.UserID));
logger.LogInformation(
"Queued post-character-review Story Intelligence jobs. BatchID={BatchID} ProjectID={ProjectID} BookID={BookID}",
batch.BatchID,
batch.ProjectID,
batch.BookID);
}
public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportLocationsAsync(Guid batchId, StoryIntelligenceLocationImportForm 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 locationImport.ImportAsync(batch, form, false);
if (result.Success)
{
var pendingCount = await reviewCandidates.MarkLocationDecisionsAsync(batch.BookID, form.Locations);
if (pendingCount == 0)
{
await reviewCandidates.CompleteModuleIfReadyAsync(batch, StoryIntelligenceReviewModules.Locations);
}
else
{
result = WithRemainingMessage(result, pendingCount, "location");
}
}
return (await GetReviewCentreAsync(batchId), result);
}
public async Task<StoryIntelligenceLocationImportResultViewModel?> GetLocationImportResultAsync(Guid batchId)
{
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
if (batch is null)
{
return null;
}
return new StoryIntelligenceLocationImportResultViewModel
{
BatchID = batch.BatchID,
ProjectID = batch.ProjectID,
BookID = batch.BookID,
LocationsCreated = batch.LastLocationImportResult?.LocationsCreated ?? 0,
LocationsLinked = batch.LastLocationImportResult?.LocationsLinked ?? 0,
LocationAliasesAdded = batch.LastLocationImportResult?.LocationAliasesAdded ?? 0,
LocationsIgnored = batch.LastLocationImportResult?.LocationsIgnored ?? 0,
SceneLocationsUpdated = batch.LastLocationImportResult?.SceneLocationsUpdated ?? 0
};
}
public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportAssetsAsync(Guid batchId, StoryIntelligenceAssetImportForm 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 assetImport.ImportAsync(batch, form, false);
if (result.Success)
{
var pendingCount = await reviewCandidates.MarkAssetDecisionsAsync(batch.BookID, form.Assets);
if (pendingCount == 0)
{
await reviewCandidates.CompleteModuleIfReadyAsync(batch, StoryIntelligenceReviewModules.Assets);
}
else
{
result = WithRemainingMessage(result, pendingCount, "asset");
}
}
return (await GetReviewCentreAsync(batchId), result);
}
public async Task<StoryIntelligenceAssetImportResultViewModel?> GetAssetImportResultAsync(Guid batchId)
{
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
if (batch is null)
{
return null;
}
return new StoryIntelligenceAssetImportResultViewModel
{
BatchID = batch.BatchID,
ProjectID = batch.ProjectID,
BookID = batch.BookID,
AssetsCreated = batch.LastAssetImportResult?.AssetsCreated ?? 0,
AssetsLinked = batch.LastAssetImportResult?.AssetsLinked ?? 0,
AssetAliasesAdded = batch.LastAssetImportResult?.AssetAliasesAdded ?? 0,
AssetsIgnored = batch.LastAssetImportResult?.AssetsIgnored ?? 0,
SceneAssetEventsCreated = batch.LastAssetImportResult?.SceneAssetEventsCreated ?? 0,
OwnershipLinksCreated = batch.LastAssetImportResult?.OwnershipLinksCreated ?? 0
};
}
public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportRelationshipsAsync(Guid batchId, StoryIntelligenceRelationshipImportForm 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 relationshipImport.ImportAsync(batch, form, false);
if (result.Success)
{
var pendingCount = await reviewCandidates.MarkRelationshipDecisionsAsync(batch.BookID, form.Relationships);
if (pendingCount == 0)
{
await reviewCandidates.CompleteModuleIfReadyAsync(batch, StoryIntelligenceReviewModules.Relationships);
}
else
{
result = WithRemainingMessage(result, pendingCount, "relationship");
}
}
return (await GetReviewCentreAsync(batchId), result);
}
public async Task<StoryIntelligenceRelationshipImportResultViewModel?> GetRelationshipImportResultAsync(Guid batchId)
{
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
if (batch is null)
{
return null;
}
return new StoryIntelligenceRelationshipImportResultViewModel
{
BatchID = batch.BatchID,
ProjectID = batch.ProjectID,
BookID = batch.BookID,
RelationshipsCreated = batch.LastRelationshipImportResult?.RelationshipsCreated ?? 0,
RelationshipsLinked = batch.LastRelationshipImportResult?.RelationshipsLinked ?? 0,
RelationshipsMerged = batch.LastRelationshipImportResult?.RelationshipsMerged ?? 0,
RelationshipsIgnored = batch.LastRelationshipImportResult?.RelationshipsIgnored ?? 0,
RelationshipEventsCreated = batch.LastRelationshipImportResult?.RelationshipEventsCreated ?? 0
};
}
public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportKnowledgeAsync(Guid batchId, StoryIntelligenceKnowledgeImportForm 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 knowledgeImport.ImportAsync(batch, form, false);
if (result.Success)
{
var pendingCount = await reviewCandidates.MarkKnowledgeDecisionsAsync(batch.BookID, form.Knowledge);
if (pendingCount == 0)
{
await reviewCandidates.CompleteModuleIfReadyAsync(batch, StoryIntelligenceReviewModules.Knowledge);
}
else
{
result = WithRemainingMessage(result, pendingCount, "knowledge");
}
}
return (await GetReviewCentreAsync(batchId), result);
}
public async Task<StoryIntelligenceKnowledgeImportResultViewModel?> GetKnowledgeImportResultAsync(Guid batchId)
{
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
if (batch is null)
{
return null;
}
return new StoryIntelligenceKnowledgeImportResultViewModel
{
BatchID = batch.BatchID,
ProjectID = batch.ProjectID,
BookID = batch.BookID,
KnowledgeCreated = batch.LastKnowledgeImportResult?.KnowledgeCreated ?? 0,
KnowledgeLinked = batch.LastKnowledgeImportResult?.KnowledgeLinked ?? 0,
KnowledgeMerged = batch.LastKnowledgeImportResult?.KnowledgeMerged ?? 0,
KnowledgeIgnored = batch.LastKnowledgeImportResult?.KnowledgeIgnored ?? 0
};
}
private static StoryIntelligenceImportCommitResult WithRemainingMessage(
StoryIntelligenceImportCommitResult result,
int pendingCount,
string candidateLabel)
=> new()
{
Success = result.Success,
CommitID = result.CommitID,
ScenesCreated = result.ScenesCreated,
MetricsCreated = result.MetricsCreated,
Message = $"{result.Message} {pendingCount:N0} {candidateLabel} candidate(s) remain."
};
public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportNarrativeAsync(Guid batchId, StoryIntelligenceNarrativeImportForm 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 narrativeReview.ImportAsync(batch, form, RequireUserId());
await batchStore.SaveAsync(batch);
return (await GetReviewCentreAsync(batchId), result);
}
public async Task<StoryIntelligenceNarrativeImportResultViewModel?> GetNarrativeImportResultAsync(Guid batchId)
{
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
return batch is null ? null : await narrativeReview.GetResultAsync(batch);
}
public async Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId)
{
var progress = await GetReviewCentreAsync(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,
LocationsCreatedOrLinked = progress.PipelineDashboard.LocationsCreatedOrLinked,
LocationStageComplete = progress.PipelineDashboard.LocationStageComplete,
AssetsCreatedOrLinked = progress.PipelineDashboard.AssetsCreatedOrLinked,
AssetStageComplete = progress.PipelineDashboard.AssetStageComplete,
RelationshipsCreatedOrLinked = progress.PipelineDashboard.RelationshipsCreatedOrLinked,
RelationshipStageComplete = progress.PipelineDashboard.RelationshipStageComplete
};
}
public async Task<OnboardingStoryIntelligenceResumeTarget?> ResumeBookAsync(int bookId)
{
var userId = RequireUserId();
var state = await pipelineState.GetForBookAsync(bookId, userId);
if (state is null)
{
return null;
}
var chapterLookup = (await chapters.ListByBookAsync(bookId)).ToDictionary(chapter => chapter.ChapterID);
var committedRuns = (await pipelineState.ListCommittedRunsByBookAsync(bookId, userId))
.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 = CanonicalChapterTitle(chapterLookup.GetValueOrDefault(run.ChapterID), run.ChapterNumber),
ChapterID = run.ChapterID,
RunID = run.StoryIntelligenceRunID
})
.ToList();
var activeRuns = committedRuns.Count > 0
? []
: (await runs.ListRunsByBookForUserAsync(bookId, userId))
.Where(run => run.BookID == state.BookID)
.OrderBy(run => run.ChapterNumber ?? decimal.MaxValue)
.ThenBy(run => run.StoryIntelligenceRunID)
.Select(run => new OnboardingStoryIntelligenceBatchItem
{
TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID ?? run.StoryIntelligenceRunID}",
ChapterNumber = Convert.ToInt32(run.ChapterNumber ?? 0),
ChapterTitle = CanonicalChapterTitle(run.ChapterID.HasValue ? chapterLookup.GetValueOrDefault(run.ChapterID.Value) : null, run.ChapterNumber),
ChapterID = run.ChapterID ?? 0,
RunID = run.StoryIntelligenceRunID
})
.ToList();
var batchItems = committedRuns.Count > 0 ? committedRuns : activeRuns;
if (batchItems.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 = batchItems
};
batch.CharacterStageComplete = state.CharacterReviewCompletedUtc is not null;
batch.LocationStageComplete = state.LocationReviewCompletedUtc is not null;
batch.AssetStageComplete = state.AssetReviewCompletedUtc is not null;
batch.RelationshipStageComplete = state.RelationshipReviewCompletedUtc is not null;
batch.KnowledgeStageComplete = state.KnowledgeReviewCompletedUtc is not null;
batch.NarrativeStageComplete = state.NarrativeReviewCompletedUtc is not null;
await batchStore.SaveAsync(batch);
var route = SceneAnalysisComplete(state)
? StoryIntelligenceResumeRoutes.ReviewCentre
: StoryIntelligenceResumeRoutes.SceneReview;
return new OnboardingStoryIntelligenceResumeTarget(batch.BatchID, route);
}
private static bool HasCompletedStage(StoryIntelligenceBookPipelineState state, string stage)
=> string.Equals(state.LastCompletedStage, stage, StringComparison.OrdinalIgnoreCase);
private static bool SceneAnalysisComplete(StoryIntelligenceBookPipelineState state)
=> HasCompletedStage(state, StoryIntelligencePipelineStages.SceneImport)
|| state.CurrentStage is StoryIntelligencePipelineStages.CharacterReview
or StoryIntelligencePipelineStages.CharacterImport
or StoryIntelligencePipelineStages.LocationReview
or StoryIntelligencePipelineStages.LocationImport
or StoryIntelligencePipelineStages.AssetReview
or StoryIntelligencePipelineStages.AssetImport
or StoryIntelligencePipelineStages.RelationshipReview
or StoryIntelligencePipelineStages.RelationshipImport
or StoryIntelligencePipelineStages.KnowledgeReview
or StoryIntelligencePipelineStages.KnowledgeImport
or StoryIntelligencePipelineStages.NarrativeReview
or StoryIntelligencePipelineStages.NarrativeImport
or StoryIntelligencePipelineStages.Complete;
private static bool SceneAnalysisComplete(StoryIntelligenceReviewCentreSummary state)
=> string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.SceneImport, StringComparison.OrdinalIgnoreCase)
|| state.CurrentStage is StoryIntelligencePipelineStages.CharacterReview
or StoryIntelligencePipelineStages.CharacterImport
or StoryIntelligencePipelineStages.LocationReview
or StoryIntelligencePipelineStages.LocationImport
or StoryIntelligencePipelineStages.AssetReview
or StoryIntelligencePipelineStages.AssetImport
or StoryIntelligencePipelineStages.RelationshipReview
or StoryIntelligencePipelineStages.RelationshipImport
or StoryIntelligencePipelineStages.KnowledgeReview
or StoryIntelligencePipelineStages.KnowledgeImport
or StoryIntelligencePipelineStages.NarrativeReview
or StoryIntelligencePipelineStages.NarrativeImport
or StoryIntelligencePipelineStages.Complete;
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.CoreImportComplete, 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 string CanonicalChapterTitle(Chapter? chapter, decimal? chapterNumber)
{
var number = chapterNumber ?? chapter?.ChapterNumber;
var fallback = number.HasValue && number.Value > 0 ? $"Chapter {number.Value:0.##}" : "Chapter";
var title = CleanContext(chapter?.ChapterTitle).TrimStart('-', ' ', '\t');
if (string.IsNullOrWhiteSpace(title))
{
return fallback;
}
return title.StartsWith("Chapter ", StringComparison.OrdinalIgnoreCase) ? title : $"{fallback} - {title}";
}
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 Scene? CurrentCanonicalScene(StoryIntelligenceSavedRun run, IReadOnlyList<Scene> scenes)
{
if (scenes.Count == 0)
{
return null;
}
var orderedScenes = scenes.OrderBy(scene => scene.SceneNumber).ToList();
var sceneNumber = CurrentSceneNumber(run);
if (sceneNumber is not > 0)
{
return orderedScenes[0];
}
var byNumber = orderedScenes.FirstOrDefault(scene => Convert.ToInt32(scene.SceneNumber) == sceneNumber.Value);
if (byNumber is not null)
{
return byNumber;
}
return orderedScenes[Math.Clamp(sceneNumber.Value - 1, 0, orderedScenes.Count - 1)];
}
private static int? CurrentSceneNumber(StoryIntelligenceSavedRun run)
{
var completed = Math.Max(0, run.CompletedScenes ?? 0);
var total = Math.Max(0, run.TotalDetectedScenes ?? 0);
if (run.Status == StoryIntelligenceRunStatuses.Running
&& string.Equals(run.CurrentStage, StoryIntelligenceFailureStages.SceneIntelligence, StringComparison.OrdinalIgnoreCase)
&& total > 0)
{
return Math.Clamp(completed + 1, 1, total);
}
if (completed > 0)
{
return Math.Clamp(completed, 1, total > 0 ? total : completed);
}
return run.Status is StoryIntelligenceRunStatuses.Pending or StoryIntelligenceRunStatuses.Running && total > 0
? 1
: null;
}
private static string? CanonicalSceneSummary(Scene? scene)
=> FirstConfigured(scene?.StructuralSummary, scene?.Summary);
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
{
SceneID = scene.SceneID,
SceneNumber = scene.TemporarySceneNumber,
Summary = Trim(FirstConfigured(scene.Summary, 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.Setting?.Confidence ?? scene.ParsedScene.PointOfView?.Confidence,
HasWarnings = scene.HasWarnings,
AnalysisStatus = scene.AnalysisStatus
})
.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 string AppearancePreference { get; init; } = StoryMemoryAppearancePreferences.Balanced;
public string PrimaryAgeGroup { get; init; } = string.Empty;
public string StoryEra { get; init; } = string.Empty;
public string StoryLocation { get; init; } = string.Empty;
public DateTime CreatedUtc { get; init; } = DateTime.UtcNow;
public IReadOnlyList<OnboardingStoryIntelligenceBatchItem> Items { get; init; } = [];
public List<OnboardingStoryIntelligenceCharacterDecision> CharacterDecisions { get; } = [];
public List<OnboardingStoryIntelligenceLocationDecision> LocationDecisions { get; } = [];
public List<OnboardingStoryIntelligenceAssetDecision> AssetDecisions { get; } = [];
public List<OnboardingStoryIntelligenceRelationshipSelection> RelationshipSelections { get; } = [];
public List<OnboardingStoryIntelligenceRelationshipDecision> RelationshipDecisions { get; } = [];
public List<OnboardingStoryIntelligenceKnowledgeSelection> KnowledgeSelections { get; } = [];
public List<OnboardingStoryIntelligenceKnowledgeDecision> KnowledgeDecisions { get; } = [];
public StoryIntelligenceCharacterImportBatchResult? LastCharacterImportResult { get; set; }
public StoryIntelligenceLocationImportBatchResult? LastLocationImportResult { get; set; }
public StoryIntelligenceAssetImportBatchResult? LastAssetImportResult { get; set; }
public StoryIntelligenceRelationshipImportBatchResult? LastRelationshipImportResult { get; set; }
public StoryIntelligenceKnowledgeImportBatchResult? LastKnowledgeImportResult { get; set; }
public StoryIntelligenceNarrativeImportBatchResult? LastNarrativeImportResult { get; set; }
public bool CharacterStageComplete { get; set; }
public bool LocationStageComplete { get; set; }
public bool AssetStageComplete { get; set; }
public bool RelationshipStageComplete { get; set; }
public bool KnowledgeStageComplete { get; set; }
public bool NarrativeStageComplete { 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 OnboardingStoryIntelligenceLocationDecision
{
public string Key { get; init; } = string.Empty;
public string Action { get; init; } = string.Empty;
public string? LocationName { get; init; }
public int? LocationID { get; init; }
public bool CreatedOrLinked { get; init; }
}
public sealed class OnboardingStoryIntelligenceAssetDecision
{
public string Key { get; init; } = string.Empty;
public string Action { get; init; } = string.Empty;
public string? AssetName { get; init; }
public int? StoryAssetID { get; init; }
public bool CreatedOrLinked { get; init; }
}
public sealed class OnboardingStoryIntelligenceRelationshipDecision
{
public string Key { get; init; } = string.Empty;
public string Action { get; init; } = string.Empty;
public int? CharacterRelationshipID { get; init; }
public string? RelationshipLabel { get; init; }
public bool CreatedOrLinked { get; init; }
}
public sealed class OnboardingStoryIntelligenceRelationshipSelection
{
public string Key { get; init; } = string.Empty;
public int RelationshipTypeID { get; init; }
}
public sealed class StoryIntelligenceCharacterImportBatchResult
{
public int CharactersCreated { get; init; }
public int CharactersLinked { get; init; }
public int CharactersAliased { get; init; }
public int CharactersIgnored { get; init; }
public int PovLinksResolved { get; init; }
public int ScenePeoplePanelsUpdated { get; init; }
}
public sealed class StoryIntelligenceLocationImportBatchResult
{
public int LocationsCreated { get; init; }
public int LocationsLinked { get; init; }
public int LocationAliasesAdded { get; init; }
public int LocationsIgnored { get; init; }
public int SceneLocationsUpdated { get; init; }
}
public sealed class StoryIntelligenceAssetImportBatchResult
{
public int AssetsCreated { get; init; }
public int AssetsLinked { get; init; }
public int AssetAliasesAdded { get; init; }
public int AssetsIgnored { get; init; }
public int SceneAssetEventsCreated { get; init; }
public int OwnershipLinksCreated { get; init; }
}
public sealed class StoryIntelligenceRelationshipImportBatchResult
{
public int RelationshipsCreated { get; init; }
public int RelationshipsLinked { get; init; }
public int RelationshipsMerged { get; init; }
public int RelationshipsIgnored { get; init; }
public int RelationshipEventsCreated { get; init; }
}
public sealed class OnboardingStoryIntelligenceKnowledgeDecision
{
public string Key { get; init; } = string.Empty;
public string Action { get; init; } = string.Empty;
public int? CharacterKnowledgeID { get; init; }
public string? KnowledgeLabel { get; init; }
public bool CreatedOrLinked { get; init; }
}
public sealed class OnboardingStoryIntelligenceKnowledgeSelection
{
public string Key { get; init; } = string.Empty;
public int KnowledgeStateID { get; init; }
}
public sealed class StoryIntelligenceKnowledgeImportBatchResult
{
public int KnowledgeCreated { get; init; }
public int KnowledgeLinked { get; init; }
public int KnowledgeMerged { get; init; }
public int KnowledgeIgnored { get; init; }
}
public sealed class StoryIntelligenceNarrativeImportBatchResult
{
public int PlotLinesCreated { get; init; }
public int PlotLinesMerged { get; init; }
public int ThreadsCreated { get; init; }
public int CandidatesRejected { get; init; }
public int TotalReviewed => PlotLinesCreated + PlotLinesMerged + ThreadsCreated + CandidatesRejected;
}
public static class StoryIntelligenceResumeRoutes
{
public const string SceneReview = "SceneReview";
public const string ReviewCentre = "ReviewCentre";
public const string Characters = "Characters";
public const string Locations = "Locations";
public const string Assets = "Assets";
public const string Relationships = "Relationships";
public const string Knowledge = "Knowledge";
public const string Narrative = "Narrative";
public const string Complete = "Complete";
}
public sealed record OnboardingStoryIntelligenceResumeTarget(Guid BatchID, string Route);