using System.Text.Json; using Microsoft.AspNetCore.Mvc.Rendering; using PlotLine.Data; using PlotLine.Models; using PlotLine.ViewModels; namespace PlotLine.Services; public interface IStoryIntelligenceNarrativeReviewService { Task GetReviewAsync(OnboardingStoryIntelligenceBatch batch); Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceNarrativeImportForm form, int userId); Task GetResultAsync(OnboardingStoryIntelligenceBatch batch); } public sealed class StoryIntelligenceNarrativeReviewService( IStoryIntelligenceNarrativeCandidateRepository candidates, IStoryIntelligenceResultRepository runs, ISceneRepository scenes, IPlotRepository plots, IStoryIntelligencePipelineStateService pipelineState) : IStoryIntelligenceNarrativeReviewService { private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true }; public async Task GetReviewAsync(OnboardingStoryIntelligenceBatch batch) { await EnsureCandidatesAsync(batch); var rows = await candidates.ListByBookAsync(batch.BookID); var pending = rows .Where(candidate => string.Equals(candidate.Status, StoryIntelligenceNarrativeCandidateStatuses.Pending, StringComparison.OrdinalIgnoreCase)) .ToList(); var plotLines = await plots.ListPlotLinesAsync(batch.ProjectID); return new StoryIntelligenceNarrativeReviewViewModel { HasCommittedScenes = await HasCommittedScenesAsync(batch), CanImport = pending.Count > 0, IsComplete = rows.Count > 0 && pending.Count == 0, PlotLineOptions = plotLines .OrderBy(plotLine => plotLine.SortOrder) .ThenBy(plotLine => plotLine.PlotLineName) .Select(plotLine => new SelectListItem(plotLine.PlotLineName, plotLine.PlotLineID.ToString())) .ToList(), Candidates = pending.Select(ToViewModel).ToList() }; } public async Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceNarrativeImportForm form, int userId) { await EnsureCandidatesAsync(batch); var rows = (await candidates.ListByBookAsync(batch.BookID)).ToDictionary(candidate => candidate.NarrativeCandidateID); var lookups = await plots.GetLookupsAsync(); var plotLineTypeId = lookups.PlotLineTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Main Plot", StringComparison.OrdinalIgnoreCase))?.PlotLineTypeID ?? lookups.PlotLineTypes.FirstOrDefault()?.PlotLineTypeID ?? 0; var plotImportanceId = lookups.PlotImportance.FirstOrDefault(importance => string.Equals(importance.ImportanceName, "Primary", StringComparison.OrdinalIgnoreCase))?.PlotImportanceID ?? lookups.PlotImportance.FirstOrDefault()?.PlotImportanceID ?? 0; var threadTypeId = lookups.ThreadTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Question", StringComparison.OrdinalIgnoreCase))?.ThreadTypeID ?? lookups.ThreadTypes.FirstOrDefault()?.ThreadTypeID ?? 0; var threadStatusId = lookups.ThreadStatuses.FirstOrDefault(status => string.Equals(status.StatusName, "Open", StringComparison.OrdinalIgnoreCase))?.ThreadStatusID ?? lookups.ThreadStatuses.FirstOrDefault()?.ThreadStatusID ?? 0; var threadEventTypeId = lookups.ThreadEventTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Raised", StringComparison.OrdinalIgnoreCase))?.ThreadEventTypeID ?? lookups.ThreadEventTypes.FirstOrDefault()?.ThreadEventTypeID ?? 0; var plotEventTypeId = lookups.PlotEventTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Progress", StringComparison.OrdinalIgnoreCase))?.PlotEventTypeID ?? lookups.PlotEventTypes.FirstOrDefault()?.PlotEventTypeID ?? 0; var plotLinesCreated = 0; var plotLinesMerged = 0; var threadsCreated = 0; var rejected = 0; foreach (var choice in form.Candidates.Where(choice => choice.NarrativeCandidateID > 0)) { if (!rows.TryGetValue(choice.NarrativeCandidateID, out var candidate) || !string.Equals(candidate.Status, StoryIntelligenceNarrativeCandidateStatuses.Pending, StringComparison.OrdinalIgnoreCase)) { continue; } var action = string.IsNullOrWhiteSpace(choice.Action) ? StoryIntelligenceNarrativeReviewActions.AcceptNew : choice.Action; if (string.Equals(action, StoryIntelligenceNarrativeReviewActions.Reject, StringComparison.OrdinalIgnoreCase)) { await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Rejected, action, userId, null, null); rejected++; continue; } if (string.Equals(candidate.CandidateType, StoryIntelligenceNarrativeCandidateTypes.PlotLine, StringComparison.OrdinalIgnoreCase)) { if (string.Equals(action, StoryIntelligenceNarrativeReviewActions.MergeExisting, StringComparison.OrdinalIgnoreCase) && choice.ExistingPlotLineID is > 0) { await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Merged, action, userId, choice.ExistingPlotLineID, null); plotLinesMerged++; continue; } var plotLineId = await plots.SavePlotLineAsync(new PlotLineItem { ProjectID = batch.ProjectID, BookID = batch.BookID, PlotLineName = CleanTitle(choice.Title, candidate.ProposedTitle), Description = CleanNullable(choice.Description) ?? candidate.ProposedDescription, PlotLineTypeID = plotLineTypeId, PlotImportanceID = plotImportanceId, SortOrder = 0, Colour = "#2f6f63", IsVisibleOnTimeline = true }); await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Accepted, action, userId, plotLineId, null); plotLinesCreated++; continue; } var relatedPlotLineId = choice.RelatedPlotLineID ?? candidate.RelatedPlotLineID ?? choice.ExistingPlotLineID; if (relatedPlotLineId is not > 0) { await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Rejected, StoryIntelligenceNarrativeReviewActions.Reject, userId, null, null); rejected++; continue; } var firstSceneId = candidate.Evidence.OrderBy(evidence => evidence.SortOrder).FirstOrDefault()?.SceneID; var threadId = await plots.SavePlotThreadAsync(new PlotThread { PlotLineID = relatedPlotLineId.Value, ThreadTitle = CleanTitle(choice.Title, candidate.ProposedTitle), Summary = CleanNullable(choice.Description) ?? candidate.ProposedDescription, ThreadTypeID = threadTypeId, ThreadStatusID = threadStatusId, Importance = 5, IntroducedSceneID = firstSceneId }); foreach (var evidence in candidate.Evidence.Where(evidence => evidence.SceneID > 0)) { await plots.SaveThreadEventAsync(new ThreadEvent { PlotThreadID = threadId, SceneID = evidence.SceneID, EventTypeID = threadEventTypeId, PlotEventTypeID = plotEventTypeId, EventTitle = string.IsNullOrWhiteSpace(evidence.ContributionType) ? candidate.ProposedTitle : evidence.ContributionType, EventDescription = evidence.Evidence }); } await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Accepted, action, userId, null, threadId); threadsCreated++; } batch.LastNarrativeImportResult = new StoryIntelligenceNarrativeImportBatchResult { PlotLinesCreated = plotLinesCreated, PlotLinesMerged = plotLinesMerged, ThreadsCreated = threadsCreated, CandidatesRejected = rejected }; batch.NarrativeStageComplete = true; await pipelineState.RecordNarrativeImportAsync(batch.ProjectID, batch.BookID); return new StoryIntelligenceImportCommitResult { Success = true, Message = $"Narrative review applied. {plotLinesCreated:N0} Plot Line(s) created, {threadsCreated:N0} Thread(s) created, {plotLinesMerged:N0} merged, {rejected:N0} rejected." }; } public Task GetResultAsync(OnboardingStoryIntelligenceBatch batch) => Task.FromResult(new StoryIntelligenceNarrativeImportResultViewModel { BatchID = batch.BatchID, ProjectID = batch.ProjectID, BookID = batch.BookID, PlotLinesCreated = batch.LastNarrativeImportResult?.PlotLinesCreated ?? 0, PlotLinesMerged = batch.LastNarrativeImportResult?.PlotLinesMerged ?? 0, ThreadsCreated = batch.LastNarrativeImportResult?.ThreadsCreated ?? 0, CandidatesRejected = batch.LastNarrativeImportResult?.CandidatesRejected ?? 0 }); private async Task EnsureCandidatesAsync(OnboardingStoryIntelligenceBatch batch) { var existingPlotLines = await plots.ListPlotLinesAsync(batch.ProjectID); var existingThreads = await plots.ListPlotThreadsByProjectAsync(batch.ProjectID); var existingPlotLineByKey = existingPlotLines .GroupBy(plotLine => Key(plotLine.PlotLineName), StringComparer.OrdinalIgnoreCase) .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); var existingThreadByKey = existingThreads .GroupBy(thread => Key(thread.ThreadTitle), StringComparer.OrdinalIgnoreCase) .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); var signals = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var item in batch.Items) { var commit = await runs.GetImportCommitAsync(item.RunID); if (commit is null || !string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase)) { continue; } var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID)) .Where(scene => scene.ImportRunID == item.RunID) .ToList(); var importedByRange = importedScenes .Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue) .ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase); var importedByNumber = importedScenes.ToDictionary(scene => Convert.ToInt32(scene.SceneNumber), scene => scene); var sceneResults = await runs.ListSceneResultsAsync(item.RunID); foreach (var sceneResult in sceneResults) { var importedScene = ResolveImportedScene(sceneResult, importedByRange, importedByNumber); var parsed = TryReadScene(sceneResult); if (importedScene is null || parsed is null) { continue; } foreach (var question in parsed.QuestionsRaised ?? []) { AddSignal(signals, batch, importedScene, CleanNullable(question.Question), "Question", "Open", question.Evidence, question.Confidence); } foreach (var clue in parsed.TimelineClues ?? []) { AddSignal(signals, batch, importedScene, CleanNullable(clue.Clue), "Timeline clue", "Introduced", clue.Evidence, clue.Confidence); } } } foreach (var signal in signals.Values.Where(signal => signal.SceneIds.Count > 1).OrderBy(signal => signal.Title)) { var existingThread = existingThreadByKey.GetValueOrDefault(Key(signal.Title)); var existingPlotLine = existingPlotLineByKey.GetValueOrDefault(Key(signal.Title)); var candidateType = signal.SceneIds.Count >= 3 && existingThread is null ? StoryIntelligenceNarrativeCandidateTypes.PlotLine : StoryIntelligenceNarrativeCandidateTypes.Thread; var candidateId = await candidates.SaveAsync(new StoryIntelligenceNarrativeCandidateSeed { ProjectID = batch.ProjectID, BookID = batch.BookID, CandidateType = candidateType, CandidateKey = $"{candidateType}:{Key(signal.Title)}", ProposedTitle = signal.Title, ProposedDescription = signal.Description, Category = signal.Category, InferredState = signal.State, ExistingPlotLineID = existingPlotLine?.PlotLineID, ExistingPlotThreadID = existingThread?.PlotThreadID, RelatedPlotLineID = existingThread?.PlotLineID, Confidence = signal.Confidence }); var sortOrder = 0; foreach (var item in signal.Evidence.OrderBy(item => item.SceneID)) { await candidates.SaveEvidenceAsync(candidateId, item.SceneID, item.ContributionType, item.Evidence, sortOrder++); } } } private async Task HasCommittedScenesAsync(OnboardingStoryIntelligenceBatch batch) { foreach (var item in batch.Items) { var commit = await runs.GetImportCommitAsync(item.RunID); if (commit is not null && string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase)) { return true; } } return false; } private static StoryIntelligenceNarrativeReviewCandidateViewModel ToViewModel(StoryIntelligenceNarrativeCandidate candidate) => new() { NarrativeCandidateID = candidate.NarrativeCandidateID, CandidateType = candidate.CandidateType, ProposedTitle = candidate.ProposedTitle, ProposedDescription = candidate.ProposedDescription ?? string.Empty, Category = candidate.Category ?? string.Empty, InferredState = candidate.InferredState ?? string.Empty, Confidence = candidate.Confidence.HasValue ? $"{candidate.Confidence.Value:P0}" : "Unknown", ExistingPlotLineID = candidate.ExistingPlotLineID, RelatedPlotLineID = candidate.RelatedPlotLineID, RelatedPlotLineName = candidate.RelatedPlotLineName, DefaultAction = candidate.ExistingPlotLineID.HasValue || candidate.ExistingPlotThreadID.HasValue ? StoryIntelligenceNarrativeReviewActions.MergeExisting : StoryIntelligenceNarrativeReviewActions.AcceptNew, Evidence = candidate.Evidence.Select(evidence => new StoryIntelligenceNarrativeEvidenceViewModel { SceneID = evidence.SceneID, SceneLabel = $"Chapter {evidence.ChapterNumber:0.##}, Scene {evidence.SceneNumber:0.##}: {evidence.SceneTitle}", ContributionType = evidence.ContributionType ?? string.Empty, Evidence = evidence.Evidence ?? string.Empty }).ToList() }; private static void AddSignal( Dictionary signals, OnboardingStoryIntelligenceBatch batch, Scene scene, string? title, string category, string state, string? evidence, decimal? confidence) { if (string.IsNullOrWhiteSpace(title) || title.Length < 12) { return; } var key = Key(title); if (!signals.TryGetValue(key, out var signal)) { signal = new NarrativeSignal(batch.ProjectID, batch.BookID, title, category, state); signals[key] = signal; } signal.SceneIds.Add(scene.SceneID); signal.Confidence = confidence.HasValue ? Math.Max(signal.Confidence ?? 0m, confidence.Value) : signal.Confidence; signal.Evidence.Add(new NarrativeSignalEvidence(scene.SceneID, category, evidence)); } private static Scene? ResolveImportedScene( StoryIntelligenceSavedSceneResult sceneResult, IReadOnlyDictionary importedByRange, IReadOnlyDictionary importedByNumber) { if (sceneResult.StartParagraph.HasValue && sceneResult.EndParagraph.HasValue && importedByRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var byRange)) { return byRange; } return importedByNumber.TryGetValue(sceneResult.TemporarySceneNumber, out var byNumber) ? byNumber : null; } private static SceneIntelligenceScene? TryReadScene(StoryIntelligenceSavedSceneResult result) { foreach (var json in new[] { result.ParsedJson, result.OutputTextJson }) { if (string.IsNullOrWhiteSpace(json)) { continue; } try { var direct = JsonSerializer.Deserialize(json, JsonOptions); if (direct?.Summary is not null) { return direct; } using var document = JsonDocument.Parse(json); if (document.RootElement.TryGetProperty("parsedScene", out var parsedScene)) { return parsedScene.Deserialize(JsonOptions); } } catch (JsonException) { // Try the next persisted JSON form. } } return null; } private static string CleanTitle(string? preferred, string fallback) => string.IsNullOrWhiteSpace(preferred) ? fallback.Trim() : preferred.Trim(); private static string? CleanNullable(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); private static string Key(string? value) => new((value ?? string.Empty).Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray()); private sealed record NarrativeSignal(int ProjectID, int BookID, string Title, string Category, string State) { public HashSet SceneIds { get; } = []; public List Evidence { get; } = []; public decimal? Confidence { get; set; } public string Description => $"{Category} recurring across {SceneIds.Count:N0} scenes."; } private sealed record NarrativeSignalEvidence(int SceneID, string ContributionType, string? Evidence); }