PlotDirector/PlotLine/Services/StoryIntelligenceNarrativeReviewService.cs

639 lines
28 KiB
C#

using System.Text.Json;
using Microsoft.AspNetCore.Mvc.Rendering;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface IStoryIntelligenceNarrativeReviewService
{
Task<StoryIntelligenceNarrativeReviewViewModel> GetReviewAsync(OnboardingStoryIntelligenceBatch batch);
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,
IStoryIntelligencePipelineStateService pipelineState) : IStoryIntelligenceNarrativeReviewService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
public async Task<StoryIntelligenceNarrativeReviewViewModel> GetReviewAsync(OnboardingStoryIntelligenceBatch batch)
{
await EnsureCandidatesAsync(batch);
var rows = await candidates.ListByBookAsync(batch.BookID);
var pending = rows
.Where(candidate => string.Equals(candidate.Status, StoryIntelligenceNarrativeCandidateStatuses.Pending, StringComparison.OrdinalIgnoreCase))
.ToList();
var plotLines = await plots.ListPlotLinesAsync(batch.ProjectID);
return new StoryIntelligenceNarrativeReviewViewModel
{
HasCommittedScenes = await HasCommittedScenesAsync(batch),
CanImport = pending.Count > 0,
IsComplete = rows.Count > 0 && pending.Count == 0,
PlotLineOptions = plotLines
.OrderBy(plotLine => plotLine.SortOrder)
.ThenBy(plotLine => plotLine.PlotLineName)
.Select(plotLine => new SelectListItem(plotLine.PlotLineName, plotLine.PlotLineID.ToString()))
.ToList(),
Candidates = pending.Select(ToViewModel).ToList()
};
}
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceNarrativeImportForm form, int userId)
{
await EnsureCandidatesAsync(batch);
var rows = (await candidates.ListByBookAsync(batch.BookID)).ToDictionary(candidate => candidate.NarrativeCandidateID);
var lookups = await plots.GetLookupsAsync();
var plotLineTypeId = lookups.PlotLineTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Main Plot", StringComparison.OrdinalIgnoreCase))?.PlotLineTypeID
?? lookups.PlotLineTypes.FirstOrDefault()?.PlotLineTypeID
?? 0;
var plotImportanceId = lookups.PlotImportance.FirstOrDefault(importance => string.Equals(importance.ImportanceName, "Primary", StringComparison.OrdinalIgnoreCase))?.PlotImportanceID
?? lookups.PlotImportance.FirstOrDefault()?.PlotImportanceID
?? 0;
var threadTypeId = lookups.ThreadTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Question", StringComparison.OrdinalIgnoreCase))?.ThreadTypeID
?? lookups.ThreadTypes.FirstOrDefault()?.ThreadTypeID
?? 0;
var threadStatusId = lookups.ThreadStatuses.FirstOrDefault(status => string.Equals(status.StatusName, "Open", StringComparison.OrdinalIgnoreCase))?.ThreadStatusID
?? lookups.ThreadStatuses.FirstOrDefault()?.ThreadStatusID
?? 0;
var threadEventTypeId = lookups.ThreadEventTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Raised", StringComparison.OrdinalIgnoreCase))?.ThreadEventTypeID
?? lookups.ThreadEventTypes.FirstOrDefault()?.ThreadEventTypeID
?? 0;
var plotEventTypeId = lookups.PlotEventTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Progress", StringComparison.OrdinalIgnoreCase))?.PlotEventTypeID
?? lookups.PlotEventTypes.FirstOrDefault()?.PlotEventTypeID
?? 0;
var plotLinesCreated = 0;
var plotLinesMerged = 0;
var threadsCreated = 0;
var rejected = 0;
foreach (var choice in form.Candidates.Where(choice => choice.NarrativeCandidateID > 0))
{
if (!rows.TryGetValue(choice.NarrativeCandidateID, out var candidate)
|| !string.Equals(candidate.Status, StoryIntelligenceNarrativeCandidateStatuses.Pending, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var action = string.IsNullOrWhiteSpace(choice.Action) ? StoryIntelligenceNarrativeReviewActions.AcceptNew : choice.Action;
if (string.Equals(action, StoryIntelligenceNarrativeReviewActions.Reject, StringComparison.OrdinalIgnoreCase))
{
await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Rejected, action, userId, null, null);
rejected++;
continue;
}
if (string.Equals(candidate.CandidateType, StoryIntelligenceNarrativeCandidateTypes.PlotLine, StringComparison.OrdinalIgnoreCase))
{
if (string.Equals(action, StoryIntelligenceNarrativeReviewActions.MergeExisting, StringComparison.OrdinalIgnoreCase) && choice.ExistingPlotLineID is > 0)
{
await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Merged, action, userId, choice.ExistingPlotLineID, null);
plotLinesMerged++;
continue;
}
var plotLineId = await plots.SavePlotLineAsync(new PlotLineItem
{
ProjectID = batch.ProjectID,
BookID = batch.BookID,
PlotLineName = CleanTitle(choice.Title, candidate.ProposedTitle),
Description = CleanNullable(choice.Description) ?? candidate.ProposedDescription,
PlotLineTypeID = plotLineTypeId,
PlotImportanceID = plotImportanceId,
SortOrder = 0,
Colour = "#2f6f63",
IsVisibleOnTimeline = true
});
await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Accepted, action, userId, plotLineId, null);
plotLinesCreated++;
continue;
}
var relatedPlotLineId = choice.RelatedPlotLineID ?? candidate.RelatedPlotLineID ?? choice.ExistingPlotLineID;
if (relatedPlotLineId is not > 0)
{
await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Rejected, StoryIntelligenceNarrativeReviewActions.Reject, userId, null, null);
rejected++;
continue;
}
var firstSceneId = candidate.Evidence.OrderBy(evidence => evidence.SortOrder).FirstOrDefault()?.SceneID;
var threadId = await plots.SavePlotThreadAsync(new PlotThread
{
PlotLineID = relatedPlotLineId.Value,
ThreadTitle = CleanTitle(choice.Title, candidate.ProposedTitle),
Summary = CleanNullable(choice.Description) ?? candidate.ProposedDescription,
ThreadTypeID = threadTypeId,
ThreadStatusID = threadStatusId,
Importance = 5,
IntroducedSceneID = firstSceneId
});
foreach (var evidence in candidate.Evidence.Where(evidence => evidence.SceneID > 0))
{
await plots.SaveThreadEventAsync(new ThreadEvent
{
PlotThreadID = threadId,
SceneID = evidence.SceneID,
EventTypeID = threadEventTypeId,
PlotEventTypeID = plotEventTypeId,
EventTitle = string.IsNullOrWhiteSpace(evidence.ContributionType) ? candidate.ProposedTitle : evidence.ContributionType,
EventDescription = evidence.Evidence
});
}
await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Accepted, action, userId, null, threadId);
threadsCreated++;
}
batch.LastNarrativeImportResult = new StoryIntelligenceNarrativeImportBatchResult
{
PlotLinesCreated = plotLinesCreated,
PlotLinesMerged = plotLinesMerged,
ThreadsCreated = threadsCreated,
CandidatesRejected = rejected
};
batch.NarrativeStageComplete = true;
await pipelineState.RecordNarrativeImportAsync(batch.ProjectID, batch.BookID);
return new StoryIntelligenceImportCommitResult
{
Success = true,
Message = $"Narrative review applied. {plotLinesCreated:N0} Plot Line(s) created, {threadsCreated:N0} Thread(s) created, {plotLinesMerged:N0} merged, {rejected:N0} rejected."
};
}
public Task<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
});
private async Task EnsureCandidatesAsync(OnboardingStoryIntelligenceBatch batch)
{
var existingPlotLines = await plots.ListPlotLinesAsync(batch.ProjectID);
var existingThreads = await plots.ListPlotThreadsByProjectAsync(batch.ProjectID);
var existingThreadByKey = existingThreads
.GroupBy(thread => Key(thread.ThreadTitle), StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
var signalInputs = new List<StoryIntelligenceNarrativeSignalInput>();
foreach (var item in batch.Items)
{
var commit = await runs.GetImportCommitAsync(item.RunID);
if (commit is null || !string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID))
.Where(scene => scene.ImportRunID == item.RunID)
.ToList();
var 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);
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
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.ArcType) ?? "Narrative arc",
arc.Confidence,
arc.MatchingExistingPlotLineId,
arc.Evidence));
}
foreach (var question in parsed.QuestionsRaised ?? [])
{
signalInputs.Add(new StoryIntelligenceNarrativeSignalInput(
importedScene.SceneID,
CleanNullable(question.Question),
null,
"Thread",
"Open",
question.Confidence,
null,
question.Evidence));
}
foreach (var clue in parsed.TimelineClues ?? [])
{
signalInputs.Add(new StoryIntelligenceNarrativeSignalInput(
importedScene.SceneID,
CleanNullable(clue.Clue),
null,
"Thread",
"Introduced",
clue.Confidence,
null,
clue.Evidence));
}
}
}
var signals = StoryIntelligenceNarrativeSignalConsolidator.Consolidate(signalInputs, existingPlotLines);
foreach (var signal in signals.Where(signal => signal.SceneIds.Count > 1).OrderBy(signal => signal.Title))
{
var existingThread = existingThreadByKey.GetValueOrDefault(Key(signal.Title));
var candidateType = string.Equals(signal.Category, "Plot Line", StringComparison.OrdinalIgnoreCase) || (signal.ExistingPlotLineID.HasValue && existingThread is null)
? StoryIntelligenceNarrativeCandidateTypes.PlotLine
: StoryIntelligenceNarrativeCandidateTypes.Thread;
var candidateId = await candidates.SaveAsync(new StoryIntelligenceNarrativeCandidateSeed
{
ProjectID = batch.ProjectID,
BookID = batch.BookID,
CandidateType = candidateType,
CandidateKey = $"{candidateType}:{Key(signal.Title)}",
ProposedTitle = signal.Title,
ProposedDescription = signal.Description,
Category = signal.Category,
InferredState = signal.State,
ExistingPlotLineID = signal.ExistingPlotLineID,
ExistingPlotThreadID = existingThread?.PlotThreadID,
RelatedPlotLineID = existingThread?.PlotLineID,
Confidence = signal.Confidence
});
var sortOrder = 0;
foreach (var item in signal.Evidence.OrderBy(item => item.SceneID))
{
await candidates.SaveEvidenceAsync(candidateId, item.SceneID, item.ContributionType, item.Evidence, sortOrder++);
}
}
}
private async Task<bool> HasCommittedScenesAsync(OnboardingStoryIntelligenceBatch batch)
{
foreach (var item in batch.Items)
{
var commit = await runs.GetImportCommitAsync(item.RunID);
if (commit is not null && string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static StoryIntelligenceNarrativeReviewCandidateViewModel ToViewModel(StoryIntelligenceNarrativeCandidate candidate)
=> new()
{
NarrativeCandidateID = candidate.NarrativeCandidateID,
CandidateType = candidate.CandidateType,
ProposedTitle = candidate.ProposedTitle,
ProposedDescription = candidate.ProposedDescription ?? string.Empty,
Category = candidate.Category ?? string.Empty,
InferredState = candidate.InferredState ?? string.Empty,
Confidence = candidate.Confidence.HasValue ? $"{candidate.Confidence.Value:P0}" : "Unknown",
ExistingPlotLineID = candidate.ExistingPlotLineID,
RelatedPlotLineID = candidate.RelatedPlotLineID,
RelatedPlotLineName = candidate.RelatedPlotLineName,
DefaultAction = candidate.ExistingPlotLineID.HasValue || candidate.ExistingPlotThreadID.HasValue
? StoryIntelligenceNarrativeReviewActions.MergeExisting
: StoryIntelligenceNarrativeReviewActions.AcceptNew,
Evidence = candidate.Evidence.Select(evidence => new StoryIntelligenceNarrativeEvidenceViewModel
{
SceneID = evidence.SceneID,
SceneLabel = $"Chapter {evidence.ChapterNumber:0.##}, Scene {evidence.SceneNumber:0.##}: {evidence.SceneTitle}",
ContributionType = evidence.ContributionType ?? string.Empty,
Evidence = evidence.Evidence ?? string.Empty
}).ToList()
};
private static Scene? ResolveImportedScene(
StoryIntelligenceSavedSceneResult sceneResult,
IReadOnlyDictionary<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?.Summary is not null)
{
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 string CleanTitle(string? preferred, string fallback)
=> string.IsNullOrWhiteSpace(preferred) ? fallback.Trim() : preferred.Trim();
private static string? CleanNullable(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static string Key(string? value)
=> StoryIntelligenceNarrativeSignalConsolidator.Key(value);
}
public sealed record StoryIntelligenceNarrativeSignalInput(
int SceneID,
string? Title,
string? Description,
string Category,
string State,
decimal? Confidence,
int? MatchingExistingPlotLineID,
string? Evidence);
public sealed record StoryIntelligenceNarrativeSignalEvidence(int SceneID, string ContributionType, string? Evidence);
public sealed record StoryIntelligenceNarrativeConsolidatedSignal(
string Title,
string Description,
string Category,
string State,
int? ExistingPlotLineID,
decimal? Confidence,
IReadOnlyCollection<int> SceneIds,
IReadOnlyList<StoryIntelligenceNarrativeSignalEvidence> Evidence);
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)
{
var groups = new Dictionary<string, NarrativeSignal>(StringComparer.OrdinalIgnoreCase);
var plotLinesById = existingPlotLines.Where(plotLine => plotLine.PlotLineID > 0).ToDictionary(plotLine => plotLine.PlotLineID);
foreach (var input in inputs)
{
if (string.IsNullOrWhiteSpace(input.Title) || input.Title.Trim().Length < 8)
{
continue;
}
var matchingPlotLine = ResolveExistingPlotLine(input, existingPlotLines, plotLinesById);
var key = matchingPlotLine is not null
? $"existing:{matchingPlotLine.PlotLineID}"
: FindSimilarGroupKey(groups, input) ?? ArcKey(input.Title, input.Description);
if (!groups.TryGetValue(key, out var signal))
{
var terms = Terms($"{input.Title} {input.Description}");
signal = new NarrativeSignal(
input.Title.Trim(),
string.IsNullOrWhiteSpace(input.Description) ? $"{input.Category} recurring across scenes." : input.Description.Trim(),
matchingPlotLine is not null ? "Plot Line" : input.Category,
input.State,
matchingPlotLine?.PlotLineID,
ArcFamily(terms),
terms.ToHashSet(StringComparer.OrdinalIgnoreCase));
groups[key] = signal;
}
signal.SceneIds.Add(input.SceneID);
signal.Confidence = input.Confidence.HasValue
? Math.Max(signal.Confidence ?? 0m, input.Confidence.Value)
: signal.Confidence;
signal.Evidence.Add(new StoryIntelligenceNarrativeSignalEvidence(input.SceneID, input.Category, input.Evidence));
}
return groups.Values
.Select(signal => new StoryIntelligenceNarrativeConsolidatedSignal(
signal.Title,
signal.Description,
signal.Category,
signal.State,
signal.ExistingPlotLineID,
signal.Confidence,
signal.SceneIds,
signal.Evidence))
.ToList();
}
public static string Key(string? value)
=> new((value ?? string.Empty).Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray());
private static PlotLineItem? ResolveExistingPlotLine(
StoryIntelligenceNarrativeSignalInput input,
IReadOnlyList<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}");
var plotLineTerms = Terms($"{plotLine.PlotLineName} {plotLine.Description}");
if (signalTerms.Count == 0 || plotLineTerms.Count == 0)
{
return false;
}
var shared = signalTerms.Intersect(plotLineTerms, StringComparer.OrdinalIgnoreCase).Count();
var smaller = Math.Min(signalTerms.Count, plotLineTerms.Count);
if (shared >= 2 && shared / (decimal)smaller >= 0.5m)
{
return true;
}
if (ArcFamily(signalTerms) == ArcFamily(plotLineTerms)
&& signalTerms.Intersect(plotLineTerms, StringComparer.OrdinalIgnoreCase).Any())
{
return true;
}
return ArcKey(input.Title, input.Description) == ArcKey(plotLine.PlotLineName, plotLine.Description);
}
private static string? FindSimilarGroupKey(
IReadOnlyDictionary<string, NarrativeSignal> groups,
StoryIntelligenceNarrativeSignalInput input)
{
var terms = Terms($"{input.Title} {input.Description}");
var family = ArcFamily(terms);
if (terms.Count == 0)
{
return null;
}
foreach (var group in groups)
{
if (group.Value.ExistingPlotLineID.HasValue || !string.Equals(group.Value.Family, family, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (group.Value.Terms.Intersect(terms, StringComparer.OrdinalIgnoreCase).Any())
{
return group.Key;
}
}
return null;
}
private static string ArcKey(string? title, string? description)
{
var terms = Terms($"{title} {description}");
var family = ArcFamily(terms);
var anchors = terms
.Where(term => !ArcFamilies.ContainsKey(term))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(term => term)
.Take(3)
.ToList();
if (anchors.Count == 0)
{
anchors = terms.Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(term => term).Take(3).ToList();
}
return $"{family}:{string.Join('-', anchors)}";
}
private static string ArcFamily(IEnumerable<string> terms)
=> terms.Select(term => ArcFamilies.GetValueOrDefault(term)).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? "general";
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 record NarrativeSignal(
string Title,
string Description,
string Category,
string State,
int? ExistingPlotLineID,
string Family,
HashSet<string> Terms)
{
public HashSet<int> SceneIds { get; } = [];
public List<StoryIntelligenceNarrativeSignalEvidence> Evidence { get; } = [];
public decimal? Confidence { get; set; }
}
}