Verify AI-discovered Plot Line candidates

This commit is contained in:
Nick Beckley 2026-08-25 15:13:33 +00:00
parent a6145a25e8
commit 1b8b2a984a
5 changed files with 423 additions and 46 deletions

View File

@ -100,6 +100,8 @@ var tests = new (string Name, Action Test)[]
("Story Intelligence runner supplies metrics and Plot Lines", StoryIntelligenceRunnerSuppliesMetricsAndPlotLines), ("Story Intelligence runner supplies metrics and Plot Lines", StoryIntelligenceRunnerSuppliesMetricsAndPlotLines),
("Story Intelligence metric persistence uses configured keys", StoryIntelligenceMetricPersistenceUsesConfiguredKeys), ("Story Intelligence metric persistence uses configured keys", StoryIntelligenceMetricPersistenceUsesConfiguredKeys),
("Story Intelligence narrative candidates require review", StoryIntelligenceNarrativeCandidatesRequireReview), ("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 review creates canonical records via existing procs", StoryIntelligenceNarrativeReviewCreatesCanonicalRecordsViaExistingProcs),
("Story Intelligence narrative stage follows knowledge", StoryIntelligenceNarrativeStageFollowsKnowledge), ("Story Intelligence narrative stage follows knowledge", StoryIntelligenceNarrativeStageFollowsKnowledge),
("Word Companion replays live current scene to writing panes", WordCompanionReplaysLiveCurrentSceneToWritingPanes), ("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 root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var sql = File.ReadAllText(Path.Combine(root, "Sql/155_Phase25_StoryIntelligenceNarrativeReview.sql")); var sql = File.ReadAllText(Path.Combine(root, "Sql/155_Phase25_StoryIntelligenceNarrativeReview.sql"));
var models = File.ReadAllText(Path.Combine(root, "Models/StoryIntelligenceNarrativeModels.cs")); 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 service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceNarrativeReviewService.cs"));
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceNarrative.cshtml")); 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 projectDelete = File.ReadAllText(Path.Combine(root, "Sql/124_Phase20Z2_StoryIntelligenceCommitAndProjectHardDelete.sql"));
var bookDelete = File.ReadAllText(Path.Combine(root, "Sql/134_Phase20AV_ArchivedBookProjectPermanentDeletion.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("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(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(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(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("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."); 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."); 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() static void StoryIntelligenceNarrativeReviewCreatesCanonicalRecordsViaExistingProcs()
{ {
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");

View File

@ -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`. 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 ## Questions Answered Rules
Report answers supplied by the scene. Report answers supplied by the scene.
@ -641,6 +690,17 @@ Return exactly one JSON object matching this shape:
"confidence": 0.0 "confidence": 0.0
} }
], ],
"narrativeArcs": [
{
"title": "",
"description": "",
"arcType": "other",
"scale": "major",
"matchingExistingPlotLineId": null,
"evidence": "",
"confidence": 0.0
}
],
"questionsRaised": [ "questionsRaised": [
{ {
"question": "", "question": "",

View File

@ -18,6 +18,7 @@ public sealed class SceneIntelligenceScene
public List<SceneIntelligenceRelationship>? Relationships { get; init; } public List<SceneIntelligenceRelationship>? Relationships { get; init; }
public List<SceneIntelligenceKnowledgeChange>? KnowledgeChanges { get; init; } public List<SceneIntelligenceKnowledgeChange>? KnowledgeChanges { get; init; }
public List<SceneIntelligenceTimelineClue>? TimelineClues { get; init; } public List<SceneIntelligenceTimelineClue>? TimelineClues { get; init; }
public List<SceneIntelligenceNarrativeArc>? NarrativeArcs { get; init; }
public List<SceneIntelligenceQuestionRaised>? QuestionsRaised { get; init; } public List<SceneIntelligenceQuestionRaised>? QuestionsRaised { get; init; }
public List<SceneIntelligenceQuestionAnswered>? QuestionsAnswered { get; init; } public List<SceneIntelligenceQuestionAnswered>? QuestionsAnswered { get; init; }
public List<SceneIntelligenceObservation>? Observations { get; init; } public List<SceneIntelligenceObservation>? Observations { get; init; }
@ -207,6 +208,20 @@ public sealed class SceneIntelligenceQuestionRaised
public Dictionary<string, JsonElement>? ExtensionData { get; init; } public Dictionary<string, JsonElement>? 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<string, JsonElement>? ExtensionData { get; init; }
}
public sealed class SceneIntelligenceQuestionAnswered public sealed class SceneIntelligenceQuestionAnswered
{ {
public string? Question { get; init; } public string? Question { get; init; }

View File

@ -190,13 +190,10 @@ public sealed class StoryIntelligenceNarrativeReviewService(
{ {
var existingPlotLines = await plots.ListPlotLinesAsync(batch.ProjectID); var existingPlotLines = await plots.ListPlotLinesAsync(batch.ProjectID);
var existingThreads = await plots.ListPlotThreadsByProjectAsync(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 var existingThreadByKey = existingThreads
.GroupBy(thread => Key(thread.ThreadTitle), StringComparer.OrdinalIgnoreCase) .GroupBy(thread => Key(thread.ThreadTitle), StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
var signals = new Dictionary<string, NarrativeSignal>(StringComparer.OrdinalIgnoreCase); var signalInputs = new List<StoryIntelligenceNarrativeSignalInput>();
foreach (var item in batch.Items) foreach (var item in batch.Items)
{ {
@ -224,23 +221,55 @@ public sealed class StoryIntelligenceNarrativeReviewService(
continue; 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 ?? []) 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 ?? []) 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 existingThread = existingThreadByKey.GetValueOrDefault(Key(signal.Title));
var existingPlotLine = existingPlotLineByKey.GetValueOrDefault(Key(signal.Title)); var candidateType = string.Equals(signal.Category, "Plot Line", StringComparison.OrdinalIgnoreCase) || (signal.ExistingPlotLineID.HasValue && existingThread is null)
var candidateType = signal.SceneIds.Count >= 3 && existingThread is null
? StoryIntelligenceNarrativeCandidateTypes.PlotLine ? StoryIntelligenceNarrativeCandidateTypes.PlotLine
: StoryIntelligenceNarrativeCandidateTypes.Thread; : StoryIntelligenceNarrativeCandidateTypes.Thread;
var candidateId = await candidates.SaveAsync(new StoryIntelligenceNarrativeCandidateSeed var candidateId = await candidates.SaveAsync(new StoryIntelligenceNarrativeCandidateSeed
@ -253,7 +282,7 @@ public sealed class StoryIntelligenceNarrativeReviewService(
ProposedDescription = signal.Description, ProposedDescription = signal.Description,
Category = signal.Category, Category = signal.Category,
InferredState = signal.State, InferredState = signal.State,
ExistingPlotLineID = existingPlotLine?.PlotLineID, ExistingPlotLineID = signal.ExistingPlotLineID,
ExistingPlotThreadID = existingThread?.PlotThreadID, ExistingPlotThreadID = existingThread?.PlotThreadID,
RelatedPlotLineID = existingThread?.PlotLineID, RelatedPlotLineID = existingThread?.PlotLineID,
Confidence = signal.Confidence Confidence = signal.Confidence
@ -306,35 +335,6 @@ public sealed class StoryIntelligenceNarrativeReviewService(
}).ToList() }).ToList()
}; };
private static void AddSignal(
Dictionary<string, NarrativeSignal> 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( private static Scene? ResolveImportedScene(
StoryIntelligenceSavedSceneResult sceneResult, StoryIntelligenceSavedSceneResult sceneResult,
IReadOnlyDictionary<string, Scene> importedByRange, IReadOnlyDictionary<string, Scene> importedByRange,
@ -389,15 +389,243 @@ public sealed class StoryIntelligenceNarrativeReviewService(
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim(); => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static string Key(string? value) 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()); => new((value ?? string.Empty).Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray());
private sealed record NarrativeSignal(int ProjectID, int BookID, string Title, string Category, string State) private static PlotLineItem? ResolveExistingPlotLine(
StoryIntelligenceNarrativeSignalInput input,
IReadOnlyList<PlotLineItem> existingPlotLines,
IReadOnlyDictionary<int, PlotLineItem> plotLinesById)
{ {
public HashSet<int> SceneIds { get; } = []; if (input.MatchingExistingPlotLineID is > 0 && plotLinesById.TryGetValue(input.MatchingExistingPlotLineID.Value, out var suppliedMatch))
public List<NarrativeSignalEvidence> Evidence { get; } = []; {
public decimal? Confidence { get; set; } return suppliedMatch;
public string Description => $"{Category} recurring across {SceneIds.Count:N0} scenes.";
} }
private sealed record NarrativeSignalEvidence(int SceneID, string ContributionType, string? Evidence); 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; }
}
} }

View File

@ -42,6 +42,7 @@ public sealed class StorySceneValidator : IStorySceneValidator
ValidateRelationships(scene.Relationships, result); ValidateRelationships(scene.Relationships, result);
ValidateKnowledgeChanges(scene.KnowledgeChanges, result); ValidateKnowledgeChanges(scene.KnowledgeChanges, result);
ValidateTimelineClues(scene.TimelineClues, result); ValidateTimelineClues(scene.TimelineClues, result);
ValidateNarrativeArcs(scene.NarrativeArcs, result);
ValidateQuestionsRaised(scene.QuestionsRaised, result); ValidateQuestionsRaised(scene.QuestionsRaised, result);
ValidateQuestionsAnswered(scene.QuestionsAnswered, result); ValidateQuestionsAnswered(scene.QuestionsAnswered, result);
ValidateObservations(scene.Observations, result); ValidateObservations(scene.Observations, result);
@ -80,6 +81,7 @@ public sealed class StorySceneValidator : IStorySceneValidator
RequiredArray(scene.Relationships, "relationships", result); RequiredArray(scene.Relationships, "relationships", result);
RequiredArray(scene.KnowledgeChanges, "knowledgeChanges", result); RequiredArray(scene.KnowledgeChanges, "knowledgeChanges", result);
RequiredArray(scene.TimelineClues, "timelineClues", result); RequiredArray(scene.TimelineClues, "timelineClues", result);
RequiredArray(scene.NarrativeArcs, "narrativeArcs", result);
RequiredArray(scene.QuestionsRaised, "questionsRaised", result); RequiredArray(scene.QuestionsRaised, "questionsRaised", result);
RequiredArray(scene.QuestionsAnswered, "questionsAnswered", result); RequiredArray(scene.QuestionsAnswered, "questionsAnswered", result);
RequiredArray(scene.Observations, "observations", result); RequiredArray(scene.Observations, "observations", result);
@ -340,6 +342,28 @@ public sealed class StorySceneValidator : IStorySceneValidator
} }
} }
private static void ValidateNarrativeArcs(List<SceneIntelligenceNarrativeArc>? 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<SceneIntelligenceQuestionAnswered>? values, ValidationResult result) private static void ValidateQuestionsAnswered(List<SceneIntelligenceQuestionAnswered>? values, ValidationResult result)
{ {
if (values is null) if (values is null)