1298 lines
60 KiB
C#
1298 lines
60 KiB
C#
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<StoryIntelligenceNarrativeReviewViewModel> GetReviewAsync(OnboardingStoryIntelligenceBatch batch);
|
|
Task EnsureCandidatesAsync(OnboardingStoryIntelligenceBatch batch);
|
|
Task<IReadOnlyList<StoryIntelligenceNarrativeCandidate>> ListCandidatesByBookAsync(int bookId);
|
|
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceNarrativeImportForm form, int userId);
|
|
Task<StoryIntelligenceNarrativeImportResultViewModel?> 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<StoryIntelligenceNarrativeReviewViewModel> 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<IReadOnlyList<StoryIntelligenceNarrativeCandidate>> ListCandidatesByBookAsync(int bookId)
|
|
=> candidates.ListByBookAsync(bookId);
|
|
|
|
public async Task<StoryIntelligenceImportCommitResult> 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<int, int>();
|
|
var mergedPlotLineTargets = new Dictionary<int, int>();
|
|
|
|
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<StoryIntelligenceNarrativeImportResultViewModel?> GetResultAsync(OnboardingStoryIntelligenceBatch batch)
|
|
=> Task.FromResult<StoryIntelligenceNarrativeImportResultViewModel?>(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<StoryIntelligenceNarrativeSignalInput>();
|
|
|
|
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<string, int>(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<bool> 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<int> SavePlotLineAsync(IDbConnection connection, IDbTransaction transaction, PlotLineItem plotLine)
|
|
=> connection.QuerySingleAsync<int>(
|
|
"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<int> SavePlotThreadAsync(IDbConnection connection, IDbTransaction transaction, PlotThread plotThread)
|
|
=> connection.QuerySingleAsync<int>(
|
|
"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<int> SaveThreadEventAsync(IDbConnection connection, IDbTransaction transaction, ThreadEvent threadEvent)
|
|
=> connection.QuerySingleAsync<int>(
|
|
"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<StoryIntelligenceNarrativeReviewCandidateViewModel> BuildCandidateHierarchy(IReadOnlyList<StoryIntelligenceNarrativeCandidate> 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<StoryIntelligenceNarrativeReviewCandidateViewModel>? 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,
|
|
ExistingPlotLineName = candidate.ExistingPlotLineName,
|
|
AuthorPlotSeedID = candidate.AuthorPlotSeedID,
|
|
AuthorPlotSeedDescription = candidate.AuthorPlotSeedDescription ?? string.Empty,
|
|
AuthorPlotSeedSource = candidate.AuthorPlotSeedSource ?? string.Empty,
|
|
Source = candidate.Source ?? string.Empty,
|
|
ExistingPlotLineMatchReason = candidate.ExistingPlotLineMatchReason,
|
|
SeedMatchReason = candidate.SeedMatchReason,
|
|
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<int, Scene> importedById,
|
|
IReadOnlyDictionary<string, Scene> importedByRange,
|
|
IReadOnlyDictionary<int, Scene> 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<SceneIntelligenceScene>(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<SceneIntelligenceScene>(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<int, int> acceptedPlotLines,
|
|
IReadOnlyDictionary<int, int> mergedPlotLineTargets,
|
|
IReadOnlyDictionary<int, StoryIntelligenceNarrativeCandidate> candidates,
|
|
IReadOnlyDictionary<int, StoryIntelligenceNarrativeImportChoiceForm> 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<StoryIntelligenceNarrativeCandidateEvidence> EffectiveEvidence(
|
|
StoryIntelligenceNarrativeCandidate candidate,
|
|
IReadOnlyDictionary<int, StoryIntelligenceNarrativeCandidate> candidates,
|
|
IReadOnlyDictionary<int, StoryIntelligenceNarrativeImportChoiceForm> choices)
|
|
{
|
|
var rows = new List<StoryIntelligenceNarrativeCandidateEvidence>();
|
|
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<StoryIntelligenceNarrativeCandidateEvent> EffectiveEvents(
|
|
StoryIntelligenceNarrativeCandidate candidate,
|
|
IReadOnlyDictionary<int, StoryIntelligenceNarrativeCandidate> candidates,
|
|
IReadOnlyDictionary<int, StoryIntelligenceNarrativeImportChoiceForm> choices)
|
|
{
|
|
var rows = new List<StoryIntelligenceNarrativeCandidateEvent>();
|
|
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<StoryIntelligenceNarrativeCandidateEvent> rows,
|
|
IReadOnlyDictionary<int, StoryIntelligenceNarrativeImportChoiceForm> choices)
|
|
{
|
|
var eventChoices = choices.TryGetValue(candidate.NarrativeCandidateID, out var choice)
|
|
? choice.Events.ToDictionary(item => item.NarrativeCandidateEventID)
|
|
: new Dictionary<int, StoryIntelligenceNarrativeEventChoiceForm>();
|
|
|
|
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<int> SceneIds,
|
|
IReadOnlyList<StoryIntelligenceNarrativeSignalEvidence> 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<string> 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<string, string> 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<StoryIntelligenceNarrativeConsolidatedSignal> Consolidate(
|
|
IEnumerable<StoryIntelligenceNarrativeSignalInput> inputs,
|
|
IReadOnlyList<PlotLineItem> existingPlotLines,
|
|
IReadOnlyList<PlotThread>? existingThreads = null)
|
|
{
|
|
var groups = new Dictionary<string, NarrativeSignal>(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<PlotLineItem> existingPlotLines,
|
|
IReadOnlyDictionary<int, PlotLineItem> 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<PlotThread> 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<string, NarrativeSignal> 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<string> 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<string> 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<string> 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<string> Terms { get; } = terms;
|
|
public HashSet<int> SceneIds { get; } = [];
|
|
public List<StoryIntelligenceNarrativeSignalEvidence> Evidence { get; } = [];
|
|
public decimal? Confidence { get; set; }
|
|
}
|
|
}
|