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 existingThreadByKey = existingThreads .GroupBy(thread => Key(thread.ThreadTitle), StringComparer.OrdinalIgnoreCase) .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); var signalInputs = new List(); 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 arc in parsed.NarrativeArcs ?? []) { var category = string.Equals(arc.Scale, "thread", StringComparison.OrdinalIgnoreCase) ? "Thread" : "Plot Line"; signalInputs.Add(new StoryIntelligenceNarrativeSignalInput( importedScene.SceneID, CleanNullable(arc.Title), CleanNullable(arc.Description), category, CleanNullable(arc.ArcType) ?? "Narrative arc", arc.Confidence, arc.MatchingExistingPlotLineId, arc.Evidence)); } foreach (var question in parsed.QuestionsRaised ?? []) { signalInputs.Add(new StoryIntelligenceNarrativeSignalInput( importedScene.SceneID, CleanNullable(question.Question), null, "Thread", "Open", question.Confidence, null, question.Evidence)); } foreach (var clue in parsed.TimelineClues ?? []) { signalInputs.Add(new StoryIntelligenceNarrativeSignalInput( importedScene.SceneID, CleanNullable(clue.Clue), null, "Thread", "Introduced", clue.Confidence, null, clue.Evidence)); } } } var signals = StoryIntelligenceNarrativeSignalConsolidator.Consolidate(signalInputs, existingPlotLines); foreach (var signal in signals.Where(signal => signal.SceneIds.Count > 1).OrderBy(signal => signal.Title)) { var existingThread = existingThreadByKey.GetValueOrDefault(Key(signal.Title)); var candidateType = string.Equals(signal.Category, "Plot Line", StringComparison.OrdinalIgnoreCase) || (signal.ExistingPlotLineID.HasValue && 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 = signal.ExistingPlotLineID, 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 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) => StoryIntelligenceNarrativeSignalConsolidator.Key(value); } public sealed record StoryIntelligenceNarrativeSignalInput( int SceneID, string? Title, string? Description, string Category, string State, decimal? Confidence, int? MatchingExistingPlotLineID, string? Evidence); public sealed record StoryIntelligenceNarrativeSignalEvidence(int SceneID, string ContributionType, string? Evidence); public sealed record StoryIntelligenceNarrativeConsolidatedSignal( string Title, string Description, string Category, string State, int? ExistingPlotLineID, decimal? Confidence, IReadOnlyCollection SceneIds, IReadOnlyList Evidence); public static class StoryIntelligenceNarrativeSignalConsolidator { private static readonly HashSet StopWords = new(StringComparer.OrdinalIgnoreCase) { "a", "an", "and", "are", "as", "at", "be", "by", "for", "from", "has", "have", "her", "his", "how", "in", "into", "is", "it", "its", "of", "on", "or", "she", "he", "that", "the", "their", "them", "they", "this", "to", "what", "when", "where", "who", "why", "with", "about", "after", "before", "looks", "looking", "case", "plot", "line", "story", "arc" }; private static readonly Dictionary ArcFamilies = new(StringComparer.OrdinalIgnoreCase) { ["absence"] = "disappearance", ["absent"] = "disappearance", ["clue"] = "disappearance", ["clues"] = "disappearance", ["disappear"] = "disappearance", ["disappearance"] = "disappearance", ["disappeared"] = "disappearance", ["disappears"] = "disappearance", ["find"] = "disappearance", ["finding"] = "disappearance", ["found"] = "disappearance", ["investigate"] = "disappearance", ["investigates"] = "disappearance", ["investigation"] = "disappearance", ["lead"] = "disappearance", ["leads"] = "disappearance", ["missing"] = "disappearance", ["search"] = "disappearance", ["searches"] = "disappearance", ["searching"] = "disappearance", ["suspect"] = "disappearance", ["suspects"] = "disappearance", ["gone"] = "disappearance", ["went"] = "disappearance", ["conspiracy"] = "conspiracy", ["quest"] = "quest", ["romance"] = "romance", ["secret"] = "secret", ["secrets"] = "secret", ["revenge"] = "revenge", ["rivalry"] = "rivalry" }; public static IReadOnlyList Consolidate( IEnumerable inputs, IReadOnlyList existingPlotLines) { var groups = new Dictionary(StringComparer.OrdinalIgnoreCase); var plotLinesById = existingPlotLines.Where(plotLine => plotLine.PlotLineID > 0).ToDictionary(plotLine => plotLine.PlotLineID); foreach (var input in inputs) { if (string.IsNullOrWhiteSpace(input.Title) || input.Title.Trim().Length < 8) { continue; } var matchingPlotLine = ResolveExistingPlotLine(input, existingPlotLines, plotLinesById); var key = matchingPlotLine is not null ? $"existing:{matchingPlotLine.PlotLineID}" : FindSimilarGroupKey(groups, input) ?? ArcKey(input.Title, input.Description); if (!groups.TryGetValue(key, out var signal)) { var terms = Terms($"{input.Title} {input.Description}"); signal = new NarrativeSignal( input.Title.Trim(), string.IsNullOrWhiteSpace(input.Description) ? $"{input.Category} recurring across scenes." : input.Description.Trim(), matchingPlotLine is not null ? "Plot Line" : input.Category, input.State, matchingPlotLine?.PlotLineID, ArcFamily(terms), terms.ToHashSet(StringComparer.OrdinalIgnoreCase)); groups[key] = signal; } signal.SceneIds.Add(input.SceneID); signal.Confidence = input.Confidence.HasValue ? Math.Max(signal.Confidence ?? 0m, input.Confidence.Value) : signal.Confidence; signal.Evidence.Add(new StoryIntelligenceNarrativeSignalEvidence(input.SceneID, input.Category, input.Evidence)); } return groups.Values .Select(signal => new StoryIntelligenceNarrativeConsolidatedSignal( signal.Title, signal.Description, signal.Category, signal.State, signal.ExistingPlotLineID, signal.Confidence, signal.SceneIds, signal.Evidence)) .ToList(); } public static string Key(string? value) => new((value ?? string.Empty).Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray()); private static PlotLineItem? ResolveExistingPlotLine( StoryIntelligenceNarrativeSignalInput input, IReadOnlyList existingPlotLines, IReadOnlyDictionary plotLinesById) { if (input.MatchingExistingPlotLineID is > 0 && plotLinesById.TryGetValue(input.MatchingExistingPlotLineID.Value, out var suppliedMatch)) { return suppliedMatch; } return existingPlotLines.FirstOrDefault(plotLine => HasSharedArcIdentity(input, plotLine)); } private static bool HasSharedArcIdentity(StoryIntelligenceNarrativeSignalInput input, PlotLineItem plotLine) { var signalTerms = Terms($"{input.Title} {input.Description}"); var plotLineTerms = Terms($"{plotLine.PlotLineName} {plotLine.Description}"); if (signalTerms.Count == 0 || plotLineTerms.Count == 0) { return false; } var shared = signalTerms.Intersect(plotLineTerms, StringComparer.OrdinalIgnoreCase).Count(); var smaller = Math.Min(signalTerms.Count, plotLineTerms.Count); if (shared >= 2 && shared / (decimal)smaller >= 0.5m) { return true; } if (ArcFamily(signalTerms) == ArcFamily(plotLineTerms) && signalTerms.Intersect(plotLineTerms, StringComparer.OrdinalIgnoreCase).Any()) { return true; } return ArcKey(input.Title, input.Description) == ArcKey(plotLine.PlotLineName, plotLine.Description); } private static string? FindSimilarGroupKey( IReadOnlyDictionary groups, StoryIntelligenceNarrativeSignalInput input) { var terms = Terms($"{input.Title} {input.Description}"); var family = ArcFamily(terms); if (terms.Count == 0) { return null; } foreach (var group in groups) { if (group.Value.ExistingPlotLineID.HasValue || !string.Equals(group.Value.Family, family, StringComparison.OrdinalIgnoreCase)) { continue; } if (group.Value.Terms.Intersect(terms, StringComparer.OrdinalIgnoreCase).Any()) { return group.Key; } } return null; } private static string ArcKey(string? title, string? description) { var terms = Terms($"{title} {description}"); var family = ArcFamily(terms); var anchors = terms .Where(term => !ArcFamilies.ContainsKey(term)) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(term => term) .Take(3) .ToList(); if (anchors.Count == 0) { anchors = terms.Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(term => term).Take(3).ToList(); } return $"{family}:{string.Join('-', anchors)}"; } private static string ArcFamily(IEnumerable terms) => terms.Select(term => ArcFamilies.GetValueOrDefault(term)).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? "general"; private static List Terms(string? value) => (value ?? string.Empty) .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(NormalizeTerm) .Where(term => term.Length > 2 && !StopWords.Contains(term)) .ToList(); private static string NormalizeTerm(string value) { var term = new string(value.Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray()); return term.EndsWith('s') && term.Length > 4 ? term[..^1] : term; } private sealed record NarrativeSignal( string Title, string Description, string Category, string State, int? ExistingPlotLineID, string Family, HashSet Terms) { public HashSet SceneIds { get; } = []; public List Evidence { get; } = []; public decimal? Confidence { get; set; } } }