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(), CandidatePlotLineOptions = pending .Where(candidate => string.Equals(candidate.CandidateType, StoryIntelligenceNarrativeCandidateTypes.PlotLine, StringComparison.OrdinalIgnoreCase)) .OrderBy(candidate => candidate.ProposedTitle) .Select(candidate => new SelectListItem(candidate.ProposedTitle, candidate.NarrativeCandidateID.ToString())) .ToList(), Candidates = pending.Select(candidate => ToViewModel(candidate)).ToList(), PlotLineCandidates = BuildCandidateHierarchy(pending), UnassignedThreadCandidates = pending .Where(candidate => string.Equals(candidate.CandidateType, StoryIntelligenceNarrativeCandidateTypes.Thread, StringComparison.OrdinalIgnoreCase) && !candidate.ParentNarrativeCandidateID.HasValue && !candidate.RelatedPlotLineID.HasValue && !candidate.ExistingPlotThreadID.HasValue) .Select(candidate => ToViewModel(candidate)) .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; var choices = form.Candidates .Where(choice => choice.NarrativeCandidateID > 0) .ToDictionary(choice => choice.NarrativeCandidateID); var acceptedPlotLines = new Dictionary(); var mergedPlotLineTargets = new Dictionary(); foreach (var choice in choices.Values) { if (!rows.TryGetValue(choice.NarrativeCandidateID, out var candidate) || !string.Equals(candidate.Status, StoryIntelligenceNarrativeCandidateStatuses.Pending, StringComparison.OrdinalIgnoreCase)) { continue; } if (!string.Equals(candidate.CandidateType, StoryIntelligenceNarrativeCandidateTypes.PlotLine, 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(action, StoryIntelligenceNarrativeReviewActions.MergeCandidate, StringComparison.OrdinalIgnoreCase) && choice.MergeTargetNarrativeCandidateID is > 0 && rows.TryGetValue(choice.MergeTargetNarrativeCandidateID.Value, out var target) && string.Equals(target.CandidateType, candidate.CandidateType, StringComparison.OrdinalIgnoreCase)) { await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Merged, action, userId, null, null, target.NarrativeCandidateID); if (string.Equals(candidate.CandidateType, StoryIntelligenceNarrativeCandidateTypes.PlotLine, StringComparison.OrdinalIgnoreCase)) { plotLinesMerged++; } else { rejected++; } continue; } if (string.Equals(action, StoryIntelligenceNarrativeReviewActions.MergeExisting, StringComparison.OrdinalIgnoreCase) && choice.ExistingPlotLineID is > 0) { await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Merged, action, userId, choice.ExistingPlotLineID, null); mergedPlotLineTargets[candidate.NarrativeCandidateID] = choice.ExistingPlotLineID.Value; 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); acceptedPlotLines[candidate.NarrativeCandidateID] = plotLineId; plotLinesCreated++; continue; } foreach (var choice in choices.Values) { if (!rows.TryGetValue(choice.NarrativeCandidateID, out var candidate) || !string.Equals(candidate.Status, StoryIntelligenceNarrativeCandidateStatuses.Pending, StringComparison.OrdinalIgnoreCase) || string.Equals(candidate.CandidateType, StoryIntelligenceNarrativeCandidateTypes.PlotLine, 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(action, StoryIntelligenceNarrativeReviewActions.MergeCandidate, StringComparison.OrdinalIgnoreCase) && choice.MergeTargetNarrativeCandidateID is > 0 && rows.TryGetValue(choice.MergeTargetNarrativeCandidateID.Value, out var target) && string.Equals(target.CandidateType, candidate.CandidateType, StringComparison.OrdinalIgnoreCase)) { await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Merged, action, userId, null, null, target.NarrativeCandidateID); rejected++; continue; } var relatedPlotLineId = choice.RelatedPlotLineID ?? candidate.RelatedPlotLineID ?? choice.ExistingPlotLineID; if (relatedPlotLineId is not > 0 && choice.ParentNarrativeCandidateID is > 0) { relatedPlotLineId = ResolveCreatedOrMergedPlotLine(choice.ParentNarrativeCandidateID.Value, acceptedPlotLines, mergedPlotLineTargets, rows, choices); } if (relatedPlotLineId is not > 0 && candidate.ParentNarrativeCandidateID is > 0) { relatedPlotLineId = ResolveCreatedOrMergedPlotLine(candidate.ParentNarrativeCandidateID.Value, acceptedPlotLines, mergedPlotLineTargets, rows, choices); } 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 EffectiveEvidence(candidate, rows, choices).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 sceneResults = await runs.ListSceneResultsAsync(item.RunID); var chapterScenes = (await scenes.ListByChapterAsync(item.ChapterID)).ToList(); var importedScenes = sceneResults.Any(result => result.SceneID.HasValue) ? chapterScenes : chapterScenes.Where(scene => scene.ImportRunID == item.RunID).ToList(); var importedById = importedScenes.ToDictionary(scene => scene.SceneID, scene => scene); 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); foreach (var sceneResult in sceneResults) { var importedScene = ResolveImportedScene(sceneResult, importedById, 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.ChangeType) ?? CleanNullable(arc.ArcType) ?? "Developed", CleanNullable(arc.Significance) ?? (string.Equals(category, "Plot Line", StringComparison.OrdinalIgnoreCase) ? "major" : "moderate"), CleanNullable(arc.ContinuityKey), CleanNullable(arc.ParentArcTitle), CleanNullable(arc.ArcType) ?? "Narrative arc", arc.Confidence, arc.MatchingExistingPlotLineId, arc.MatchingExistingThreadId, arc.Evidence)); } foreach (var question in parsed.QuestionsRaised ?? []) { signalInputs.Add(new StoryIntelligenceNarrativeSignalInput( importedScene.SceneID, CleanNullable(question.Question), null, "Thread", "Open", IsPlotScope(question.Scope) ? "minor" : "minor", null, null, CleanNullable(question.Scope) ?? "question", question.Confidence, null, null, question.Evidence)); } foreach (var clue in parsed.TimelineClues ?? []) { signalInputs.Add(new StoryIntelligenceNarrativeSignalInput( importedScene.SceneID, CleanNullable(clue.Clue), null, "Thread", "Introduced", "minor", null, null, "clue", clue.Confidence, null, null, clue.Evidence)); } } } var signals = StoryIntelligenceNarrativeSignalConsolidator.Consolidate(signalInputs, existingPlotLines, existingThreads); var savedByKey = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var signal in signals.Where(signal => signal.ShouldCreateCandidate).OrderBy(signal => signal.ParentCandidateKey is null ? 0 : 1).ThenBy(signal => signal.Title)) { var existingThread = signal.ExistingPlotThreadID.HasValue ? existingThreads.FirstOrDefault(thread => thread.PlotThreadID == signal.ExistingPlotThreadID.Value) : 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 candidateKey = $"{candidateType}:{signal.Key}"; var parentCandidateId = signal.ParentCandidateKey is not null && savedByKey.TryGetValue($"PlotLine:{signal.ParentCandidateKey}", out var savedParentId) ? savedParentId : (int?)null; var candidateId = await candidates.SaveAsync(new StoryIntelligenceNarrativeCandidateSeed { ProjectID = batch.ProjectID, BookID = batch.BookID, CandidateType = candidateType, CandidateKey = candidateKey, ProposedTitle = signal.Title, ProposedDescription = signal.Description, Category = signal.Category, InferredState = signal.State, ChangeType = signal.ChangeType, Significance = signal.Significance, ExistingPlotLineID = signal.ExistingPlotLineID, ExistingPlotThreadID = existingThread?.PlotThreadID, RelatedPlotLineID = existingThread?.PlotLineID ?? signal.RelatedPlotLineID, ParentCandidateKey = signal.ParentCandidateKey is null ? null : $"PlotLine:{signal.ParentCandidateKey}", ParentNarrativeCandidateID = parentCandidateId, Confidence = signal.Confidence }); savedByKey[candidateKey] = candidateId; var sortOrder = 0; foreach (var item in signal.Evidence) { 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 IReadOnlyList BuildCandidateHierarchy(IReadOnlyList candidates) { var childrenByParent = candidates .Where(candidate => candidate.ParentNarrativeCandidateID.HasValue) .GroupBy(candidate => candidate.ParentNarrativeCandidateID!.Value) .ToDictionary(group => group.Key, group => group.Select(candidate => ToViewModel(candidate)).ToList()); return candidates .Where(candidate => !candidate.ParentNarrativeCandidateID.HasValue) .Select(candidate => ToViewModel(candidate, childrenByParent.GetValueOrDefault(candidate.NarrativeCandidateID) ?? [])) .ToList(); } private static StoryIntelligenceNarrativeReviewCandidateViewModel ToViewModel(StoryIntelligenceNarrativeCandidate candidate, IReadOnlyList? childThreads = null) => 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, ChangeType = candidate.ChangeType ?? string.Empty, Significance = candidate.Significance ?? string.Empty, Confidence = candidate.Confidence.HasValue ? $"{candidate.Confidence.Value:P0}" : "Unknown", ExistingPlotLineID = candidate.ExistingPlotLineID, RelatedPlotLineID = candidate.RelatedPlotLineID, RelatedPlotLineName = candidate.RelatedPlotLineName, ParentNarrativeCandidateID = candidate.ParentNarrativeCandidateID, ParentCandidateTitle = candidate.ParentCandidateTitle, ChildThreads = childThreads ?? [], 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 importedById, IReadOnlyDictionary importedByRange, IReadOnlyDictionary importedByNumber) { if (sceneResult.SceneID.HasValue && importedById.TryGetValue(sceneResult.SceneID.Value, out var byId)) { return byId; } 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 bool IsPlotScope(string? scope) => string.Equals(scope, "plot", StringComparison.OrdinalIgnoreCase); private static int? ResolveCreatedOrMergedPlotLine( int candidateId, IReadOnlyDictionary acceptedPlotLines, IReadOnlyDictionary mergedPlotLineTargets, IReadOnlyDictionary candidates, IReadOnlyDictionary choices) { if (choices.TryGetValue(candidateId, out var choice) && string.Equals(choice.Action, StoryIntelligenceNarrativeReviewActions.MergeCandidate, StringComparison.OrdinalIgnoreCase) && choice.MergeTargetNarrativeCandidateID is > 0 && choice.MergeTargetNarrativeCandidateID.Value != candidateId) { return ResolveCreatedOrMergedPlotLine(choice.MergeTargetNarrativeCandidateID.Value, acceptedPlotLines, mergedPlotLineTargets, candidates, choices); } if (acceptedPlotLines.TryGetValue(candidateId, out var acceptedId)) { return acceptedId; } if (mergedPlotLineTargets.TryGetValue(candidateId, out var mergedId)) { return mergedId; } if (candidates.TryGetValue(candidateId, out var candidate)) { return candidate.AcceptedPlotLineID ?? candidate.ExistingPlotLineID; } return null; } private static IReadOnlyList EffectiveEvidence( StoryIntelligenceNarrativeCandidate candidate, IReadOnlyDictionary candidates, IReadOnlyDictionary choices) { var rows = new List(); rows.AddRange(candidate.Evidence); foreach (var choice in choices.Values) { if (choice.MergeTargetNarrativeCandidateID != candidate.NarrativeCandidateID || !string.Equals(choice.Action, StoryIntelligenceNarrativeReviewActions.MergeCandidate, StringComparison.OrdinalIgnoreCase) || !candidates.TryGetValue(choice.NarrativeCandidateID, out var merged) || !string.Equals(merged.CandidateType, candidate.CandidateType, StringComparison.OrdinalIgnoreCase)) { continue; } rows.AddRange(merged.Evidence); } return rows .Where(evidence => evidence.SceneID > 0) .GroupBy(evidence => new { evidence.SceneID, evidence.ContributionType, Evidence = evidence.Evidence ?? string.Empty }) .Select(group => group.First()) .OrderBy(evidence => evidence.SortOrder) .ThenBy(evidence => evidence.SceneID) .ToList(); } private static string Key(string? value) => StoryIntelligenceNarrativeSignalConsolidator.Key(value); } public sealed record StoryIntelligenceNarrativeSignalInput( int SceneID, string? Title, string? Description, string Category, string State, string Significance, string? ContinuityKey, string? ParentTitle, string ArcType, decimal? Confidence, int? MatchingExistingPlotLineID, int? MatchingExistingThreadID, string? Evidence); public sealed record StoryIntelligenceNarrativeSignalEvidence(int SceneID, string ContributionType, string? Evidence); public sealed record StoryIntelligenceNarrativeConsolidatedSignal( string Key, string Title, string Description, string Category, string State, string ChangeType, string Significance, string? ParentCandidateKey, int? ExistingPlotLineID, int? ExistingPlotThreadID, int? RelatedPlotLineID, decimal? Confidence, IReadOnlyCollection SceneIds, IReadOnlyList Evidence) { public bool ShouldCreateCandidate => string.Equals(Category, "Plot Line", StringComparison.OrdinalIgnoreCase) ? IsSignificant(Significance) && (SceneIds.Count > 1 || ExistingPlotLineID.HasValue || HasTerminalOrMajorChange) : IsSignificant(Significance) && (SceneIds.Count > 1 || HasTerminalOrMajorChange || ExistingPlotThreadID.HasValue); private bool HasTerminalOrMajorChange => string.Equals(Significance, "major", StringComparison.OrdinalIgnoreCase) || Evidence.Any(evidence => IsTerminalChange(evidence.ContributionType)); private static bool IsSignificant(string? value) => !string.Equals(value, "minor", StringComparison.OrdinalIgnoreCase); private static bool IsTerminalChange(string? value) => string.Equals(value, "Resolved", StringComparison.OrdinalIgnoreCase) || string.Equals(value, "Reopened", StringComparison.OrdinalIgnoreCase) || string.Equals(value, "Revealed", StringComparison.OrdinalIgnoreCase) || string.Equals(value, "Escalated", StringComparison.OrdinalIgnoreCase) || string.Equals(value, "Complicated", StringComparison.OrdinalIgnoreCase); } 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, IReadOnlyList? existingThreads = null) { var groups = new Dictionary(StringComparer.OrdinalIgnoreCase); var plotLinesById = existingPlotLines.Where(plotLine => plotLine.PlotLineID > 0).ToDictionary(plotLine => plotLine.PlotLineID); var threadsById = (existingThreads ?? []) .Where(thread => thread.PlotThreadID > 0) .ToDictionary(thread => thread.PlotThreadID); foreach (var input in inputs) { if (string.IsNullOrWhiteSpace(input.Title) || input.Title.Trim().Length < 8) { continue; } var matchingPlotLine = ResolveExistingPlotLine(input, existingPlotLines, plotLinesById); var matchingThread = input.MatchingExistingThreadID is > 0 && threadsById.TryGetValue(input.MatchingExistingThreadID.Value, out var suppliedThread) ? suppliedThread : ResolveExistingThread(input, existingThreads ?? []); var key = matchingPlotLine is not null ? $"existing:{matchingPlotLine.PlotLineID}" : matchingThread is not null ? $"existing-thread:{matchingThread.PlotThreadID}" : FindStructuredGroupKey(groups, input) ?? ArcKey(input.Title, input.Description, input.ContinuityKey, input.ArcType); if (!groups.TryGetValue(key, out var signal)) { var terms = Terms($"{input.Title} {input.Description} {input.ContinuityKey} {input.ArcType}"); var category = ClassifyCategory(input, matchingPlotLine, matchingThread); signal = new NarrativeSignal( KeyFromInput(input), input.Title.Trim(), string.IsNullOrWhiteSpace(input.Description) ? $"{category} recurring across scenes." : input.Description.Trim(), category, NormalizeChangeType(input.State), NormalizeChangeType(input.State), NormalizeSignificance(input.Significance), ParentCandidateKey(input.ParentTitle), matchingPlotLine?.PlotLineID, matchingThread?.PlotThreadID, matchingThread?.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.Significance = MaxSignificance(signal.Significance, input.Significance); signal.ChangeType = LatestChange(signal.ChangeType, input.State); var contributionType = NormalizeChangeType(input.State); if (!signal.Evidence.Any(evidence => evidence.SceneID == input.SceneID && string.Equals(evidence.ContributionType, contributionType, StringComparison.OrdinalIgnoreCase) && string.Equals(evidence.Evidence ?? string.Empty, input.Evidence ?? string.Empty, StringComparison.OrdinalIgnoreCase))) { signal.Evidence.Add(new StoryIntelligenceNarrativeSignalEvidence(input.SceneID, contributionType, input.Evidence)); } } return groups.Values .Select(signal => new StoryIntelligenceNarrativeConsolidatedSignal( signal.Key, signal.Title, signal.Description, signal.Category, signal.State, signal.ChangeType, signal.Significance, signal.ParentCandidateKey, signal.ExistingPlotLineID, signal.ExistingPlotThreadID, signal.RelatedPlotLineID, signal.Confidence, signal.SceneIds, signal.Evidence.OrderBy(evidence => evidence.SceneID).ToList())) .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} {input.ContinuityKey} {input.ArcType}"); 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, input.ContinuityKey, input.ArcType) == ArcKey(plotLine.PlotLineName, plotLine.Description, null, null); } private static PlotThread? ResolveExistingThread(StoryIntelligenceNarrativeSignalInput input, IReadOnlyList existingThreads) => existingThreads.FirstOrDefault(thread => { var signalTerms = Terms($"{input.Title} {input.Description} {input.ContinuityKey} {input.ArcType}"); var threadTerms = Terms($"{thread.ThreadTitle} {thread.Summary}"); if (signalTerms.Count == 0 || threadTerms.Count == 0) { return false; } var shared = signalTerms.Intersect(threadTerms, StringComparer.OrdinalIgnoreCase).Count(); return shared >= 2 && shared / (decimal)Math.Min(signalTerms.Count, threadTerms.Count) >= 0.5m; }); private static string? FindStructuredGroupKey( IReadOnlyDictionary groups, StoryIntelligenceNarrativeSignalInput input) { var terms = Terms($"{input.Title} {input.Description} {input.ContinuityKey} {input.ArcType}"); var family = ArcFamily(terms); var continuityKey = Key(input.ContinuityKey); var parentKey = ParentCandidateKey(input.ParentTitle); if (terms.Count == 0) { return null; } foreach (var group in groups) { if (group.Value.ExistingPlotLineID.HasValue || group.Value.ExistingPlotThreadID.HasValue) { continue; } if (!string.Equals(group.Value.Category, input.Category, StringComparison.OrdinalIgnoreCase)) { continue; } if (continuityKey.Length > 0 && string.Equals(group.Value.Key, continuityKey, StringComparison.OrdinalIgnoreCase)) { return group.Key; } if (!string.IsNullOrWhiteSpace(parentKey) && string.Equals(group.Value.ParentCandidateKey, parentKey, StringComparison.OrdinalIgnoreCase) && string.Equals(group.Value.Family, family, StringComparison.OrdinalIgnoreCase)) { return group.Key; } if (!string.Equals(group.Value.Family, family, StringComparison.OrdinalIgnoreCase)) { continue; } var sharedTerms = group.Value.Terms.Intersect(terms, StringComparer.OrdinalIgnoreCase).Count(); if (sharedTerms >= 2 || (sharedTerms >= 1 && IsStrongProgression(input.State))) { return group.Key; } } return null; } private static string ArcKey(string? title, string? description, string? continuityKey, string? arcType) { var explicitKey = Key(continuityKey); if (explicitKey.Length > 0) { return $"continuity:{explicitKey}"; } var terms = Terms($"{title} {description} {arcType}"); 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 string KeyFromInput(StoryIntelligenceNarrativeSignalInput input) { var explicitKey = Key(input.ContinuityKey); return explicitKey.Length > 0 ? explicitKey : Key($"{input.Title} {input.ArcType}"); } private static string? ParentCandidateKey(string? parentTitle) { var key = Key(parentTitle); return key.Length == 0 ? null : $"continuity:{key}"; } private static string ClassifyCategory( StoryIntelligenceNarrativeSignalInput input, PlotLineItem? matchingPlotLine, PlotThread? matchingThread) { if (matchingThread is not null) { return "Thread"; } if (matchingPlotLine is not null) { return "Plot Line"; } if (string.Equals(input.Category, "Plot Line", StringComparison.OrdinalIgnoreCase) && (string.Equals(input.Significance, "major", StringComparison.OrdinalIgnoreCase) || string.Equals(input.State, "Resolved", StringComparison.OrdinalIgnoreCase) || string.Equals(input.State, "Reopened", StringComparison.OrdinalIgnoreCase))) { return "Plot Line"; } return string.Equals(input.Category, "Plot Line", StringComparison.OrdinalIgnoreCase) ? "Plot Line" : "Thread"; } private static string NormalizeChangeType(string? value) { if (string.IsNullOrWhiteSpace(value)) { return "Developed"; } var normalized = value.Trim(); return normalized.Equals("Open", StringComparison.OrdinalIgnoreCase) ? "Introduced" : normalized; } private static string NormalizeSignificance(string? value) => string.Equals(value, "major", StringComparison.OrdinalIgnoreCase) ? "major" : string.Equals(value, "moderate", StringComparison.OrdinalIgnoreCase) ? "moderate" : "minor"; private static string MaxSignificance(string current, string next) { var currentRank = SignificanceRank(current); var nextRank = SignificanceRank(next); return nextRank > currentRank ? NormalizeSignificance(next) : NormalizeSignificance(current); } private static int SignificanceRank(string? value) => string.Equals(value, "major", StringComparison.OrdinalIgnoreCase) ? 3 : string.Equals(value, "moderate", StringComparison.OrdinalIgnoreCase) ? 2 : 1; private static string LatestChange(string current, string next) => IsTerminalOrEscalating(next) ? NormalizeChangeType(next) : current; private static bool IsStrongProgression(string? value) => IsTerminalOrEscalating(value) || string.Equals(value, "Developed", StringComparison.OrdinalIgnoreCase); private static bool IsTerminalOrEscalating(string? value) => string.Equals(value, "Resolved", StringComparison.OrdinalIgnoreCase) || string.Equals(value, "Reopened", StringComparison.OrdinalIgnoreCase) || string.Equals(value, "Revealed", StringComparison.OrdinalIgnoreCase) || string.Equals(value, "Escalated", StringComparison.OrdinalIgnoreCase) || string.Equals(value, "Complicated", StringComparison.OrdinalIgnoreCase); 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 class NarrativeSignal( string key, string title, string description, string category, string state, string changeType, string significance, string? parentCandidateKey, int? existingPlotLineId, int? existingPlotThreadId, int? relatedPlotLineId, string family, HashSet terms) { public string Key { get; } = key; public string Title { get; } = title; public string Description { get; } = description; public string Category { get; } = category; public string State { get; } = state; public string ChangeType { get; set; } = changeType; public string Significance { get; set; } = significance; public string? ParentCandidateKey { get; } = parentCandidateKey; public int? ExistingPlotLineID { get; } = existingPlotLineId; public int? ExistingPlotThreadID { get; } = existingPlotThreadId; public int? RelatedPlotLineID { get; } = relatedPlotLineId; public string Family { get; } = family; public HashSet Terms { get; } = terms; public HashSet SceneIds { get; } = []; public List Evidence { get; } = []; public decimal? Confidence { get; set; } } }