using System.Text.Json; using System.Data; using Dapper; 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 EnsureCandidatesAsync(OnboardingStoryIntelligenceBatch batch); Task> ListCandidatesByBookAsync(int bookId); Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceNarrativeImportForm form, int userId); Task GetResultAsync(OnboardingStoryIntelligenceBatch batch); } public sealed class StoryIntelligenceNarrativeReviewService( IStoryIntelligenceNarrativeCandidateRepository candidates, IStoryIntelligenceResultRepository runs, ISceneRepository scenes, IPlotRepository plots, ISqlConnectionFactory connectionFactory, IStoryIntelligencePipelineStateService pipelineState) : IStoryIntelligenceNarrativeReviewService { private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true }; public async Task GetReviewAsync(OnboardingStoryIntelligenceBatch batch) { var rows = (await candidates.ListByBookAsync(batch.BookID)) .Where(IsWholeBookCandidate) .ToList(); 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 Task> ListCandidatesByBookAsync(int bookId) => candidates.ListByBookAsync(bookId); public async Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceNarrativeImportForm form, int userId) { var rows = (await candidates.ListByBookAsync(batch.BookID)) .Where(IsWholeBookCandidate) .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 threadEventTypeByName = lookups.ThreadEventTypes .GroupBy(type => type.TypeName, StringComparer.OrdinalIgnoreCase) .ToDictionary(group => group.Key, group => group.First().ThreadEventTypeID, StringComparer.OrdinalIgnoreCase); var threadEventTypeId = threadEventTypeByName.TryGetValue("Mentioned", out var mentionedTypeId) ? mentionedTypeId : 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; using var connection = connectionFactory.CreateConnection(); connection.Open(); using var transaction = connection.BeginTransaction(); try { 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 MarkReviewedAsync(connection, transaction, 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 MarkReviewedAsync(connection, transaction, 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 MarkReviewedAsync(connection, transaction, candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Merged, action, userId, choice.ExistingPlotLineID, null); mergedPlotLineTargets[candidate.NarrativeCandidateID] = choice.ExistingPlotLineID.Value; plotLinesMerged++; continue; } var plotLineId = await SavePlotLineAsync(connection, transaction, 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 MarkReviewedAsync(connection, transaction, 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 MarkReviewedAsync(connection, transaction, 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 MarkReviewedAsync(connection, transaction, 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 MarkReviewedAsync(connection, transaction, candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Rejected, StoryIntelligenceNarrativeReviewActions.Reject, userId, null, null); rejected++; continue; } var effectiveEvents = EffectiveEvents(candidate, rows, choices).ToList(); var firstSceneId = effectiveEvents.OrderBy(item => item.SortOrder).FirstOrDefault()?.SceneID ?? candidate.Evidence.OrderBy(evidence => evidence.SortOrder).FirstOrDefault()?.SceneID; var threadId = await SavePlotThreadAsync(connection, transaction, 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 }); if (effectiveEvents.Count > 0) { foreach (var threadEvent in effectiveEvents.Where(item => item.SceneID > 0)) { await SaveThreadEventAsync(connection, transaction, new ThreadEvent { PlotThreadID = threadId, SceneID = threadEvent.SceneID, EventTypeID = threadEventTypeByName.GetValueOrDefault( string.IsNullOrWhiteSpace(threadEvent.EventType) ? "Mentioned" : threadEvent.EventType, threadEventTypeId), PlotEventTypeID = plotEventTypeId, EventTitle = CleanTitle(threadEvent.EventTitle, candidate.ProposedTitle), EventDescription = CleanNullable(threadEvent.EventDescription) ?? threadEvent.Evidence, Source = WholeBookPlotSynthesisSources.WholeBook, SourceReferenceID = threadEvent.NarrativeCandidateEventID, SourceKey = $"{threadEvent.WholeBookPlotSynthesisRunID}:{threadEvent.NarrativeCandidateID}:{threadEvent.EventKey}", Confidence = threadEvent.Confidence }); } } else { foreach (var evidence in EffectiveEvidence(candidate, rows, choices).Where(evidence => evidence.SceneID > 0)) { await SaveThreadEventAsync(connection, transaction, new ThreadEvent { PlotThreadID = threadId, SceneID = evidence.SceneID, EventTypeID = threadEventTypeByName.GetValueOrDefault( string.IsNullOrWhiteSpace(evidence.ContributionType) ? "Mentioned" : evidence.ContributionType, threadEventTypeId), PlotEventTypeID = plotEventTypeId, EventTitle = string.IsNullOrWhiteSpace(evidence.ContributionType) ? candidate.ProposedTitle : evidence.ContributionType, EventDescription = evidence.Evidence, Source = WholeBookPlotSynthesisSources.WholeBook, SourceReferenceID = candidate.NarrativeCandidateID, SourceKey = $"{candidate.WholeBookPlotSynthesisRunID}:{candidate.NarrativeCandidateID}:{evidence.SceneID}:{evidence.SortOrder}", Confidence = candidate.Confidence }); } } await MarkReviewedAsync(connection, transaction, candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Accepted, action, userId, null, threadId); threadsCreated++; } transaction.Commit(); } catch { transaction.Rollback(); throw; } 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 }); public 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 run = await runs.GetRunAsync(item.RunID); if (run?.Status is not (StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings)) { continue; } var sceneResults = await runs.ListSceneResultsAsync(item.RunID); if (sceneResults.Count == 0) { continue; } 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 run = await runs.GetRunAsync(item.RunID); if (run?.Status is not (StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings)) { continue; } var sceneResults = await runs.ListSceneResultsAsync(item.RunID); if (sceneResults.Count > 0) { return true; } } return false; } private static Task SavePlotLineAsync(IDbConnection connection, IDbTransaction transaction, PlotLineItem plotLine) => connection.QuerySingleAsync( "dbo.PlotLine_Save", new { plotLine.PlotLineID, plotLine.ProjectID, plotLine.BookID, plotLine.PlotLineName, plotLine.PlotLineTypeID, plotLine.PlotImportanceID, plotLine.Description, plotLine.ParentPlotLineID, plotLine.EmergesFromPlotLineID, plotLine.SortOrder, plotLine.Colour, plotLine.IsVisibleOnTimeline }, transaction, commandType: CommandType.StoredProcedure); private static Task SavePlotThreadAsync(IDbConnection connection, IDbTransaction transaction, PlotThread plotThread) => connection.QuerySingleAsync( "dbo.PlotThread_Save", new { plotThread.PlotThreadID, plotThread.PlotLineID, plotThread.ThreadTitle, plotThread.ThreadTypeID, plotThread.ThreadStatusID, plotThread.Importance, plotThread.Summary, plotThread.IntroducedSceneID, plotThread.PlannedResolutionSceneID, plotThread.ActualResolutionSceneID }, transaction, commandType: CommandType.StoredProcedure); private static Task SaveThreadEventAsync(IDbConnection connection, IDbTransaction transaction, ThreadEvent threadEvent) => connection.QuerySingleAsync( "dbo.ThreadEvent_Save", new { threadEvent.ThreadEventID, threadEvent.PlotThreadID, threadEvent.SceneID, threadEvent.EventTypeID, threadEvent.PlotEventTypeID, threadEvent.TargetPlotLineID, threadEvent.EventTitle, threadEvent.EventDescription, threadEvent.Source, threadEvent.SourceReferenceID, threadEvent.SourceKey, threadEvent.Confidence }, transaction, commandType: CommandType.StoredProcedure); private static Task MarkReviewedAsync( IDbConnection connection, IDbTransaction transaction, int candidateId, string status, string reviewAction, int userId, int? acceptedPlotLineId, int? acceptedPlotThreadId, int? mergeTargetCandidateId = null) => connection.ExecuteAsync( "dbo.StoryIntelligenceNarrativeCandidate_MarkReviewed", new { NarrativeCandidateID = candidateId, Status = status, ReviewAction = reviewAction, ReviewedByUserID = userId, AcceptedPlotLineID = acceptedPlotLineId, AcceptedPlotThreadID = acceptedPlotThreadId, MergeTargetNarrativeCandidateID = mergeTargetCandidateId }, transaction, commandType: CommandType.StoredProcedure); 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(), Events = candidate.Events.Select(item => new StoryIntelligenceNarrativeEventViewModel { NarrativeCandidateEventID = item.NarrativeCandidateEventID, SceneID = item.SceneID, SceneLabel = $"Chapter {item.ChapterNumber:0.##}, Scene {item.SceneNumber:0.##}: {item.SceneTitle}", EventType = item.EventType, EventTitle = item.EventTitle, EventDescription = item.EventDescription ?? string.Empty, Evidence = item.Evidence ?? string.Empty, Confidence = item.Confidence.HasValue ? $"{item.Confidence.Value:P0}" : "Unknown" }).ToList() }; private static bool IsWholeBookCandidate(StoryIntelligenceNarrativeCandidate candidate) => string.Equals(candidate.Source, WholeBookPlotSynthesisSources.WholeBook, StringComparison.OrdinalIgnoreCase) || candidate.WholeBookPlotSynthesisRunID.HasValue; 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 is not null && IsReadableSceneResult(direct)) { 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 bool IsReadableSceneResult(SceneIntelligenceScene scene) => !string.IsNullOrWhiteSpace(scene.SchemaVersion) || scene.Summary is not null || scene.NarrativeArcs?.Count > 0 || scene.QuestionsRaised?.Count > 0 || scene.TimelineClues?.Count > 0 || scene.MetricValues?.Count > 0 || scene.Metrics?.Count > 0; 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 IReadOnlyList EffectiveEvents( StoryIntelligenceNarrativeCandidate candidate, IReadOnlyDictionary candidates, IReadOnlyDictionary choices) { var rows = new List(); AddEvents(candidate, rows, choices); 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; } AddEvents(merged, rows, choices); } return rows .Where(item => item.SceneID > 0) .GroupBy(item => new { item.NarrativeCandidateEventID, item.SceneID, item.EventKey }) .Select(group => group.First()) .OrderBy(item => item.SortOrder) .ThenBy(item => item.SceneID) .ToList(); } private static void AddEvents( StoryIntelligenceNarrativeCandidate candidate, List rows, IReadOnlyDictionary choices) { var eventChoices = choices.TryGetValue(candidate.NarrativeCandidateID, out var choice) ? choice.Events.ToDictionary(item => item.NarrativeCandidateEventID) : new Dictionary(); foreach (var item in candidate.Events) { if (eventChoices.TryGetValue(item.NarrativeCandidateEventID, out var eventChoice)) { if (!eventChoice.Include) { continue; } rows.Add(new StoryIntelligenceNarrativeCandidateEvent { NarrativeCandidateEventID = item.NarrativeCandidateEventID, NarrativeCandidateID = item.NarrativeCandidateID, WholeBookPlotSynthesisRunID = item.WholeBookPlotSynthesisRunID, EventKey = item.EventKey, SceneID = item.SceneID, SceneNumber = item.SceneNumber, SceneTitle = item.SceneTitle, ChapterNumber = item.ChapterNumber, ChapterTitle = item.ChapterTitle, EventType = CleanTitle(eventChoice.EventType, item.EventType), EventTitle = CleanTitle(eventChoice.EventTitle, item.EventTitle), EventDescription = CleanNullable(eventChoice.EventDescription) ?? item.EventDescription, Evidence = item.Evidence, Confidence = item.Confidence, SortOrder = item.SortOrder }); continue; } if (!item.IsIgnored) { rows.Add(item); } } } 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; } } }