From 1b8b2a984a0ef91d01ec9e46b41f303817221e31 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Tue, 25 Aug 2026 15:13:33 +0000 Subject: [PATCH] Verify AI-discovered Plot Line candidates --- PlotLine.Tests/Program.cs | 50 +++ PlotLine/Docs/AI/Scene-Prompt-V2.md | 60 ++++ PlotLine/Models/SceneIntelligenceModels.cs | 15 + ...StoryIntelligenceNarrativeReviewService.cs | 320 +++++++++++++++--- PlotLine/Services/StorySceneValidator.cs | 24 ++ 5 files changed, 423 insertions(+), 46 deletions(-) diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index cd4e311..38fc140 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -100,6 +100,8 @@ var tests = new (string Name, Action Test)[] ("Story Intelligence runner supplies metrics and Plot Lines", StoryIntelligenceRunnerSuppliesMetricsAndPlotLines), ("Story Intelligence metric persistence uses configured keys", StoryIntelligenceMetricPersistenceUsesConfiguredKeys), ("Story Intelligence narrative candidates require review", StoryIntelligenceNarrativeCandidatesRequireReview), + ("Story Intelligence discovers Plot Lines without preconfiguration", StoryIntelligenceDiscoversPlotLinesWithoutPreconfiguration), + ("Story Intelligence matches existing Plot Lines without duplicates", StoryIntelligenceMatchesExistingPlotLinesWithoutDuplicates), ("Story Intelligence narrative review creates canonical records via existing procs", StoryIntelligenceNarrativeReviewCreatesCanonicalRecordsViaExistingProcs), ("Story Intelligence narrative stage follows knowledge", StoryIntelligenceNarrativeStageFollowsKnowledge), ("Word Companion replays live current scene to writing panes", WordCompanionReplaysLiveCurrentSceneToWritingPanes), @@ -2553,8 +2555,10 @@ static void StoryIntelligenceNarrativeCandidatesRequireReview() var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var sql = File.ReadAllText(Path.Combine(root, "Sql/155_Phase25_StoryIntelligenceNarrativeReview.sql")); var models = File.ReadAllText(Path.Combine(root, "Models/StoryIntelligenceNarrativeModels.cs")); + var sceneModels = File.ReadAllText(Path.Combine(root, "Models/SceneIntelligenceModels.cs")); var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceNarrativeReviewService.cs")); var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceNarrative.cshtml")); + var prompt = File.ReadAllText(Path.Combine(root, "Docs/AI/Scene-Prompt-V2.md")); var projectDelete = File.ReadAllText(Path.Combine(root, "Sql/124_Phase20Z2_StoryIntelligenceCommitAndProjectHardDelete.sql")); var bookDelete = File.ReadAllText(Path.Combine(root, "Sql/134_Phase20AV_ArchivedBookProjectPermanentDeletion.sql")); @@ -2562,6 +2566,8 @@ static void StoryIntelligenceNarrativeCandidatesRequireReview() Assert(sql.Contains("Status nvarchar(20) NOT NULL", StringComparison.Ordinal), "Narrative candidates should have review status."); Assert(sql.Contains("CONSTRAINT DF_StoryIntelligenceNarrativeCandidates_Status DEFAULT N'Pending'", StringComparison.Ordinal), "Narrative candidates should default to pending review."); Assert(models.Contains("StoryIntelligenceNarrativeCandidateTypes", StringComparison.Ordinal), "Candidate types should be explicit."); + Assert(sceneModels.Contains("SceneIntelligenceNarrativeArc", StringComparison.Ordinal), "Scene intelligence should expose explicit narrative arc signals."); + Assert(prompt.Contains("narrativeArcs", StringComparison.Ordinal) && prompt.Contains("matchingExistingPlotLineId", StringComparison.Ordinal), "Scene prompt should request narrative arc discovery and existing Plot Line matching."); Assert(service.Contains("Where(signal => signal.SceneIds.Count > 1)", StringComparison.Ordinal), "Thread discovery should filter one-off incidental details."); Assert(view.Contains("Review Plot Lines & Threads", StringComparison.Ordinal), "A dedicated narrative review page should be visible."); Assert(view.Contains("Accept new", StringComparison.Ordinal) && view.Contains("Merge with existing", StringComparison.Ordinal) && view.Contains("Reject", StringComparison.Ordinal), "Review UI should support accept, merge and reject."); @@ -2569,6 +2575,50 @@ static void StoryIntelligenceNarrativeCandidatesRequireReview() Assert(bookDelete.Contains("StoryIntelligenceNarrativeCandidateEvidence", StringComparison.Ordinal) && bookDelete.Contains("StoryIntelligenceNarrativeCandidates", StringComparison.Ordinal), "Book hard delete should clear narrative review candidates."); } +static void StoryIntelligenceDiscoversPlotLinesWithoutPreconfiguration() +{ + var signals = new[] + { + new StoryIntelligenceNarrativeSignalInput(101, "Maggie's Disappearance", "Beth notices Maggie is gone and starts looking for her.", "Plot Line", "disappearance", 0.86m, null, "Maggie is missing."), + new StoryIntelligenceNarrativeSignalInput(102, "Search for Maggie", "Beth follows a clue that may explain where Maggie went.", "Plot Line", "investigation", 0.82m, null, "Beth follows a clue."), + new StoryIntelligenceNarrativeSignalInput(103, "Finding Maggie", "The search for Maggie reaches a possible answer.", "Plot Line", "disappearance", 0.8m, null, "Maggie may have been found.") + }; + + var consolidated = StoryIntelligenceNarrativeSignalConsolidator.Consolidate(signals, []); + var plotLine = consolidated.SingleOrDefault(signal => signal.Category == "Plot Line"); + + Assert(plotLine is not null, "Zero existing Plot Lines should still allow a new Plot Line candidate signal."); + if (plotLine is null) throw new InvalidOperationException("Expected a consolidated Plot Line signal."); + Assert(plotLine.ExistingPlotLineID is null, "A discovered Plot Line should not require an existing PlotLineID."); + Assert(plotLine.SceneIds.Count == 3, "The new Plot Line candidate should consolidate evidence from multiple scenes."); + Assert(plotLine.Evidence.Select(evidence => evidence.SceneID).Distinct().Count() == 3, "The candidate should retain evidence scene IDs."); +} + +static void StoryIntelligenceMatchesExistingPlotLinesWithoutDuplicates() +{ + var existingPlotLines = new[] + { + new PlotLineItem + { + PlotLineID = 42, + PlotLineName = "Maggie's Disappearance", + Description = "Beth searches for Maggie after she vanishes." + } + }; + var signals = new[] + { + new StoryIntelligenceNarrativeSignalInput(201, "Search for Maggie", "Beth follows a clue in Maggie's disappearance.", "Plot Line", "investigation", 0.84m, null, "Beth follows a clue."), + new StoryIntelligenceNarrativeSignalInput(202, "Missing Maggie", "Maggie's absence drives the investigation.", "Plot Line", "disappearance", 0.81m, null, "Maggie remains missing."), + new StoryIntelligenceNarrativeSignalInput(203, "Where is Maggie?", "A lead suggests where Maggie may have gone.", "Plot Line", "disappearance", 0.79m, null, "A lead points to Maggie.") + }; + + var consolidated = StoryIntelligenceNarrativeSignalConsolidator.Consolidate(signals, existingPlotLines); + + Assert(consolidated.Count == 1, "Matching scene variants should consolidate into one existing Plot Line signal, not duplicate candidates."); + Assert(consolidated[0].ExistingPlotLineID == 42, "The consolidated signal should carry the matching existing PlotLineID."); + Assert(consolidated[0].SceneIds.Count == 3, "The existing Plot Line match should retain evidence from multiple scenes."); +} + static void StoryIntelligenceNarrativeReviewCreatesCanonicalRecordsViaExistingProcs() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); diff --git a/PlotLine/Docs/AI/Scene-Prompt-V2.md b/PlotLine/Docs/AI/Scene-Prompt-V2.md index ab911d9..ac08260 100644 --- a/PlotLine/Docs/AI/Scene-Prompt-V2.md +++ b/PlotLine/Docs/AI/Scene-Prompt-V2.md @@ -408,6 +408,55 @@ For each raised question, include exactly these properties: `question`, `scope`, Use a concise lowercase scope such as `plot`, `character`, `asset`, `relationship`, `location`, `timeline` or `scene`. Do not omit `scope`. +## Narrative Arc Rules + +Report sustained narrative arcs that this scene materially advances, complicates, reveals or resolves. + +Narrative arcs are not final canon. They are evidence-backed signals for later book-level consolidation and author review. + +Use `narrativeArcs` for substantial recurring plot-line material such as a disappearance, investigation, quest, conspiracy, romance arc, rivalry, revenge plan, public crisis or long-running secret. Do not use it for a generic scene topic, a one-off event, an ordinary object, or a minor unanswered detail. + +When `existingPlotLines` is supplied in `sceneContext`: + +- compare the scene's arc signal with the supplied Plot Line `name` and `description`; +- if the scene clearly belongs to an existing Plot Line, set `matchingExistingPlotLineId` to that supplied `plotLineId`; +- if no supplied Plot Line clearly matches, set `matchingExistingPlotLineId` to `null`; +- do not invent Plot Line ids. + +For each narrative arc, include exactly these properties: `title`, `description`, `arcType`, `scale`, `matchingExistingPlotLineId`, `evidence`, `confidence`. + +Use concise `arcType` values such as `investigation`, `disappearance`, `romance`, `quest`, `conspiracy`, `rivalry`, `secret`, `survival`, `family`, `political`, `mystery`, `revenge`, `redemption`, `subplot` or `other`. + +Use `scale: "major"` only when the scene signal appears to belong to a substantial book-level arc. Use `scale: "secondary"` for smaller recurring arcs. Use `scale: "thread"` only when it is likely too small to become a Plot Line; thread-scale signals may still support smaller review candidates later. + +Good: + +```json +{ + "title": "Maggie's Disappearance", + "description": "Beth's search for Maggie advances through absence, clues and investigation.", + "arcType": "disappearance", + "scale": "major", + "matchingExistingPlotLineId": null, + "evidence": "Beth follows a clue that may explain where Maggie went.", + "confidence": 0.82 +} +``` + +Bad: + +```json +{ + "title": "The kitchen conversation", + "description": "Two characters talk in a kitchen.", + "arcType": "other", + "scale": "major", + "matchingExistingPlotLineId": null, + "evidence": "They talk in the kitchen.", + "confidence": 0.7 +} +``` + ## Questions Answered Rules Report answers supplied by the scene. @@ -641,6 +690,17 @@ Return exactly one JSON object matching this shape: "confidence": 0.0 } ], + "narrativeArcs": [ + { + "title": "", + "description": "", + "arcType": "other", + "scale": "major", + "matchingExistingPlotLineId": null, + "evidence": "", + "confidence": 0.0 + } + ], "questionsRaised": [ { "question": "", diff --git a/PlotLine/Models/SceneIntelligenceModels.cs b/PlotLine/Models/SceneIntelligenceModels.cs index 8d35b13..1f4e07e 100644 --- a/PlotLine/Models/SceneIntelligenceModels.cs +++ b/PlotLine/Models/SceneIntelligenceModels.cs @@ -18,6 +18,7 @@ public sealed class SceneIntelligenceScene public List? Relationships { get; init; } public List? KnowledgeChanges { get; init; } public List? TimelineClues { get; init; } + public List? NarrativeArcs { get; init; } public List? QuestionsRaised { get; init; } public List? QuestionsAnswered { get; init; } public List? Observations { get; init; } @@ -207,6 +208,20 @@ public sealed class SceneIntelligenceQuestionRaised public Dictionary? ExtensionData { get; init; } } +public sealed class SceneIntelligenceNarrativeArc +{ + public string? Title { get; init; } + public string? Description { get; init; } + public string? ArcType { get; init; } + public string? Scale { get; init; } + public int? MatchingExistingPlotLineId { get; init; } + public string? Evidence { get; init; } + public decimal? Confidence { get; init; } + + [JsonExtensionData] + public Dictionary? ExtensionData { get; init; } +} + public sealed class SceneIntelligenceQuestionAnswered { public string? Question { get; init; } diff --git a/PlotLine/Services/StoryIntelligenceNarrativeReviewService.cs b/PlotLine/Services/StoryIntelligenceNarrativeReviewService.cs index 5adf9ab..678e03b 100644 --- a/PlotLine/Services/StoryIntelligenceNarrativeReviewService.cs +++ b/PlotLine/Services/StoryIntelligenceNarrativeReviewService.cs @@ -190,13 +190,10 @@ public sealed class StoryIntelligenceNarrativeReviewService( { var existingPlotLines = await plots.ListPlotLinesAsync(batch.ProjectID); var existingThreads = await plots.ListPlotThreadsByProjectAsync(batch.ProjectID); - var existingPlotLineByKey = existingPlotLines - .GroupBy(plotLine => Key(plotLine.PlotLineName), StringComparer.OrdinalIgnoreCase) - .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); var existingThreadByKey = existingThreads .GroupBy(thread => Key(thread.ThreadTitle), StringComparer.OrdinalIgnoreCase) .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); - var signals = new Dictionary(StringComparer.OrdinalIgnoreCase); + var signalInputs = new List(); foreach (var item in batch.Items) { @@ -224,23 +221,55 @@ public sealed class StoryIntelligenceNarrativeReviewService( 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 ?? []) { - AddSignal(signals, batch, importedScene, CleanNullable(question.Question), "Question", "Open", question.Evidence, question.Confidence); + signalInputs.Add(new StoryIntelligenceNarrativeSignalInput( + importedScene.SceneID, + CleanNullable(question.Question), + null, + "Thread", + "Open", + question.Confidence, + null, + question.Evidence)); } foreach (var clue in parsed.TimelineClues ?? []) { - AddSignal(signals, batch, importedScene, CleanNullable(clue.Clue), "Timeline clue", "Introduced", clue.Evidence, clue.Confidence); + signalInputs.Add(new StoryIntelligenceNarrativeSignalInput( + importedScene.SceneID, + CleanNullable(clue.Clue), + null, + "Thread", + "Introduced", + clue.Confidence, + null, + clue.Evidence)); } } } - foreach (var signal in signals.Values.Where(signal => signal.SceneIds.Count > 1).OrderBy(signal => signal.Title)) + 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 existingPlotLine = existingPlotLineByKey.GetValueOrDefault(Key(signal.Title)); - var candidateType = signal.SceneIds.Count >= 3 && existingThread is null + 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 @@ -253,7 +282,7 @@ public sealed class StoryIntelligenceNarrativeReviewService( ProposedDescription = signal.Description, Category = signal.Category, InferredState = signal.State, - ExistingPlotLineID = existingPlotLine?.PlotLineID, + ExistingPlotLineID = signal.ExistingPlotLineID, ExistingPlotThreadID = existingThread?.PlotThreadID, RelatedPlotLineID = existingThread?.PlotLineID, Confidence = signal.Confidence @@ -306,35 +335,6 @@ public sealed class StoryIntelligenceNarrativeReviewService( }).ToList() }; - private static void AddSignal( - Dictionary signals, - OnboardingStoryIntelligenceBatch batch, - Scene scene, - string? title, - string category, - string state, - string? evidence, - decimal? confidence) - { - if (string.IsNullOrWhiteSpace(title) || title.Length < 12) - { - return; - } - - var key = Key(title); - if (!signals.TryGetValue(key, out var signal)) - { - signal = new NarrativeSignal(batch.ProjectID, batch.BookID, title, category, state); - signals[key] = signal; - } - - signal.SceneIds.Add(scene.SceneID); - signal.Confidence = confidence.HasValue - ? Math.Max(signal.Confidence ?? 0m, confidence.Value) - : signal.Confidence; - signal.Evidence.Add(new NarrativeSignalEvidence(scene.SceneID, category, evidence)); - } - private static Scene? ResolveImportedScene( StoryIntelligenceSavedSceneResult sceneResult, IReadOnlyDictionary importedByRange, @@ -389,15 +389,243 @@ public sealed class StoryIntelligenceNarrativeReviewService( => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); private static string Key(string? value) - => new((value ?? string.Empty).Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray()); + => StoryIntelligenceNarrativeSignalConsolidator.Key(value); +} - private sealed record NarrativeSignal(int ProjectID, int BookID, string Title, string Category, string State) +public sealed record StoryIntelligenceNarrativeSignalInput( + int SceneID, + string? Title, + string? Description, + string Category, + string State, + decimal? Confidence, + int? MatchingExistingPlotLineID, + string? Evidence); + +public sealed record StoryIntelligenceNarrativeSignalEvidence(int SceneID, string ContributionType, string? Evidence); + +public sealed record StoryIntelligenceNarrativeConsolidatedSignal( + string Title, + string Description, + string Category, + string State, + int? ExistingPlotLineID, + decimal? Confidence, + IReadOnlyCollection SceneIds, + IReadOnlyList Evidence); + +public static class StoryIntelligenceNarrativeSignalConsolidator +{ + private static readonly HashSet StopWords = new(StringComparer.OrdinalIgnoreCase) { - public HashSet SceneIds { get; } = []; - public List Evidence { get; } = []; - public decimal? Confidence { get; set; } - public string Description => $"{Category} recurring across {SceneIds.Count:N0} scenes."; + "a", "an", "and", "are", "as", "at", "be", "by", "for", "from", "has", "have", "her", "his", "how", "in", "into", + "is", "it", "its", "of", "on", "or", "she", "he", "that", "the", "their", "them", "they", "this", "to", "what", + "when", "where", "who", "why", "with", "about", "after", "before", "looks", "looking", "case", "plot", "line", + "story", "arc" + }; + + private static readonly Dictionary ArcFamilies = new(StringComparer.OrdinalIgnoreCase) + { + ["absence"] = "disappearance", + ["absent"] = "disappearance", + ["clue"] = "disappearance", + ["clues"] = "disappearance", + ["disappear"] = "disappearance", + ["disappearance"] = "disappearance", + ["disappeared"] = "disappearance", + ["disappears"] = "disappearance", + ["find"] = "disappearance", + ["finding"] = "disappearance", + ["found"] = "disappearance", + ["investigate"] = "disappearance", + ["investigates"] = "disappearance", + ["investigation"] = "disappearance", + ["lead"] = "disappearance", + ["leads"] = "disappearance", + ["missing"] = "disappearance", + ["search"] = "disappearance", + ["searches"] = "disappearance", + ["searching"] = "disappearance", + ["suspect"] = "disappearance", + ["suspects"] = "disappearance", + ["gone"] = "disappearance", + ["went"] = "disappearance", + ["conspiracy"] = "conspiracy", + ["quest"] = "quest", + ["romance"] = "romance", + ["secret"] = "secret", + ["secrets"] = "secret", + ["revenge"] = "revenge", + ["rivalry"] = "rivalry" + }; + + public static IReadOnlyList Consolidate( + IEnumerable inputs, + IReadOnlyList existingPlotLines) + { + var groups = new Dictionary(StringComparer.OrdinalIgnoreCase); + var plotLinesById = existingPlotLines.Where(plotLine => plotLine.PlotLineID > 0).ToDictionary(plotLine => plotLine.PlotLineID); + + foreach (var input in inputs) + { + if (string.IsNullOrWhiteSpace(input.Title) || input.Title.Trim().Length < 8) + { + continue; + } + + var matchingPlotLine = ResolveExistingPlotLine(input, existingPlotLines, plotLinesById); + var key = matchingPlotLine is not null + ? $"existing:{matchingPlotLine.PlotLineID}" + : FindSimilarGroupKey(groups, input) ?? ArcKey(input.Title, input.Description); + + if (!groups.TryGetValue(key, out var signal)) + { + var terms = Terms($"{input.Title} {input.Description}"); + signal = new NarrativeSignal( + input.Title.Trim(), + string.IsNullOrWhiteSpace(input.Description) ? $"{input.Category} recurring across scenes." : input.Description.Trim(), + matchingPlotLine is not null ? "Plot Line" : input.Category, + input.State, + matchingPlotLine?.PlotLineID, + ArcFamily(terms), + terms.ToHashSet(StringComparer.OrdinalIgnoreCase)); + groups[key] = signal; + } + + signal.SceneIds.Add(input.SceneID); + signal.Confidence = input.Confidence.HasValue + ? Math.Max(signal.Confidence ?? 0m, input.Confidence.Value) + : signal.Confidence; + signal.Evidence.Add(new StoryIntelligenceNarrativeSignalEvidence(input.SceneID, input.Category, input.Evidence)); + } + + return groups.Values + .Select(signal => new StoryIntelligenceNarrativeConsolidatedSignal( + signal.Title, + signal.Description, + signal.Category, + signal.State, + signal.ExistingPlotLineID, + signal.Confidence, + signal.SceneIds, + signal.Evidence)) + .ToList(); } - private sealed record NarrativeSignalEvidence(int SceneID, string ContributionType, string? Evidence); + public static string Key(string? value) + => new((value ?? string.Empty).Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray()); + + private static PlotLineItem? ResolveExistingPlotLine( + StoryIntelligenceNarrativeSignalInput input, + IReadOnlyList existingPlotLines, + IReadOnlyDictionary plotLinesById) + { + if (input.MatchingExistingPlotLineID is > 0 && plotLinesById.TryGetValue(input.MatchingExistingPlotLineID.Value, out var suppliedMatch)) + { + return suppliedMatch; + } + + return existingPlotLines.FirstOrDefault(plotLine => HasSharedArcIdentity(input, plotLine)); + } + + private static bool HasSharedArcIdentity(StoryIntelligenceNarrativeSignalInput input, PlotLineItem plotLine) + { + var signalTerms = Terms($"{input.Title} {input.Description}"); + var plotLineTerms = Terms($"{plotLine.PlotLineName} {plotLine.Description}"); + if (signalTerms.Count == 0 || plotLineTerms.Count == 0) + { + return false; + } + + var shared = signalTerms.Intersect(plotLineTerms, StringComparer.OrdinalIgnoreCase).Count(); + var smaller = Math.Min(signalTerms.Count, plotLineTerms.Count); + if (shared >= 2 && shared / (decimal)smaller >= 0.5m) + { + return true; + } + + if (ArcFamily(signalTerms) == ArcFamily(plotLineTerms) + && signalTerms.Intersect(plotLineTerms, StringComparer.OrdinalIgnoreCase).Any()) + { + return true; + } + + return ArcKey(input.Title, input.Description) == ArcKey(plotLine.PlotLineName, plotLine.Description); + } + + private static string? FindSimilarGroupKey( + IReadOnlyDictionary groups, + StoryIntelligenceNarrativeSignalInput input) + { + var terms = Terms($"{input.Title} {input.Description}"); + var family = ArcFamily(terms); + if (terms.Count == 0) + { + return null; + } + + foreach (var group in groups) + { + if (group.Value.ExistingPlotLineID.HasValue || !string.Equals(group.Value.Family, family, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (group.Value.Terms.Intersect(terms, StringComparer.OrdinalIgnoreCase).Any()) + { + return group.Key; + } + } + + return null; + } + + private static string ArcKey(string? title, string? description) + { + var terms = Terms($"{title} {description}"); + var family = ArcFamily(terms); + var anchors = terms + .Where(term => !ArcFamilies.ContainsKey(term)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(term => term) + .Take(3) + .ToList(); + + if (anchors.Count == 0) + { + anchors = terms.Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(term => term).Take(3).ToList(); + } + + return $"{family}:{string.Join('-', anchors)}"; + } + + private static string ArcFamily(IEnumerable terms) + => terms.Select(term => ArcFamilies.GetValueOrDefault(term)).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? "general"; + + private static List Terms(string? value) + => (value ?? string.Empty) + .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(NormalizeTerm) + .Where(term => term.Length > 2 && !StopWords.Contains(term)) + .ToList(); + + private static string NormalizeTerm(string value) + { + var term = new string(value.Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray()); + return term.EndsWith('s') && term.Length > 4 ? term[..^1] : term; + } + + private sealed record NarrativeSignal( + string Title, + string Description, + string Category, + string State, + int? ExistingPlotLineID, + string Family, + HashSet Terms) + { + public HashSet SceneIds { get; } = []; + public List Evidence { get; } = []; + public decimal? Confidence { get; set; } + } } diff --git a/PlotLine/Services/StorySceneValidator.cs b/PlotLine/Services/StorySceneValidator.cs index 7b8079e..8c12a72 100644 --- a/PlotLine/Services/StorySceneValidator.cs +++ b/PlotLine/Services/StorySceneValidator.cs @@ -42,6 +42,7 @@ public sealed class StorySceneValidator : IStorySceneValidator ValidateRelationships(scene.Relationships, result); ValidateKnowledgeChanges(scene.KnowledgeChanges, result); ValidateTimelineClues(scene.TimelineClues, result); + ValidateNarrativeArcs(scene.NarrativeArcs, result); ValidateQuestionsRaised(scene.QuestionsRaised, result); ValidateQuestionsAnswered(scene.QuestionsAnswered, result); ValidateObservations(scene.Observations, result); @@ -80,6 +81,7 @@ public sealed class StorySceneValidator : IStorySceneValidator RequiredArray(scene.Relationships, "relationships", result); RequiredArray(scene.KnowledgeChanges, "knowledgeChanges", result); RequiredArray(scene.TimelineClues, "timelineClues", result); + RequiredArray(scene.NarrativeArcs, "narrativeArcs", result); RequiredArray(scene.QuestionsRaised, "questionsRaised", result); RequiredArray(scene.QuestionsAnswered, "questionsAnswered", result); RequiredArray(scene.Observations, "observations", result); @@ -340,6 +342,28 @@ public sealed class StorySceneValidator : IStorySceneValidator } } + private static void ValidateNarrativeArcs(List? values, ValidationResult result) + { + if (values is null) + { + return; + } + + for (var i = 0; i < values.Count; i++) + { + var item = values[i]; + var path = $"narrativeArcs[{i}]"; + RequiredString(item.Title, $"{path}.title", result); + RequiredString(item.Description, $"{path}.description", result); + RequiredString(item.ArcType, $"{path}.arcType", result); + RequiredString(item.Scale, $"{path}.scale", result); + RequiredScalar(item.MatchingExistingPlotLineId, $"{path}.matchingExistingPlotLineId", result, allowNull: true); + RequiredString(item.Evidence, $"{path}.evidence", result); + Confidence(item.Confidence, $"{path}.confidence", result); + Unknown(item.ExtensionData, path, result); + } + } + private static void ValidateQuestionsAnswered(List? values, ValidationResult result) { if (values is null)