1755 lines
82 KiB
C#
1755 lines
82 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);
|
|
Task<StoryIntelligenceProgressViewModel?> GetLocationReviewAsync(Guid batchId);
|
|
Task<StoryIntelligenceProgressViewModel?> GetAssetReviewAsync(Guid batchId);
|
|
Task<StoryIntelligenceProgressViewModel?> GetRelationshipReviewAsync(Guid batchId);
|
|
Task<StoryIntelligenceProgressViewModel?> GetKnowledgeReviewAsync(Guid batchId);
|
|
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,
|
|
IStoryIntelligenceSourceRepository sources,
|
|
IStoryIntelligencePipelineStateService pipelineState,
|
|
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 = 10;
|
|
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 characterImport.BuildReviewAsync(batch);
|
|
var locationReview = await locationImport.BuildReviewAsync(batch);
|
|
var assetReview = await assetImport.BuildReviewAsync(batch);
|
|
var relationshipReview = await relationshipImport.BuildReviewAsync(batch);
|
|
var knowledgeReview = await knowledgeImport.BuildReviewAsync(batch);
|
|
var narrative = await narrativeReview.GetReviewAsync(batch);
|
|
var plotSynthesisRun = await wholeBookPlotIntelligence.GetCurrentAsync(batch.BookID);
|
|
var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID);
|
|
var characterComplete = batch.CharacterStageComplete || pipeline?.CharacterReviewCompletedUtc is not null || characterReview.IsComplete;
|
|
var locationComplete = batch.LocationStageComplete || pipeline?.LocationReviewCompletedUtc is not null || locationReview.IsComplete;
|
|
var assetComplete = batch.AssetStageComplete || pipeline?.AssetReviewCompletedUtc is not null || assetReview.IsComplete;
|
|
var relationshipComplete = batch.RelationshipStageComplete || pipeline?.RelationshipReviewCompletedUtc is not null || relationshipReview.IsComplete;
|
|
var knowledgeComplete = batch.KnowledgeStageComplete || 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
|
|
},
|
|
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 = characterComplete,
|
|
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)
|
|
=> GetSelectedReviewAsync(batchId, "Characters", async frame => frame.Model.CharacterReview = await characterImport.BuildReviewAsync(frame.Batch));
|
|
|
|
public Task<StoryIntelligenceProgressViewModel?> GetLocationReviewAsync(Guid batchId)
|
|
=> GetSelectedReviewAsync(batchId, "Locations", async frame => frame.Model.LocationReview = await locationImport.BuildReviewAsync(frame.Batch));
|
|
|
|
public Task<StoryIntelligenceProgressViewModel?> GetAssetReviewAsync(Guid batchId)
|
|
=> GetSelectedReviewAsync(batchId, "Assets", async frame => frame.Model.AssetReview = await assetImport.BuildReviewAsync(frame.Batch));
|
|
|
|
public Task<StoryIntelligenceProgressViewModel?> GetRelationshipReviewAsync(Guid batchId)
|
|
=> GetSelectedReviewAsync(batchId, "Relationships", async frame => frame.Model.RelationshipReview = await relationshipImport.BuildReviewAsync(frame.Batch));
|
|
|
|
public Task<StoryIntelligenceProgressViewModel?> GetKnowledgeReviewAsync(Guid batchId)
|
|
=> GetSelectedReviewAsync(batchId, "Knowledge", async frame => frame.Model.KnowledgeReview = await knowledgeImport.BuildReviewAsync(frame.Batch));
|
|
|
|
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 model = new StoryIntelligenceProgressViewModel
|
|
{
|
|
BatchID = batch.BatchID,
|
|
PreviewID = batch.PreviewID,
|
|
ImportSessionID = summary.StoryIntelligenceBookPipelineID,
|
|
ProjectID = summary.ProjectID,
|
|
BookID = summary.BookID,
|
|
ProjectName = summary.ProjectName,
|
|
BookTitle = summary.BookDisplayTitle,
|
|
CharacterReview = new StoryIntelligenceCharacterReviewViewModel
|
|
{
|
|
HasCommittedScenes = hasCommittedScenes,
|
|
CanImport = !characterComplete,
|
|
IsComplete = characterComplete,
|
|
SummaryNeedsAttentionCount = characterComplete ? 0 : null
|
|
},
|
|
LocationReview = new StoryIntelligenceLocationReviewViewModel
|
|
{
|
|
HasCommittedScenes = hasCommittedScenes,
|
|
CanImport = !locationComplete,
|
|
IsComplete = locationComplete,
|
|
SummaryNeedsAttentionCount = locationComplete ? 0 : null
|
|
},
|
|
AssetReview = new StoryIntelligenceAssetReviewViewModel
|
|
{
|
|
HasCommittedScenes = hasCommittedScenes,
|
|
CanImport = !assetComplete,
|
|
IsComplete = assetComplete,
|
|
SummaryNeedsAttentionCount = assetComplete ? 0 : null
|
|
},
|
|
RelationshipReview = new StoryIntelligenceRelationshipReviewViewModel
|
|
{
|
|
HasCommittedScenes = hasCommittedScenes,
|
|
CanImport = !relationshipComplete,
|
|
IsComplete = relationshipComplete,
|
|
SummaryNeedsAttentionCount = relationshipComplete ? 0 : null
|
|
},
|
|
KnowledgeReview = new StoryIntelligenceKnowledgeReviewViewModel
|
|
{
|
|
HasCommittedScenes = hasCommittedScenes,
|
|
CanImport = !knowledgeComplete,
|
|
IsComplete = knowledgeComplete,
|
|
SummaryNeedsAttentionCount = knowledgeComplete ? 0 : null
|
|
},
|
|
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
|
|
},
|
|
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 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 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 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
|
|
};
|
|
}
|
|
|
|
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);
|
|
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);
|
|
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);
|
|
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);
|
|
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
|
|
};
|
|
}
|
|
|
|
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 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,
|
|
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);
|