Improve story intelligence narrative hierarchy

This commit is contained in:
Nick Beckley 2026-08-28 15:42:39 +00:00
parent 9c27ba6ffa
commit 90d8f65fcf
12 changed files with 1110 additions and 85 deletions

View File

@ -177,6 +177,15 @@ var tests = new (string Name, Action Test)[]
("Story Intelligence narrative candidates require review", StoryIntelligenceNarrativeCandidatesRequireReview), ("Story Intelligence narrative candidates require review", StoryIntelligenceNarrativeCandidatesRequireReview),
("Story Intelligence discovers Plot Lines without preconfiguration", StoryIntelligenceDiscoversPlotLinesWithoutPreconfiguration), ("Story Intelligence discovers Plot Lines without preconfiguration", StoryIntelligenceDiscoversPlotLinesWithoutPreconfiguration),
("Story Intelligence matches existing Plot Lines without duplicates", StoryIntelligenceMatchesExistingPlotLinesWithoutDuplicates), ("Story Intelligence matches existing Plot Lines without duplicates", StoryIntelligenceMatchesExistingPlotLinesWithoutDuplicates),
("Narrative research prototype fixture builds hierarchy", NarrativeResearchPrototypeFixtureBuildsHierarchy),
("Narrative sibling hotel fixture generalises hierarchy", NarrativeSiblingHotelFixtureGeneralisesHierarchy),
("Narrative trivial one-scene question is suppressed", NarrativeTrivialOneSceneQuestionIsSuppressed),
("Narrative short significant thread survives", NarrativeShortSignificantThreadSurvives),
("Narrative semantic evolution consolidates by continuity", NarrativeSemanticEvolutionConsolidatesByContinuity),
("Narrative existing thread matching is supported", NarrativeExistingThreadMatchingIsSupported),
("Narrative candidate merge and parent fields are persisted", NarrativeCandidateMergeAndParentFieldsArePersisted),
("Narrative prompt uses one scene call with compact schema", NarrativePromptUsesOneSceneCallWithCompactSchema),
("Narrative production code remains fixture agnostic", NarrativeProductionCodeRemainsFixtureAgnostic),
("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),
@ -4031,7 +4040,7 @@ static void StoryIntelligenceNarrativeCandidatesRequireReview()
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(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(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("ShouldCreateCandidate", StringComparison.Ordinal), "Thread discovery should filter one-off incidental details through candidate quality rules.");
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.");
Assert(projectDelete.Contains("StoryIntelligenceNarrativeCandidateEvidence", StringComparison.Ordinal) && projectDelete.Contains("StoryIntelligenceNarrativeCandidates", StringComparison.Ordinal), "Project hard delete should clear narrative review candidates."); Assert(projectDelete.Contains("StoryIntelligenceNarrativeCandidateEvidence", StringComparison.Ordinal) && projectDelete.Contains("StoryIntelligenceNarrativeCandidates", StringComparison.Ordinal), "Project hard delete should clear narrative review candidates.");
@ -4042,9 +4051,9 @@ static void StoryIntelligenceDiscoversPlotLinesWithoutPreconfiguration()
{ {
var signals = new[] 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."), NarrativeSignal(101, "Maggie's Disappearance", "Beth notices Maggie is gone and starts looking for her.", "Plot Line", "Developed", "major", "missing maggie", null, "disappearance", 0.86m, null, 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."), NarrativeSignal(102, "Search for Maggie", "Beth follows a clue that may explain where Maggie went.", "Plot Line", "Developed", "major", "missing maggie", null, "investigation", 0.82m, null, 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.") NarrativeSignal(103, "Finding Maggie", "The search for Maggie reaches a possible answer.", "Plot Line", "Revealed", "major", "missing maggie", null, "disappearance", 0.8m, null, null, "Maggie may have been found.")
}; };
var consolidated = StoryIntelligenceNarrativeSignalConsolidator.Consolidate(signals, []); var consolidated = StoryIntelligenceNarrativeSignalConsolidator.Consolidate(signals, []);
@ -4070,9 +4079,9 @@ static void StoryIntelligenceMatchesExistingPlotLinesWithoutDuplicates()
}; };
var signals = new[] 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."), NarrativeSignal(201, "Search for Maggie", "Beth follows a clue in Maggie's disappearance.", "Plot Line", "Developed", "major", null, null, "investigation", 0.84m, null, 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."), NarrativeSignal(202, "Missing Maggie", "Maggie's absence drives the investigation.", "Plot Line", "Developed", "major", null, null, "disappearance", 0.81m, null, 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.") NarrativeSignal(203, "Where is Maggie?", "A lead suggests where Maggie may have gone.", "Plot Line", "Developed", "major", null, null, "disappearance", 0.79m, null, null, "A lead points to Maggie.")
}; };
var consolidated = StoryIntelligenceNarrativeSignalConsolidator.Consolidate(signals, existingPlotLines); var consolidated = StoryIntelligenceNarrativeSignalConsolidator.Consolidate(signals, existingPlotLines);
@ -4082,6 +4091,196 @@ static void StoryIntelligenceMatchesExistingPlotLinesWithoutDuplicates()
Assert(consolidated[0].SceneIds.Count == 3, "The existing Plot Line match should retain evidence from multiple scenes."); Assert(consolidated[0].SceneIds.Count == 3, "The existing Plot Line match should retain evidence from multiple scenes.");
} }
static StoryIntelligenceNarrativeSignalInput NarrativeSignal(
int sceneId,
string? title,
string? description,
string category,
string changeType,
string significance,
string? continuityKey,
string? parentTitle,
string arcType,
decimal? confidence,
int? matchingExistingPlotLineId,
int? matchingExistingThreadId,
string? evidence)
=> new(
sceneId,
title,
description,
category,
changeType,
significance,
continuityKey,
parentTitle,
arcType,
confidence,
matchingExistingPlotLineId,
matchingExistingThreadId,
evidence);
static void NarrativeResearchPrototypeFixtureBuildsHierarchy()
{
var signals = new[]
{
NarrativeSignal(1, "The Missing Prototype", "The research prototype is discovered missing.", "Plot Line", "Introduced", "major", "research prototype", null, "investigation", 0.91m, null, null, "The prototype is missing."),
NarrativeSignal(2, "Security Investigation", "The security chief starts tracing what happened.", "Plot Line", "Developed", "major", "research prototype", null, "investigation", 0.86m, null, null, "An investigation begins."),
NarrativeSignal(3, "Camera Footage", "Camera evidence gives the investigation its first direction.", "Thread", "Revealed", "moderate", "prototype theft evidence", "The Missing Prototype", "clue", 0.8m, null, null, "Footage shows suspicious movement."),
NarrativeSignal(4, "The Implicated Engineer", "A trusted engineer appears connected to the theft.", "Thread", "Complicated", "moderate", "prototype thief identity", "The Missing Prototype", "suspicion", 0.79m, null, null, "Access logs implicate the engineer."),
NarrativeSignal(5, "The Engineer Cleared", "Evidence clears the engineer and points to another actor.", "Thread", "Revealed", "moderate", "prototype thief identity", "The Missing Prototype", "suspicion", 0.83m, null, null, "A timestamp clears the engineer."),
NarrativeSignal(6, "Prototype Location Found", "The prototype is traced to a storage site.", "Thread", "Revealed", "moderate", "prototype location", "The Missing Prototype", "clue", 0.84m, null, null, "Delivery records reveal the location."),
NarrativeSignal(7, "Betrayer Identified", "The insider responsible is identified.", "Thread", "Resolved", "major", "prototype thief identity", "The Missing Prototype", "mystery", 0.88m, null, null, "The betrayer is identified."),
NarrativeSignal(8, "Prototype Recovered", "The missing prototype is recovered.", "Plot Line", "Resolved", "major", "research prototype", null, "investigation", 0.9m, null, null, "The device is recovered.")
};
var consolidated = StoryIntelligenceNarrativeSignalConsolidator.Consolidate(signals, []);
var plotLines = consolidated.Where(signal => signal.Category == "Plot Line" && signal.ShouldCreateCandidate).ToList();
var threads = consolidated.Where(signal => signal.Category == "Thread" && signal.ShouldCreateCandidate).ToList();
Assert(plotLines.Count == 1, $"Expected one coherent prototype Plot Line, got {plotLines.Count}.");
Assert(plotLines[0].SceneIds.Order().SequenceEqual(new[] { 1, 2, 8 }), "Prototype Plot Line should retain exact supporting SceneIDs.");
Assert(threads.Count >= 2 && threads.Count < 8, "Prototype fixture should produce subordinate Threads without one candidate per scene.");
Assert(threads.Any(thread => thread.ParentCandidateKey is not null), "Prototype Threads should carry a proposed parent Plot Line candidate key.");
Assert(threads.SelectMany(thread => thread.Evidence).Any(evidence => evidence.ContributionType == "Resolved"), "Thread evidence should preserve resolution events.");
}
static void NarrativeSiblingHotelFixtureGeneralisesHierarchy()
{
var signals = new[]
{
NarrativeSignal(11, "The Failing Hotel", "Estranged siblings inherit a hotel that may have to be sold.", "Plot Line", "Introduced", "major", "hotel future", null, "family", 0.86m, null, null, "The hotel is failing."),
NarrativeSignal(12, "Selling or Saving the Hotel", "The siblings clash over whether to sell or restore the hotel.", "Plot Line", "Developed", "major", "hotel future", null, "family", 0.84m, null, null, "They argue over the hotel's future."),
NarrativeSignal(13, "Sibling Estrangement", "Old resentment shapes how the siblings handle the inheritance.", "Thread", "Introduced", "moderate", "sibling estrangement", "The Failing Hotel", "relationship", 0.82m, null, null, "They avoid discussing the past."),
NarrativeSignal(14, "Parent's Instructions", "Unusual instructions in the will raise a continuing question.", "Thread", "Introduced", "moderate", "parent instructions", "The Failing Hotel", "secret", 0.81m, null, null, "The will contains unexplained conditions."),
NarrativeSignal(15, "Working Together", "The siblings cooperate when a public event threatens the hotel.", "Thread", "Developed", "moderate", "sibling estrangement", "The Failing Hotel", "relationship", 0.83m, null, null, "They work together under pressure.")
};
var consolidated = StoryIntelligenceNarrativeSignalConsolidator.Consolidate(signals, []);
Assert(consolidated.Count(signal => signal.Category == "Plot Line" && signal.ShouldCreateCandidate) == 1, "Domestic hotel fixture should consolidate into one major Plot Line.");
Assert(consolidated.Any(signal => signal.Category == "Thread" && signal.ShouldCreateCandidate && signal.ParentCandidateKey is not null), "Domestic fixture should produce child Threads through the same generic mechanism.");
}
static void NarrativeTrivialOneSceneQuestionIsSuppressed()
{
var signals = new[]
{
NarrativeSignal(21, "Will the courier arrive before six?", null, "Thread", "Introduced", "minor", null, null, "question", 0.76m, null, null, "The courier question is answered in the same exchange.")
};
var consolidated = StoryIntelligenceNarrativeSignalConsolidator.Consolidate(signals, []);
Assert(consolidated.Count(signal => signal.ShouldCreateCandidate) == 0, "A one-scene minor courier question must not become a book-level Thread candidate.");
}
static void NarrativeShortSignificantThreadSurvives()
{
var signals = new[]
{
NarrativeSignal(31, "The Locked Safe", "A safe blocks access to evidence needed for the investigation.", "Thread", "Introduced", "major", "locked safe evidence", "The Missing Prototype", "obstacle", 0.81m, null, null, "The safe prevents access."),
NarrativeSignal(32, "The Safe Opens", "Opening the safe exposes decisive evidence.", "Thread", "Resolved", "major", "locked safe evidence", "The Missing Prototype", "obstacle", 0.87m, null, null, "The safe reveals decisive evidence.")
};
var thread = StoryIntelligenceNarrativeSignalConsolidator.Consolidate(signals, [])
.Single(signal => signal.Category == "Thread");
Assert(thread.ShouldCreateCandidate, "A short two-scene Thread should survive when its significance is major.");
Assert(thread.Evidence.Select(evidence => evidence.ContributionType).SequenceEqual(new[] { "Introduced", "Resolved" }), "Short Thread progression should preserve introduced/resolved history.");
}
static void NarrativeSemanticEvolutionConsolidatesByContinuity()
{
var signals = new[]
{
NarrativeSignal(41, "Prototype stolen", "The prototype vanishes from the lab.", "Plot Line", "Introduced", "major", "research prototype recovery", null, "investigation", 0.9m, null, null, "The prototype is gone."),
NarrativeSignal(42, "Security investigation", "Access records become the focus.", "Plot Line", "Developed", "major", "research prototype recovery", null, "investigation", 0.85m, null, null, "Security records are reviewed."),
NarrativeSignal(43, "Warehouse clue", "A shipping clue points to a warehouse.", "Plot Line", "Revealed", "major", "research prototype recovery", null, "investigation", 0.84m, null, null, "A warehouse clue appears."),
NarrativeSignal(44, "Betrayer exposed", "The insider's role is exposed.", "Plot Line", "Revealed", "major", "research prototype recovery", null, "investigation", 0.86m, null, null, "The insider is exposed."),
NarrativeSignal(45, "Device recovered", "The stolen device is recovered.", "Plot Line", "Resolved", "major", "research prototype recovery", null, "investigation", 0.88m, null, null, "The device is recovered.")
};
var consolidated = StoryIntelligenceNarrativeSignalConsolidator.Consolidate(signals, []);
Assert(consolidated.Count == 1, "Semantic evolution with a shared continuity key should consolidate without another AI call.");
Assert(consolidated[0].SceneIds.Count == 5, "All semantically evolved SceneIDs should remain attached exactly.");
}
static void NarrativeExistingThreadMatchingIsSupported()
{
var existingPlotLines = new[] { new PlotLineItem { PlotLineID = 701, PlotLineName = "Corporate Sabotage", Description = "A company investigation into sabotage." } };
var existingThreads = new[] { new PlotThread { PlotThreadID = 801, PlotLineID = 701, PlotLineName = "Corporate Sabotage", ThreadTitle = "Who Tampered With Security?", Summary = "Identify who altered the security system." } };
var signals = new[]
{
NarrativeSignal(51, "Tampered cameras", "Security footage has been altered.", "Thread", "Developed", "moderate", "security tampering", "Corporate Sabotage", "mystery", 0.82m, null, 801, "The camera files were altered.")
};
var consolidated = StoryIntelligenceNarrativeSignalConsolidator.Consolidate(signals, existingPlotLines, existingThreads);
Assert(consolidated.Single().ExistingPlotThreadID == 801, "Existing canonical Threads should be matched through supplied IDs.");
Assert(consolidated.Single().RelatedPlotLineID == 701, "Existing Thread matches should preserve their parent PlotLineID.");
}
static void NarrativeCandidateMergeAndParentFieldsArePersisted()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var migration = File.ReadAllText(Path.Combine(root, "Sql/168_Phase26D_NarrativeCandidateHierarchy.sql"));
var models = File.ReadAllText(Path.Combine(root, "Models/StoryIntelligenceNarrativeModels.cs"));
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceNarrativeReviewService.cs"));
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceNarrative.cshtml"));
Assert(migration.Contains("ParentNarrativeCandidateID", StringComparison.Ordinal), "Narrative candidates should persist proposed parent candidate relationships.");
Assert(migration.Contains("MergeTargetNarrativeCandidateID", StringComparison.Ordinal), "Narrative candidates should persist candidate-to-candidate merge targets.");
Assert(models.Contains("MergeCandidate", StringComparison.Ordinal), "Review actions should include candidate-to-candidate merge.");
Assert(service.Contains("ResolveCreatedOrMergedPlotLine", StringComparison.Ordinal), "Accepted child Threads should resolve newly-created or merged parent Plot Lines before canonical Thread creation.");
Assert(service.Contains("using var transaction = connection.BeginTransaction()", StringComparison.Ordinal), "Narrative acceptance should apply canonical Plot Line/Thread/Event changes in one database transaction.");
Assert(view.Contains("Merge with candidate", StringComparison.Ordinal), "Minimal review UI should expose candidate-to-candidate merge.");
}
static void NarrativePromptUsesOneSceneCallWithCompactSchema()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var prompt = File.ReadAllText(Path.Combine(root, "Docs/AI/Scene-Prompt-V2.md"));
var runner = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceRunner.cs"));
var context = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceSceneContextService.cs"));
Assert(prompt.Contains("changeType", StringComparison.Ordinal) && prompt.Contains("significance", StringComparison.Ordinal), "Scene prompt should request compact narrative change and significance fields.");
Assert(prompt.Contains("matchingExistingThreadId", StringComparison.Ordinal), "Scene prompt should support existing Thread matching.");
Assert(context.Contains("existingThreads", StringComparison.Ordinal), "Canonical scene context should carry existing Threads compactly.");
Assert(!runner.Contains("ConsolidatePlotLineWithAi", StringComparison.Ordinal), "Job 4 must not add a Plot Line consolidation AI call.");
Assert(!runner.Contains("WholeBookNarrative", StringComparison.Ordinal), "Job 4 must not add a whole-book narrative AI call.");
}
static void NarrativeProductionCodeRemainsFixtureAgnostic()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var productionFiles = Directory.EnumerateFiles(root, "*.*", SearchOption.AllDirectories)
.Where(file => file.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase) == false)
.Where(file => file.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase) == false)
.Where(file => file.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)
|| file.EndsWith(".cshtml", StringComparison.OrdinalIgnoreCase)
|| file.EndsWith(".md", StringComparison.OrdinalIgnoreCase)
|| file.EndsWith(".sql", StringComparison.OrdinalIgnoreCase))
.Where(file => !file.Contains($"{Path.DirectorySeparatorChar}Docs{Path.DirectorySeparatorChar}story-intelligence", StringComparison.OrdinalIgnoreCase))
.ToList();
var forbidden = new[] { "Memory Tin", "The Alpha Flame", "Ashdown", "Maggie", "Beth", "Rob", "Rosie", "Sophie", "Simon" };
var job4Files = productionFiles.Where(file =>
file.EndsWith("StoryIntelligenceNarrativeReviewService.cs", StringComparison.OrdinalIgnoreCase)
|| file.EndsWith("Scene-Prompt-V2.md", StringComparison.OrdinalIgnoreCase)
|| file.EndsWith("StoryIntelligenceSceneContextService.cs", StringComparison.OrdinalIgnoreCase)
|| file.EndsWith("PersistedStoryIntelligenceRunner.cs", StringComparison.OrdinalIgnoreCase)
|| file.EndsWith("168_Phase26D_NarrativeCandidateHierarchy.sql", StringComparison.OrdinalIgnoreCase)).ToList();
foreach (var file in job4Files)
{
var text = File.ReadAllText(file);
foreach (var term in forbidden)
{
Assert(!text.Contains(term, StringComparison.OrdinalIgnoreCase), $"Job 4 production file {Path.GetFileName(file)} must not contain fixture term {term}.");
}
}
}
static void StoryIntelligenceNarrativeReviewCreatesCanonicalRecordsViaExistingProcs() static void StoryIntelligenceNarrativeReviewCreatesCanonicalRecordsViaExistingProcs()
{ {
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
@ -4089,9 +4288,10 @@ static void StoryIntelligenceNarrativeReviewCreatesCanonicalRecordsViaExistingPr
var repository = File.ReadAllText(Path.Combine(root, "Data/Repositories.cs")); var repository = File.ReadAllText(Path.Combine(root, "Data/Repositories.cs"));
var sql = File.ReadAllText(Path.Combine(root, "Sql/155_Phase25_StoryIntelligenceNarrativeReview.sql")); var sql = File.ReadAllText(Path.Combine(root, "Sql/155_Phase25_StoryIntelligenceNarrativeReview.sql"));
Assert(service.Contains("plots.SavePlotLineAsync(new PlotLineItem", StringComparison.Ordinal), "Accepted Plot Line candidates should create canonical PlotLines through the existing plot service."); Assert(service.Contains("\"dbo.PlotLine_Save\"", StringComparison.Ordinal), "Accepted Plot Line candidates should create canonical PlotLines through a stored procedure.");
Assert(service.Contains("plots.SavePlotThreadAsync(new PlotThread", StringComparison.Ordinal), "Accepted Thread candidates should create canonical PlotThreads through the existing plot service."); Assert(service.Contains("\"dbo.PlotThread_Save\"", StringComparison.Ordinal), "Accepted Thread candidates should create canonical PlotThreads through a stored procedure.");
Assert(service.Contains("plots.SaveThreadEventAsync(new ThreadEvent", StringComparison.Ordinal), "Accepted Thread evidence should create canonical ThreadEvents."); Assert(service.Contains("\"dbo.ThreadEvent_Save\"", StringComparison.Ordinal), "Accepted Thread evidence should create canonical ThreadEvents through a stored procedure.");
Assert(service.Contains("using var transaction = connection.BeginTransaction()", StringComparison.Ordinal), "Narrative acceptance should apply canonical records and candidate decisions transactionally.");
Assert(repository.Contains("\"dbo.PlotLine_Save\"", StringComparison.Ordinal), "PlotLine creation should remain stored-procedure based."); Assert(repository.Contains("\"dbo.PlotLine_Save\"", StringComparison.Ordinal), "PlotLine creation should remain stored-procedure based.");
Assert(repository.Contains("\"dbo.PlotThread_Save\"", StringComparison.Ordinal), "PlotThread creation should remain stored-procedure based."); Assert(repository.Contains("\"dbo.PlotThread_Save\"", StringComparison.Ordinal), "PlotThread creation should remain stored-procedure based.");
Assert(repository.Contains("\"dbo.ThreadEvent_Save\"", StringComparison.Ordinal), "ThreadEvent creation should remain stored-procedure based."); Assert(repository.Contains("\"dbo.ThreadEvent_Save\"", StringComparison.Ordinal), "ThreadEvent creation should remain stored-procedure based.");

View File

@ -173,7 +173,7 @@ public interface IStoryIntelligenceNarrativeCandidateRepository
Task<int> SaveAsync(StoryIntelligenceNarrativeCandidateSeed candidate); Task<int> SaveAsync(StoryIntelligenceNarrativeCandidateSeed candidate);
Task SaveEvidenceAsync(int candidateId, int sceneId, string? contributionType, string? evidence, int sortOrder); Task SaveEvidenceAsync(int candidateId, int sceneId, string? contributionType, string? evidence, int sortOrder);
Task<IReadOnlyList<StoryIntelligenceNarrativeCandidate>> ListByBookAsync(int bookId); Task<IReadOnlyList<StoryIntelligenceNarrativeCandidate>> ListByBookAsync(int bookId);
Task MarkReviewedAsync(int candidateId, string status, string reviewAction, int userId, int? acceptedPlotLineId, int? acceptedPlotThreadId); Task MarkReviewedAsync(int candidateId, string status, string reviewAction, int userId, int? acceptedPlotLineId, int? acceptedPlotThreadId, int? mergeTargetCandidateId = null);
} }
public interface IAssetRepository public interface IAssetRepository
@ -3256,9 +3256,13 @@ public sealed class StoryIntelligenceNarrativeCandidateRepository(ISqlConnection
candidate.ProposedDescription, candidate.ProposedDescription,
candidate.Category, candidate.Category,
candidate.InferredState, candidate.InferredState,
candidate.ChangeType,
candidate.Significance,
candidate.ExistingPlotLineID, candidate.ExistingPlotLineID,
candidate.ExistingPlotThreadID, candidate.ExistingPlotThreadID,
candidate.RelatedPlotLineID, candidate.RelatedPlotLineID,
candidate.ParentCandidateKey,
candidate.ParentNarrativeCandidateID,
candidate.Confidence candidate.Confidence
}, },
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);
@ -3301,7 +3305,7 @@ public sealed class StoryIntelligenceNarrativeCandidateRepository(ISqlConnection
return candidates; return candidates;
} }
public async Task MarkReviewedAsync(int candidateId, string status, string reviewAction, int userId, int? acceptedPlotLineId, int? acceptedPlotThreadId) public async Task MarkReviewedAsync(int candidateId, string status, string reviewAction, int userId, int? acceptedPlotLineId, int? acceptedPlotThreadId, int? mergeTargetCandidateId = null)
{ {
using var connection = connectionFactory.CreateConnection(); using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync( await connection.ExecuteAsync(
@ -3313,7 +3317,8 @@ public sealed class StoryIntelligenceNarrativeCandidateRepository(ISqlConnection
ReviewAction = reviewAction, ReviewAction = reviewAction,
ReviewedByUserID = userId, ReviewedByUserID = userId,
AcceptedPlotLineID = acceptedPlotLineId, AcceptedPlotLineID = acceptedPlotLineId,
AcceptedPlotThreadID = acceptedPlotThreadId AcceptedPlotThreadID = acceptedPlotThreadId,
MergeTargetNarrativeCandidateID = mergeTargetCandidateId
}, },
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);
} }

View File

@ -421,12 +421,25 @@ When `existingPlotLines` is supplied in `sceneContext`:
- if no supplied Plot Line clearly matches, set `matchingExistingPlotLineId` to `null`; - if no supplied Plot Line clearly matches, set `matchingExistingPlotLineId` to `null`;
- do not invent Plot Line ids. - do not invent Plot Line ids.
For each narrative arc, include exactly these properties: `title`, `description`, `arcType`, `scale`, `matchingExistingPlotLineId`, `evidence`, `confidence`. When `existingThreads` is supplied in `sceneContext`:
- compare the scene's narrower thread-scale signal with supplied Thread `title`, `summary` and parent Plot Line context;
- if the scene clearly continues an existing Thread, set `matchingExistingThreadId` to that supplied `plotThreadId`;
- if no supplied Thread clearly matches, set `matchingExistingThreadId` to `null`;
- do not invent Thread ids.
For each narrative arc, include exactly these properties: `title`, `description`, `arcType`, `scale`, `changeType`, `significance`, `continuityKey`, `parentArcTitle`, `matchingExistingPlotLineId`, `matchingExistingThreadId`, `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 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. 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.
Use concise `changeType` values: `Introduced`, `Developed`, `Complicated`, `Revealed`, `Escalated`, `Resolved`, `Reopened`, or `Mentioned`.
Use `significance: "major"` for a material story development, `significance: "moderate"` for a meaningful supporting development, and `significance: "minor"` only where the signal is useful evidence but not enough on its own.
Set `continuityKey` to a short stable concept label that would remain recognisable even if later scenes use different wording for the same narrative concern. Prefer the underlying concern or objective, not this scene's phrasing. Use `parentArcTitle` only for a thread-scale or secondary signal that appears to sit beneath a broader arc; otherwise use `null`.
Good: Good:
```json ```json
@ -435,7 +448,12 @@ Good:
"description": "Mara's search for Lena advances through absence, clues and investigation.", "description": "Mara's search for Lena advances through absence, clues and investigation.",
"arcType": "disappearance", "arcType": "disappearance",
"scale": "major", "scale": "major",
"changeType": "Developed",
"significance": "major",
"continuityKey": "Lena disappearance",
"parentArcTitle": null,
"matchingExistingPlotLineId": null, "matchingExistingPlotLineId": null,
"matchingExistingThreadId": null,
"evidence": "Mara follows a clue that may explain where Lena went.", "evidence": "Mara follows a clue that may explain where Lena went.",
"confidence": 0.82 "confidence": 0.82
} }
@ -689,7 +707,12 @@ Return exactly one JSON object matching this shape:
"description": "", "description": "",
"arcType": "other", "arcType": "other",
"scale": "major", "scale": "major",
"changeType": "Introduced",
"significance": "major",
"continuityKey": "",
"parentArcTitle": null,
"matchingExistingPlotLineId": null, "matchingExistingPlotLineId": null,
"matchingExistingThreadId": null,
"evidence": "", "evidence": "",
"confidence": 0.0 "confidence": 0.0
} }

View File

@ -214,7 +214,12 @@ public sealed class SceneIntelligenceNarrativeArc
public string? Description { get; init; } public string? Description { get; init; }
public string? ArcType { get; init; } public string? ArcType { get; init; }
public string? Scale { get; init; } public string? Scale { get; init; }
public string? ChangeType { get; init; }
public string? Significance { get; init; }
public string? ContinuityKey { get; init; }
public string? ParentArcTitle { get; init; }
public int? MatchingExistingPlotLineId { get; init; } public int? MatchingExistingPlotLineId { get; init; }
public int? MatchingExistingThreadId { get; init; }
public string? Evidence { get; init; } public string? Evidence { get; init; }
public decimal? Confidence { get; init; } public decimal? Confidence { get; init; }

View File

@ -18,6 +18,7 @@ public static class StoryIntelligenceNarrativeReviewActions
{ {
public const string AcceptNew = "AcceptNew"; public const string AcceptNew = "AcceptNew";
public const string MergeExisting = "MergeExisting"; public const string MergeExisting = "MergeExisting";
public const string MergeCandidate = "MergeCandidate";
public const string Reject = "Reject"; public const string Reject = "Reject";
} }
@ -32,12 +33,18 @@ public sealed class StoryIntelligenceNarrativeCandidate
public string? ProposedDescription { get; set; } public string? ProposedDescription { get; set; }
public string? Category { get; set; } public string? Category { get; set; }
public string? InferredState { get; set; } public string? InferredState { get; set; }
public string? ChangeType { get; set; }
public string? Significance { get; set; }
public int? ExistingPlotLineID { get; set; } public int? ExistingPlotLineID { get; set; }
public string? ExistingPlotLineName { get; set; } public string? ExistingPlotLineName { get; set; }
public int? ExistingPlotThreadID { get; set; } public int? ExistingPlotThreadID { get; set; }
public string? ExistingPlotThreadTitle { get; set; } public string? ExistingPlotThreadTitle { get; set; }
public int? RelatedPlotLineID { get; set; } public int? RelatedPlotLineID { get; set; }
public string? RelatedPlotLineName { get; set; } public string? RelatedPlotLineName { get; set; }
public string? ParentCandidateKey { get; set; }
public int? ParentNarrativeCandidateID { get; set; }
public string? ParentCandidateTitle { get; set; }
public int? MergeTargetNarrativeCandidateID { get; set; }
public decimal? Confidence { get; set; } public decimal? Confidence { get; set; }
public string Status { get; set; } = StoryIntelligenceNarrativeCandidateStatuses.Pending; public string Status { get; set; } = StoryIntelligenceNarrativeCandidateStatuses.Pending;
public string? ReviewAction { get; set; } public string? ReviewAction { get; set; }
@ -77,8 +84,12 @@ public sealed class StoryIntelligenceNarrativeCandidateSeed
public string? ProposedDescription { get; init; } public string? ProposedDescription { get; init; }
public string? Category { get; init; } public string? Category { get; init; }
public string? InferredState { get; init; } public string? InferredState { get; init; }
public string? ChangeType { get; init; }
public string? Significance { get; init; }
public int? ExistingPlotLineID { get; init; } public int? ExistingPlotLineID { get; init; }
public int? ExistingPlotThreadID { get; init; } public int? ExistingPlotThreadID { get; init; }
public int? RelatedPlotLineID { get; init; } public int? RelatedPlotLineID { get; init; }
public string? ParentCandidateKey { get; init; }
public int? ParentNarrativeCandidateID { get; init; }
public decimal? Confidence { get; init; } public decimal? Confidence { get; init; }
} }

View File

@ -96,10 +96,18 @@ public sealed class PersistedStoryIntelligenceRunner(
.ThenBy(plotLine => plotLine.PlotLineName) .ThenBy(plotLine => plotLine.PlotLineName)
.ToList() .ToList()
: []; : [];
var existingThreads = run.ProjectID.HasValue
? (await plots.ListPlotThreadsByProjectAsync(run.ProjectID.Value))
.Where(thread => existingPlotLines.Any(plotLine => plotLine.PlotLineID == thread.PlotLineID))
.OrderBy(thread => thread.PlotLineName)
.ThenBy(thread => thread.ThreadTitle)
.ToList()
: [];
var mode = await SelectSceneAnalysisModeAsync( var mode = await SelectSceneAnalysisModeAsync(
run, run,
configuredMetrics, configuredMetrics,
existingPlotLines, existingPlotLines,
existingThreads,
chapterPromptVersion, chapterPromptVersion,
stageModels, stageModels,
totals, totals,
@ -453,6 +461,7 @@ public sealed class PersistedStoryIntelligenceRunner(
StoryIntelligenceQueuedRun run, StoryIntelligenceQueuedRun run,
IReadOnlyList<SceneMetricType> configuredMetrics, IReadOnlyList<SceneMetricType> configuredMetrics,
IReadOnlyList<PlotLineItem> existingPlotLines, IReadOnlyList<PlotLineItem> existingPlotLines,
IReadOnlyList<PlotThread> existingThreads,
string chapterPromptVersion, string chapterPromptVersion,
StoryIntelligenceStageModels stageModels, StoryIntelligenceStageModels stageModels,
TokenTotals totals, TokenTotals totals,
@ -461,7 +470,7 @@ public sealed class PersistedStoryIntelligenceRunner(
{ {
if (!run.ChapterID.HasValue) if (!run.ChapterID.HasValue)
{ {
return await ProcessLegacyChapterAsync(run, configuredMetrics, existingPlotLines, chapterPromptVersion, stageModels, totals, elapsedMs, cancellationToken); return await ProcessLegacyChapterAsync(run, configuredMetrics, existingPlotLines, existingThreads, chapterPromptVersion, stageModels, totals, elapsedMs, cancellationToken);
} }
var canonicalScenes = await repository.ListChapterScenesAsync(run.ChapterID.Value); var canonicalScenes = await repository.ListChapterScenesAsync(run.ChapterID.Value);
@ -471,7 +480,7 @@ public sealed class PersistedStoryIntelligenceRunner(
"Story Intelligence mode: LegacyBoundaryDetection. RunID={RunID} ChapterID={ChapterID} has no active canonical scenes.", "Story Intelligence mode: LegacyBoundaryDetection. RunID={RunID} ChapterID={ChapterID} has no active canonical scenes.",
run.StoryIntelligenceRunID, run.StoryIntelligenceRunID,
run.ChapterID); run.ChapterID);
return await ProcessLegacyChapterAsync(run, configuredMetrics, existingPlotLines, chapterPromptVersion, stageModels, totals, elapsedMs, cancellationToken); return await ProcessLegacyChapterAsync(run, configuredMetrics, existingPlotLines, existingThreads, chapterPromptVersion, stageModels, totals, elapsedMs, cancellationToken);
} }
var sourceRows = await builds.ListSceneManuscriptSourcesByChapterAsync(run.ChapterID.Value); var sourceRows = await builds.ListSceneManuscriptSourcesByChapterAsync(run.ChapterID.Value);
@ -481,7 +490,7 @@ public sealed class PersistedStoryIntelligenceRunner(
"Story Intelligence mode: LegacyBoundaryDetection. RunID={RunID} ChapterID={ChapterID} has active scenes but no canonical scene source rows.", "Story Intelligence mode: LegacyBoundaryDetection. RunID={RunID} ChapterID={ChapterID} has active scenes but no canonical scene source rows.",
run.StoryIntelligenceRunID, run.StoryIntelligenceRunID,
run.ChapterID); run.ChapterID);
return await ProcessLegacyChapterAsync(run, configuredMetrics, existingPlotLines, chapterPromptVersion, stageModels, totals, elapsedMs, cancellationToken); return await ProcessLegacyChapterAsync(run, configuredMetrics, existingPlotLines, existingThreads, chapterPromptVersion, stageModels, totals, elapsedMs, cancellationToken);
} }
var sourcesByScene = sourceRows.ToDictionary(source => source.SceneID); var sourcesByScene = sourceRows.ToDictionary(source => source.SceneID);
@ -516,7 +525,8 @@ public sealed class PersistedStoryIntelligenceRunner(
sourcesByScene[canonicalScenes[index].SceneID], sourcesByScene[canonicalScenes[index].SceneID],
index + 1, index + 1,
configuredMetrics, configuredMetrics,
existingPlotLines)); existingPlotLines,
existingThreads));
} }
if (sceneBlocks.Any(block => !block.SplitValid)) if (sceneBlocks.Any(block => !block.SplitValid))
@ -544,6 +554,7 @@ public sealed class PersistedStoryIntelligenceRunner(
StoryIntelligenceQueuedRun run, StoryIntelligenceQueuedRun run,
IReadOnlyList<SceneMetricType> configuredMetrics, IReadOnlyList<SceneMetricType> configuredMetrics,
IReadOnlyList<PlotLineItem> existingPlotLines, IReadOnlyList<PlotLineItem> existingPlotLines,
IReadOnlyList<PlotThread> existingThreads,
string chapterPromptVersion, string chapterPromptVersion,
StoryIntelligenceStageModels stageModels, StoryIntelligenceStageModels stageModels,
TokenTotals totals, TokenTotals totals,
@ -678,7 +689,7 @@ public sealed class PersistedStoryIntelligenceRunner(
} }
var sceneBlocks = chapterForProcessing.SceneBoundaries var sceneBlocks = chapterForProcessing.SceneBoundaries
.Select(boundary => BuildLegacySceneBlock(run, paragraphs, boundary, configuredMetrics, existingPlotLines)) .Select(boundary => BuildLegacySceneBlock(run, paragraphs, boundary, configuredMetrics, existingPlotLines, existingThreads))
.ToList(); .ToList();
return new SceneAnalysisModeSelection( return new SceneAnalysisModeSelection(
@ -1011,7 +1022,8 @@ public sealed class PersistedStoryIntelligenceRunner(
SceneManuscriptSourceRecord source, SceneManuscriptSourceRecord source,
int ordinal, int ordinal,
IReadOnlyList<SceneMetricType> configuredMetrics, IReadOnlyList<SceneMetricType> configuredMetrics,
IReadOnlyList<PlotLineItem> existingPlotLines) IReadOnlyList<PlotLineItem> existingPlotLines,
IReadOnlyList<PlotThread> existingThreads)
{ {
var sceneNumber = SceneDisplayNumber(scene.SceneNumber, ordinal); var sceneNumber = SceneDisplayNumber(scene.SceneNumber, ordinal);
var start = source.SourceStartParagraph ?? source.WordStartParagraphIndex ?? 0; var start = source.SourceStartParagraph ?? source.WordStartParagraphIndex ?? 0;
@ -1025,7 +1037,7 @@ public sealed class PersistedStoryIntelligenceRunner(
&& (!source.WordStartParagraphIndex.HasValue || !source.WordEndParagraphIndex.HasValue || source.WordStartParagraphIndex <= source.WordEndParagraphIndex); && (!source.WordStartParagraphIndex.HasValue || !source.WordEndParagraphIndex.HasValue || source.WordStartParagraphIndex <= source.WordEndParagraphIndex);
var context = splitValid var context = splitValid
? await sceneContexts.BuildAsync(run, orderedChapterScenes, scene, source, configuredMetrics, existingPlotLines) ? await sceneContexts.BuildAsync(run, orderedChapterScenes, scene, source, configuredMetrics, existingPlotLines, existingThreads)
: null; : null;
return new StorySceneTextBlock( return new StorySceneTextBlock(
@ -1046,7 +1058,8 @@ public sealed class PersistedStoryIntelligenceRunner(
IReadOnlyList<string> paragraphs, IReadOnlyList<string> paragraphs,
ChapterSceneBoundary boundary, ChapterSceneBoundary boundary,
IReadOnlyList<SceneMetricType> configuredMetrics, IReadOnlyList<SceneMetricType> configuredMetrics,
IReadOnlyList<PlotLineItem> existingPlotLines) IReadOnlyList<PlotLineItem> existingPlotLines,
IReadOnlyList<PlotThread> existingThreads)
{ {
var sceneNumber = boundary.SceneNumber ?? 0; var sceneNumber = boundary.SceneNumber ?? 0;
var start = boundary.StartParagraph ?? 0; var start = boundary.StartParagraph ?? 0;
@ -1073,7 +1086,8 @@ public sealed class PersistedStoryIntelligenceRunner(
boundary.StructuralSummary, boundary.StructuralSummary,
sourceLabel, sourceLabel,
configuredMetrics, configuredMetrics,
existingPlotLines), existingPlotLines,
existingThreads),
SceneTitleDisplay.Format(sceneNumber, null), SceneTitleDisplay.Format(sceneNumber, null),
boundary.StructuralSummary, boundary.StructuralSummary,
true); true);
@ -1097,7 +1111,8 @@ public sealed class PersistedStoryIntelligenceRunner(
string? structuralSummary, string? structuralSummary,
string sourceLabel, string sourceLabel,
IReadOnlyList<SceneMetricType> configuredMetrics, IReadOnlyList<SceneMetricType> configuredMetrics,
IReadOnlyList<PlotLineItem> existingPlotLines) IReadOnlyList<PlotLineItem> existingPlotLines,
IReadOnlyList<PlotThread> existingThreads)
=> JsonSerializer.Serialize(new => JsonSerializer.Serialize(new
{ {
projectId = run.ProjectID, projectId = run.ProjectID,
@ -1126,6 +1141,16 @@ public sealed class PersistedStoryIntelligenceRunner(
description = plotLine.Description, description = plotLine.Description,
status = plotLine.PlotImportanceName, status = plotLine.PlotImportanceName,
bookId = plotLine.BookID bookId = plotLine.BookID
}),
existingThreads = existingThreads.Select(thread => new
{
plotThreadId = thread.PlotThreadID,
plotLineId = thread.PlotLineID,
plotLineName = thread.PlotLineName,
title = thread.ThreadTitle,
summary = thread.Summary,
type = thread.ThreadTypeName,
status = thread.ThreadStatusName
}) })
}, JsonOptions); }, JsonOptions);

View File

@ -1,4 +1,6 @@
using System.Text.Json; using System.Text.Json;
using System.Data;
using Dapper;
using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.Rendering;
using PlotLine.Data; using PlotLine.Data;
using PlotLine.Models; using PlotLine.Models;
@ -18,6 +20,7 @@ public sealed class StoryIntelligenceNarrativeReviewService(
IStoryIntelligenceResultRepository runs, IStoryIntelligenceResultRepository runs,
ISceneRepository scenes, ISceneRepository scenes,
IPlotRepository plots, IPlotRepository plots,
ISqlConnectionFactory connectionFactory,
IStoryIntelligencePipelineStateService pipelineState) : IStoryIntelligenceNarrativeReviewService IStoryIntelligencePipelineStateService pipelineState) : IStoryIntelligenceNarrativeReviewService
{ {
private static readonly JsonSerializerOptions JsonOptions = new() private static readonly JsonSerializerOptions JsonOptions = new()
@ -45,7 +48,20 @@ public sealed class StoryIntelligenceNarrativeReviewService(
.ThenBy(plotLine => plotLine.PlotLineName) .ThenBy(plotLine => plotLine.PlotLineName)
.Select(plotLine => new SelectListItem(plotLine.PlotLineName, plotLine.PlotLineID.ToString())) .Select(plotLine => new SelectListItem(plotLine.PlotLineName, plotLine.PlotLineID.ToString()))
.ToList(), .ToList(),
Candidates = pending.Select(ToViewModel).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()
}; };
} }
@ -78,7 +94,19 @@ public sealed class StoryIntelligenceNarrativeReviewService(
var threadsCreated = 0; var threadsCreated = 0;
var rejected = 0; var rejected = 0;
foreach (var choice in form.Candidates.Where(choice => choice.NarrativeCandidateID > 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) if (!rows.TryGetValue(choice.NarrativeCandidateID, out var candidate)
|| !string.Equals(candidate.Status, StoryIntelligenceNarrativeCandidateStatuses.Pending, StringComparison.OrdinalIgnoreCase)) || !string.Equals(candidate.Status, StoryIntelligenceNarrativeCandidateStatuses.Pending, StringComparison.OrdinalIgnoreCase))
@ -86,50 +114,110 @@ public sealed class StoryIntelligenceNarrativeReviewService(
continue; continue;
} }
if (!string.Equals(candidate.CandidateType, StoryIntelligenceNarrativeCandidateTypes.PlotLine, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var action = string.IsNullOrWhiteSpace(choice.Action) ? StoryIntelligenceNarrativeReviewActions.AcceptNew : choice.Action; var action = string.IsNullOrWhiteSpace(choice.Action) ? StoryIntelligenceNarrativeReviewActions.AcceptNew : choice.Action;
if (string.Equals(action, StoryIntelligenceNarrativeReviewActions.Reject, StringComparison.OrdinalIgnoreCase)) if (string.Equals(action, StoryIntelligenceNarrativeReviewActions.Reject, StringComparison.OrdinalIgnoreCase))
{ {
await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Rejected, action, userId, null, null); await MarkReviewedAsync(connection, transaction, candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Rejected, action, userId, null, null);
rejected++; rejected++;
continue; continue;
} }
if (string.Equals(candidate.CandidateType, StoryIntelligenceNarrativeCandidateTypes.PlotLine, StringComparison.OrdinalIgnoreCase)) 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))
{ {
if (string.Equals(action, StoryIntelligenceNarrativeReviewActions.MergeExisting, StringComparison.OrdinalIgnoreCase) && choice.ExistingPlotLineID is > 0) await MarkReviewedAsync(connection, transaction, candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Merged, action, userId, null, null, target.NarrativeCandidateID);
if (string.Equals(candidate.CandidateType, StoryIntelligenceNarrativeCandidateTypes.PlotLine, StringComparison.OrdinalIgnoreCase))
{ {
await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Merged, action, userId, choice.ExistingPlotLineID, null);
plotLinesMerged++; plotLinesMerged++;
continue; }
else
{
rejected++;
} }
var plotLineId = await plots.SavePlotLineAsync(new PlotLineItem continue;
{ }
ProjectID = batch.ProjectID,
BookID = batch.BookID, if (string.Equals(action, StoryIntelligenceNarrativeReviewActions.MergeExisting, StringComparison.OrdinalIgnoreCase) && choice.ExistingPlotLineID is > 0)
PlotLineName = CleanTitle(choice.Title, candidate.ProposedTitle), {
Description = CleanNullable(choice.Description) ?? candidate.ProposedDescription, await MarkReviewedAsync(connection, transaction, candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Merged, action, userId, choice.ExistingPlotLineID, null);
PlotLineTypeID = plotLineTypeId, mergedPlotLineTargets[candidate.NarrativeCandidateID] = choice.ExistingPlotLineID.Value;
PlotImportanceID = plotImportanceId, plotLinesMerged++;
SortOrder = 0, continue;
Colour = "#2f6f63", }
IsVisibleOnTimeline = true
}); var plotLineId = await SavePlotLineAsync(connection, transaction, new PlotLineItem
await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Accepted, action, userId, plotLineId, null); {
plotLinesCreated++; 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; continue;
} }
var relatedPlotLineId = choice.RelatedPlotLineID ?? candidate.RelatedPlotLineID ?? choice.ExistingPlotLineID; 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) if (relatedPlotLineId is not > 0)
{ {
await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Rejected, StoryIntelligenceNarrativeReviewActions.Reject, userId, null, null); await MarkReviewedAsync(connection, transaction, candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Rejected, StoryIntelligenceNarrativeReviewActions.Reject, userId, null, null);
rejected++; rejected++;
continue; continue;
} }
var firstSceneId = candidate.Evidence.OrderBy(evidence => evidence.SortOrder).FirstOrDefault()?.SceneID; var firstSceneId = candidate.Evidence.OrderBy(evidence => evidence.SortOrder).FirstOrDefault()?.SceneID;
var threadId = await plots.SavePlotThreadAsync(new PlotThread var threadId = await SavePlotThreadAsync(connection, transaction, new PlotThread
{ {
PlotLineID = relatedPlotLineId.Value, PlotLineID = relatedPlotLineId.Value,
ThreadTitle = CleanTitle(choice.Title, candidate.ProposedTitle), ThreadTitle = CleanTitle(choice.Title, candidate.ProposedTitle),
@ -140,9 +228,9 @@ public sealed class StoryIntelligenceNarrativeReviewService(
IntroducedSceneID = firstSceneId IntroducedSceneID = firstSceneId
}); });
foreach (var evidence in candidate.Evidence.Where(evidence => evidence.SceneID > 0)) foreach (var evidence in EffectiveEvidence(candidate, rows, choices).Where(evidence => evidence.SceneID > 0))
{ {
await plots.SaveThreadEventAsync(new ThreadEvent await SaveThreadEventAsync(connection, transaction, new ThreadEvent
{ {
PlotThreadID = threadId, PlotThreadID = threadId,
SceneID = evidence.SceneID, SceneID = evidence.SceneID,
@ -153,10 +241,18 @@ public sealed class StoryIntelligenceNarrativeReviewService(
}); });
} }
await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Accepted, action, userId, null, threadId); await MarkReviewedAsync(connection, transaction, candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Accepted, action, userId, null, threadId);
threadsCreated++; threadsCreated++;
} }
transaction.Commit();
}
catch
{
transaction.Rollback();
throw;
}
batch.LastNarrativeImportResult = new StoryIntelligenceNarrativeImportBatchResult batch.LastNarrativeImportResult = new StoryIntelligenceNarrativeImportBatchResult
{ {
PlotLinesCreated = plotLinesCreated, PlotLinesCreated = plotLinesCreated,
@ -233,9 +329,14 @@ public sealed class StoryIntelligenceNarrativeReviewService(
CleanNullable(arc.Title), CleanNullable(arc.Title),
CleanNullable(arc.Description), CleanNullable(arc.Description),
category, 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", CleanNullable(arc.ArcType) ?? "Narrative arc",
arc.Confidence, arc.Confidence,
arc.MatchingExistingPlotLineId, arc.MatchingExistingPlotLineId,
arc.MatchingExistingThreadId,
arc.Evidence)); arc.Evidence));
} }
@ -247,8 +348,13 @@ public sealed class StoryIntelligenceNarrativeReviewService(
null, null,
"Thread", "Thread",
"Open", "Open",
IsPlotScope(question.Scope) ? "minor" : "minor",
null,
null,
CleanNullable(question.Scope) ?? "question",
question.Confidence, question.Confidence,
null, null,
null,
question.Evidence)); question.Evidence));
} }
@ -260,38 +366,55 @@ public sealed class StoryIntelligenceNarrativeReviewService(
null, null,
"Thread", "Thread",
"Introduced", "Introduced",
"minor",
null,
null,
"clue",
clue.Confidence, clue.Confidence,
null, null,
null,
clue.Evidence)); clue.Evidence));
} }
} }
} }
var signals = StoryIntelligenceNarrativeSignalConsolidator.Consolidate(signalInputs, existingPlotLines); var signals = StoryIntelligenceNarrativeSignalConsolidator.Consolidate(signalInputs, existingPlotLines, existingThreads);
foreach (var signal in signals.Where(signal => signal.SceneIds.Count > 1).OrderBy(signal => signal.Title)) 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 = existingThreadByKey.GetValueOrDefault(Key(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) var candidateType = string.Equals(signal.Category, "Plot Line", StringComparison.OrdinalIgnoreCase) || (signal.ExistingPlotLineID.HasValue && existingThread is null)
? StoryIntelligenceNarrativeCandidateTypes.PlotLine ? StoryIntelligenceNarrativeCandidateTypes.PlotLine
: StoryIntelligenceNarrativeCandidateTypes.Thread; : 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 var candidateId = await candidates.SaveAsync(new StoryIntelligenceNarrativeCandidateSeed
{ {
ProjectID = batch.ProjectID, ProjectID = batch.ProjectID,
BookID = batch.BookID, BookID = batch.BookID,
CandidateType = candidateType, CandidateType = candidateType,
CandidateKey = $"{candidateType}:{Key(signal.Title)}", CandidateKey = candidateKey,
ProposedTitle = signal.Title, ProposedTitle = signal.Title,
ProposedDescription = signal.Description, ProposedDescription = signal.Description,
Category = signal.Category, Category = signal.Category,
InferredState = signal.State, InferredState = signal.State,
ChangeType = signal.ChangeType,
Significance = signal.Significance,
ExistingPlotLineID = signal.ExistingPlotLineID, ExistingPlotLineID = signal.ExistingPlotLineID,
ExistingPlotThreadID = existingThread?.PlotThreadID, ExistingPlotThreadID = existingThread?.PlotThreadID,
RelatedPlotLineID = existingThread?.PlotLineID, RelatedPlotLineID = existingThread?.PlotLineID ?? signal.RelatedPlotLineID,
ParentCandidateKey = signal.ParentCandidateKey is null ? null : $"PlotLine:{signal.ParentCandidateKey}",
ParentNarrativeCandidateID = parentCandidateId,
Confidence = signal.Confidence Confidence = signal.Confidence
}); });
savedByKey[candidateKey] = candidateId;
var sortOrder = 0; var sortOrder = 0;
foreach (var item in signal.Evidence.OrderBy(item => item.SceneID)) foreach (var item in signal.Evidence)
{ {
await candidates.SaveEvidenceAsync(candidateId, item.SceneID, item.ContributionType, item.Evidence, sortOrder++); await candidates.SaveEvidenceAsync(candidateId, item.SceneID, item.ContributionType, item.Evidence, sortOrder++);
} }
@ -312,7 +435,102 @@ public sealed class StoryIntelligenceNarrativeReviewService(
return false; return false;
} }
private static StoryIntelligenceNarrativeReviewCandidateViewModel ToViewModel(StoryIntelligenceNarrativeCandidate candidate) 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
},
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() => new()
{ {
NarrativeCandidateID = candidate.NarrativeCandidateID, NarrativeCandidateID = candidate.NarrativeCandidateID,
@ -321,10 +539,15 @@ public sealed class StoryIntelligenceNarrativeReviewService(
ProposedDescription = candidate.ProposedDescription ?? string.Empty, ProposedDescription = candidate.ProposedDescription ?? string.Empty,
Category = candidate.Category ?? string.Empty, Category = candidate.Category ?? string.Empty,
InferredState = candidate.InferredState ?? 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", Confidence = candidate.Confidence.HasValue ? $"{candidate.Confidence.Value:P0}" : "Unknown",
ExistingPlotLineID = candidate.ExistingPlotLineID, ExistingPlotLineID = candidate.ExistingPlotLineID,
RelatedPlotLineID = candidate.RelatedPlotLineID, RelatedPlotLineID = candidate.RelatedPlotLineID,
RelatedPlotLineName = candidate.RelatedPlotLineName, RelatedPlotLineName = candidate.RelatedPlotLineName,
ParentNarrativeCandidateID = candidate.ParentNarrativeCandidateID,
ParentCandidateTitle = candidate.ParentCandidateTitle,
ChildThreads = childThreads ?? [],
DefaultAction = candidate.ExistingPlotLineID.HasValue || candidate.ExistingPlotThreadID.HasValue DefaultAction = candidate.ExistingPlotLineID.HasValue || candidate.ExistingPlotThreadID.HasValue
? StoryIntelligenceNarrativeReviewActions.MergeExisting ? StoryIntelligenceNarrativeReviewActions.MergeExisting
: StoryIntelligenceNarrativeReviewActions.AcceptNew, : StoryIntelligenceNarrativeReviewActions.AcceptNew,
@ -396,6 +619,71 @@ public sealed class StoryIntelligenceNarrativeReviewService(
private static string? CleanNullable(string? value) private static string? CleanNullable(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim(); => 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 string Key(string? value) private static string Key(string? value)
=> StoryIntelligenceNarrativeSignalConsolidator.Key(value); => StoryIntelligenceNarrativeSignalConsolidator.Key(value);
} }
@ -406,21 +694,52 @@ public sealed record StoryIntelligenceNarrativeSignalInput(
string? Description, string? Description,
string Category, string Category,
string State, string State,
string Significance,
string? ContinuityKey,
string? ParentTitle,
string ArcType,
decimal? Confidence, decimal? Confidence,
int? MatchingExistingPlotLineID, int? MatchingExistingPlotLineID,
int? MatchingExistingThreadID,
string? Evidence); string? Evidence);
public sealed record StoryIntelligenceNarrativeSignalEvidence(int SceneID, string ContributionType, string? Evidence); public sealed record StoryIntelligenceNarrativeSignalEvidence(int SceneID, string ContributionType, string? Evidence);
public sealed record StoryIntelligenceNarrativeConsolidatedSignal( public sealed record StoryIntelligenceNarrativeConsolidatedSignal(
string Key,
string Title, string Title,
string Description, string Description,
string Category, string Category,
string State, string State,
string ChangeType,
string Significance,
string? ParentCandidateKey,
int? ExistingPlotLineID, int? ExistingPlotLineID,
int? ExistingPlotThreadID,
int? RelatedPlotLineID,
decimal? Confidence, decimal? Confidence,
IReadOnlyCollection<int> SceneIds, IReadOnlyCollection<int> SceneIds,
IReadOnlyList<StoryIntelligenceNarrativeSignalEvidence> Evidence); 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 public static class StoryIntelligenceNarrativeSignalConsolidator
{ {
@ -469,10 +788,14 @@ public static class StoryIntelligenceNarrativeSignalConsolidator
public static IReadOnlyList<StoryIntelligenceNarrativeConsolidatedSignal> Consolidate( public static IReadOnlyList<StoryIntelligenceNarrativeConsolidatedSignal> Consolidate(
IEnumerable<StoryIntelligenceNarrativeSignalInput> inputs, IEnumerable<StoryIntelligenceNarrativeSignalInput> inputs,
IReadOnlyList<PlotLineItem> existingPlotLines) IReadOnlyList<PlotLineItem> existingPlotLines,
IReadOnlyList<PlotThread>? existingThreads = null)
{ {
var groups = new Dictionary<string, NarrativeSignal>(StringComparer.OrdinalIgnoreCase); var groups = new Dictionary<string, NarrativeSignal>(StringComparer.OrdinalIgnoreCase);
var plotLinesById = existingPlotLines.Where(plotLine => plotLine.PlotLineID > 0).ToDictionary(plotLine => plotLine.PlotLineID); 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) foreach (var input in inputs)
{ {
@ -482,19 +805,31 @@ public static class StoryIntelligenceNarrativeSignalConsolidator
} }
var matchingPlotLine = ResolveExistingPlotLine(input, existingPlotLines, plotLinesById); 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 var key = matchingPlotLine is not null
? $"existing:{matchingPlotLine.PlotLineID}" ? $"existing:{matchingPlotLine.PlotLineID}"
: FindSimilarGroupKey(groups, input) ?? ArcKey(input.Title, input.Description); : 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)) if (!groups.TryGetValue(key, out var signal))
{ {
var terms = Terms($"{input.Title} {input.Description}"); var terms = Terms($"{input.Title} {input.Description} {input.ContinuityKey} {input.ArcType}");
var category = ClassifyCategory(input, matchingPlotLine, matchingThread);
signal = new NarrativeSignal( signal = new NarrativeSignal(
KeyFromInput(input),
input.Title.Trim(), input.Title.Trim(),
string.IsNullOrWhiteSpace(input.Description) ? $"{input.Category} recurring across scenes." : input.Description.Trim(), string.IsNullOrWhiteSpace(input.Description) ? $"{category} recurring across scenes." : input.Description.Trim(),
matchingPlotLine is not null ? "Plot Line" : input.Category, category,
input.State, NormalizeChangeType(input.State),
NormalizeChangeType(input.State),
NormalizeSignificance(input.Significance),
ParentCandidateKey(input.ParentTitle),
matchingPlotLine?.PlotLineID, matchingPlotLine?.PlotLineID,
matchingThread?.PlotThreadID,
matchingThread?.PlotLineID,
ArcFamily(terms), ArcFamily(terms),
terms.ToHashSet(StringComparer.OrdinalIgnoreCase)); terms.ToHashSet(StringComparer.OrdinalIgnoreCase));
groups[key] = signal; groups[key] = signal;
@ -504,19 +839,33 @@ public static class StoryIntelligenceNarrativeSignalConsolidator
signal.Confidence = input.Confidence.HasValue signal.Confidence = input.Confidence.HasValue
? Math.Max(signal.Confidence ?? 0m, input.Confidence.Value) ? Math.Max(signal.Confidence ?? 0m, input.Confidence.Value)
: signal.Confidence; : signal.Confidence;
signal.Evidence.Add(new StoryIntelligenceNarrativeSignalEvidence(input.SceneID, input.Category, input.Evidence)); 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 return groups.Values
.Select(signal => new StoryIntelligenceNarrativeConsolidatedSignal( .Select(signal => new StoryIntelligenceNarrativeConsolidatedSignal(
signal.Key,
signal.Title, signal.Title,
signal.Description, signal.Description,
signal.Category, signal.Category,
signal.State, signal.State,
signal.ChangeType,
signal.Significance,
signal.ParentCandidateKey,
signal.ExistingPlotLineID, signal.ExistingPlotLineID,
signal.ExistingPlotThreadID,
signal.RelatedPlotLineID,
signal.Confidence, signal.Confidence,
signal.SceneIds, signal.SceneIds,
signal.Evidence)) signal.Evidence.OrderBy(evidence => evidence.SceneID).ToList()))
.ToList(); .ToList();
} }
@ -538,7 +887,7 @@ public static class StoryIntelligenceNarrativeSignalConsolidator
private static bool HasSharedArcIdentity(StoryIntelligenceNarrativeSignalInput input, PlotLineItem plotLine) private static bool HasSharedArcIdentity(StoryIntelligenceNarrativeSignalInput input, PlotLineItem plotLine)
{ {
var signalTerms = Terms($"{input.Title} {input.Description}"); var signalTerms = Terms($"{input.Title} {input.Description} {input.ContinuityKey} {input.ArcType}");
var plotLineTerms = Terms($"{plotLine.PlotLineName} {plotLine.Description}"); var plotLineTerms = Terms($"{plotLine.PlotLineName} {plotLine.Description}");
if (signalTerms.Count == 0 || plotLineTerms.Count == 0) if (signalTerms.Count == 0 || plotLineTerms.Count == 0)
{ {
@ -558,15 +907,31 @@ public static class StoryIntelligenceNarrativeSignalConsolidator
return true; return true;
} }
return ArcKey(input.Title, input.Description) == ArcKey(plotLine.PlotLineName, plotLine.Description); return ArcKey(input.Title, input.Description, input.ContinuityKey, input.ArcType) == ArcKey(plotLine.PlotLineName, plotLine.Description, null, null);
} }
private static string? FindSimilarGroupKey( 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, IReadOnlyDictionary<string, NarrativeSignal> groups,
StoryIntelligenceNarrativeSignalInput input) StoryIntelligenceNarrativeSignalInput input)
{ {
var terms = Terms($"{input.Title} {input.Description}"); var terms = Terms($"{input.Title} {input.Description} {input.ContinuityKey} {input.ArcType}");
var family = ArcFamily(terms); var family = ArcFamily(terms);
var continuityKey = Key(input.ContinuityKey);
var parentKey = ParentCandidateKey(input.ParentTitle);
if (terms.Count == 0) if (terms.Count == 0)
{ {
return null; return null;
@ -574,12 +939,35 @@ public static class StoryIntelligenceNarrativeSignalConsolidator
foreach (var group in groups) foreach (var group in groups)
{ {
if (group.Value.ExistingPlotLineID.HasValue || !string.Equals(group.Value.Family, family, StringComparison.OrdinalIgnoreCase)) if (group.Value.ExistingPlotLineID.HasValue || group.Value.ExistingPlotThreadID.HasValue)
{ {
continue; continue;
} }
if (group.Value.Terms.Intersect(terms, StringComparer.OrdinalIgnoreCase).Any()) 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 group.Key;
} }
@ -588,9 +976,15 @@ public static class StoryIntelligenceNarrativeSignalConsolidator
return null; return null;
} }
private static string ArcKey(string? title, string? description) private static string ArcKey(string? title, string? description, string? continuityKey, string? arcType)
{ {
var terms = Terms($"{title} {description}"); var explicitKey = Key(continuityKey);
if (explicitKey.Length > 0)
{
return $"continuity:{explicitKey}";
}
var terms = Terms($"{title} {description} {arcType}");
var family = ArcFamily(terms); var family = ArcFamily(terms);
var anchors = terms var anchors = terms
.Where(term => !ArcFamilies.ContainsKey(term)) .Where(term => !ArcFamilies.ContainsKey(term))
@ -610,6 +1004,88 @@ public static class StoryIntelligenceNarrativeSignalConsolidator
private static string ArcFamily(IEnumerable<string> terms) private static string ArcFamily(IEnumerable<string> terms)
=> terms.Select(term => ArcFamilies.GetValueOrDefault(term)).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? "general"; => 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) private static List<string> Terms(string? value)
=> (value ?? string.Empty) => (value ?? string.Empty)
.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
@ -623,15 +1099,34 @@ public static class StoryIntelligenceNarrativeSignalConsolidator
return term.EndsWith('s') && term.Length > 4 ? term[..^1] : term; return term.EndsWith('s') && term.Length > 4 ? term[..^1] : term;
} }
private sealed record NarrativeSignal( private sealed class NarrativeSignal(
string Title, string key,
string Description, string title,
string Category, string description,
string State, string category,
int? ExistingPlotLineID, string state,
string Family, string changeType,
HashSet<string> Terms) 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 HashSet<int> SceneIds { get; } = [];
public List<StoryIntelligenceNarrativeSignalEvidence> Evidence { get; } = []; public List<StoryIntelligenceNarrativeSignalEvidence> Evidence { get; } = [];
public decimal? Confidence { get; set; } public decimal? Confidence { get; set; }

View File

@ -13,7 +13,8 @@ public interface IStoryIntelligenceSceneContextService
Scene scene, Scene scene,
SceneManuscriptSourceRecord source, SceneManuscriptSourceRecord source,
IReadOnlyList<SceneMetricType> configuredMetrics, IReadOnlyList<SceneMetricType> configuredMetrics,
IReadOnlyList<PlotLineItem> existingPlotLines); IReadOnlyList<PlotLineItem> existingPlotLines,
IReadOnlyList<PlotThread> existingThreads);
} }
public sealed class StoryIntelligenceSceneContextService( public sealed class StoryIntelligenceSceneContextService(
@ -35,7 +36,8 @@ public sealed class StoryIntelligenceSceneContextService(
Scene scene, Scene scene,
SceneManuscriptSourceRecord source, SceneManuscriptSourceRecord source,
IReadOnlyList<SceneMetricType> configuredMetrics, IReadOnlyList<SceneMetricType> configuredMetrics,
IReadOnlyList<PlotLineItem> existingPlotLines) IReadOnlyList<PlotLineItem> existingPlotLines,
IReadOnlyList<PlotThread> existingThreads)
{ {
var book = run.BookID.HasValue ? await books.GetAsync(run.BookID.Value) : null; var book = run.BookID.HasValue ? await books.GetAsync(run.BookID.Value) : null;
var chapter = run.ChapterID.HasValue ? await chapters.GetAsync(run.ChapterID.Value) : null; var chapter = run.ChapterID.HasValue ? await chapters.GetAsync(run.ChapterID.Value) : null;
@ -92,6 +94,14 @@ public sealed class StoryIntelligenceSceneContextService(
plotLine.Description, plotLine.Description,
plotLine.PlotImportanceName, plotLine.PlotImportanceName,
plotLine.BookID)).ToList(), plotLine.BookID)).ToList(),
existingThreads.Select(thread => new StoryIntelligencePlotThreadContext(
thread.PlotThreadID,
thread.PlotLineID,
thread.PlotLineName,
thread.ThreadTitle,
thread.Summary,
thread.ThreadTypeName,
thread.ThreadStatusName)).ToList(),
run.SourceLabel ?? string.Empty); run.SourceLabel ?? string.Empty);
} }
@ -277,6 +287,16 @@ public sealed class StoryIntelligenceSceneContextService(
plotLine.Description, plotLine.Description,
plotLine.Status, plotLine.Status,
bookId = plotLine.BookID bookId = plotLine.BookID
}),
existingThreads = context.ExistingThreads.Select(thread => new
{
plotThreadId = thread.PlotThreadID,
plotLineId = thread.PlotLineID,
plotLineName = thread.PlotLineName,
title = thread.Title,
thread.Summary,
thread.Type,
thread.Status
}) })
}, JsonOptions); }, JsonOptions);
} }
@ -289,6 +309,7 @@ public sealed record StoryIntelligenceCanonicalSceneContext(
IReadOnlyList<StoryIntelligenceSceneCharacterContext> SceneCharacters, IReadOnlyList<StoryIntelligenceSceneCharacterContext> SceneCharacters,
IReadOnlyList<StoryIntelligenceMetricContext> ConfiguredMetrics, IReadOnlyList<StoryIntelligenceMetricContext> ConfiguredMetrics,
IReadOnlyList<StoryIntelligencePlotLineContext> ExistingPlotLines, IReadOnlyList<StoryIntelligencePlotLineContext> ExistingPlotLines,
IReadOnlyList<StoryIntelligencePlotThreadContext> ExistingThreads,
string SourceLabel); string SourceLabel);
public sealed record StoryIntelligenceBookContext(int? ProjectID, int? BookID, string? Title, string? StoryEra, string? StoryEraLabel, string? PrimaryLocaleCode, string? PrimaryLocaleLabel); public sealed record StoryIntelligenceBookContext(int? ProjectID, int? BookID, string? Title, string? StoryEra, string? StoryEraLabel, string? PrimaryLocaleCode, string? PrimaryLocaleLabel);
@ -317,3 +338,4 @@ public sealed record StoryIntelligenceNeighbourSceneContext(string Position, int
public sealed record StoryIntelligenceSceneCharacterContext(int CharacterID, string CanonicalName, IReadOnlyList<string> Aliases, bool IsPointOfView, bool IsSceneMapped, string? RoleInScene, string? PresenceType, string? MatchSource, string? MatchedText, int? OccurrenceCount, int? FirstOccurrenceIndex); public sealed record StoryIntelligenceSceneCharacterContext(int CharacterID, string CanonicalName, IReadOnlyList<string> Aliases, bool IsPointOfView, bool IsSceneMapped, string? RoleInScene, string? PresenceType, string? MatchSource, string? MatchedText, int? OccurrenceCount, int? FirstOccurrenceIndex);
public sealed record StoryIntelligenceMetricContext(string Key, int MetricTypeID, string Name, string? Description, int MinValue, int MaxValue, int DefaultValue); public sealed record StoryIntelligenceMetricContext(string Key, int MetricTypeID, string Name, string? Description, int MinValue, int MaxValue, int DefaultValue);
public sealed record StoryIntelligencePlotLineContext(int PlotLineID, string Name, string? Description, string? Status, int? BookID); public sealed record StoryIntelligencePlotLineContext(int PlotLineID, string Name, string? Description, string? Status, int? BookID);
public sealed record StoryIntelligencePlotThreadContext(int PlotThreadID, int PlotLineID, string PlotLineName, string Title, string? Summary, string? Type, string? Status);

View File

@ -356,7 +356,12 @@ public sealed class StorySceneValidator : IStorySceneValidator
RequiredString(item.Description, $"{path}.description", result); RequiredString(item.Description, $"{path}.description", result);
RequiredString(item.ArcType, $"{path}.arcType", result); RequiredString(item.ArcType, $"{path}.arcType", result);
RequiredString(item.Scale, $"{path}.scale", result); RequiredString(item.Scale, $"{path}.scale", result);
RequiredString(item.ChangeType, $"{path}.changeType", result);
RequiredString(item.Significance, $"{path}.significance", result);
RequiredString(item.ContinuityKey, $"{path}.continuityKey", result);
RequiredScalar(item.ParentArcTitle, $"{path}.parentArcTitle", result, allowNull: true);
RequiredScalar(item.MatchingExistingPlotLineId, $"{path}.matchingExistingPlotLineId", result, allowNull: true); RequiredScalar(item.MatchingExistingPlotLineId, $"{path}.matchingExistingPlotLineId", result, allowNull: true);
RequiredScalar(item.MatchingExistingThreadId, $"{path}.matchingExistingThreadId", result, allowNull: true);
RequiredString(item.Evidence, $"{path}.evidence", result); RequiredString(item.Evidence, $"{path}.evidence", result);
Confidence(item.Confidence, $"{path}.confidence", result); Confidence(item.Confidence, $"{path}.confidence", result);
Unknown(item.ExtensionData, path, result); Unknown(item.ExtensionData, path, result);

View File

@ -0,0 +1,187 @@
IF COL_LENGTH(N'dbo.StoryIntelligenceNarrativeCandidates', N'ChangeType') IS NULL
BEGIN
ALTER TABLE dbo.StoryIntelligenceNarrativeCandidates ADD ChangeType nvarchar(40) NULL;
END;
GO
IF COL_LENGTH(N'dbo.StoryIntelligenceNarrativeCandidates', N'Significance') IS NULL
BEGIN
ALTER TABLE dbo.StoryIntelligenceNarrativeCandidates ADD Significance nvarchar(20) NULL;
END;
GO
IF COL_LENGTH(N'dbo.StoryIntelligenceNarrativeCandidates', N'ParentCandidateKey') IS NULL
BEGIN
ALTER TABLE dbo.StoryIntelligenceNarrativeCandidates ADD ParentCandidateKey nvarchar(220) NULL;
END;
GO
IF COL_LENGTH(N'dbo.StoryIntelligenceNarrativeCandidates', N'ParentNarrativeCandidateID') IS NULL
BEGIN
ALTER TABLE dbo.StoryIntelligenceNarrativeCandidates ADD ParentNarrativeCandidateID int NULL;
END;
GO
IF COL_LENGTH(N'dbo.StoryIntelligenceNarrativeCandidates', N'MergeTargetNarrativeCandidateID') IS NULL
BEGIN
ALTER TABLE dbo.StoryIntelligenceNarrativeCandidates ADD MergeTargetNarrativeCandidateID int NULL;
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_StoryIntelligenceNarrativeCandidates_ParentCandidate')
BEGIN
ALTER TABLE dbo.StoryIntelligenceNarrativeCandidates
ADD CONSTRAINT FK_StoryIntelligenceNarrativeCandidates_ParentCandidate
FOREIGN KEY (ParentNarrativeCandidateID) REFERENCES dbo.StoryIntelligenceNarrativeCandidates(NarrativeCandidateID);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_StoryIntelligenceNarrativeCandidates_MergeTarget')
BEGIN
ALTER TABLE dbo.StoryIntelligenceNarrativeCandidates
ADD CONSTRAINT FK_StoryIntelligenceNarrativeCandidates_MergeTarget
FOREIGN KEY (MergeTargetNarrativeCandidateID) REFERENCES dbo.StoryIntelligenceNarrativeCandidates(NarrativeCandidateID);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_StoryIntelligenceNarrativeCandidates_Parent' AND object_id = OBJECT_ID(N'dbo.StoryIntelligenceNarrativeCandidates'))
CREATE INDEX IX_StoryIntelligenceNarrativeCandidates_Parent ON dbo.StoryIntelligenceNarrativeCandidates(BookID, ParentNarrativeCandidateID, CandidateType);
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceNarrativeCandidate_Save
@ProjectID int,
@BookID int,
@CandidateType nvarchar(20),
@CandidateKey nvarchar(220),
@ProposedTitle nvarchar(200),
@ProposedDescription nvarchar(1000) = NULL,
@Category nvarchar(80) = NULL,
@InferredState nvarchar(40) = NULL,
@ChangeType nvarchar(40) = NULL,
@Significance nvarchar(20) = NULL,
@ExistingPlotLineID int = NULL,
@ExistingPlotThreadID int = NULL,
@RelatedPlotLineID int = NULL,
@ParentCandidateKey nvarchar(220) = NULL,
@ParentNarrativeCandidateID int = NULL,
@Confidence decimal(5,4) = NULL
AS
BEGIN
SET NOCOUNT ON;
DECLARE @NarrativeCandidateID int;
SELECT @NarrativeCandidateID = NarrativeCandidateID
FROM dbo.StoryIntelligenceNarrativeCandidates
WHERE BookID = @BookID AND CandidateType = @CandidateType AND CandidateKey = @CandidateKey;
IF @ParentNarrativeCandidateID IS NULL AND @ParentCandidateKey IS NOT NULL
BEGIN
SELECT @ParentNarrativeCandidateID = NarrativeCandidateID
FROM dbo.StoryIntelligenceNarrativeCandidates
WHERE BookID = @BookID
AND CandidateType = N'PlotLine'
AND CandidateKey = @ParentCandidateKey;
END;
IF @NarrativeCandidateID IS NULL
BEGIN
INSERT dbo.StoryIntelligenceNarrativeCandidates
(ProjectID, BookID, CandidateType, CandidateKey, ProposedTitle, ProposedDescription, Category, InferredState,
ChangeType, Significance, ExistingPlotLineID, ExistingPlotThreadID, RelatedPlotLineID, ParentCandidateKey,
ParentNarrativeCandidateID, Confidence)
VALUES
(@ProjectID, @BookID, @CandidateType, @CandidateKey, @ProposedTitle, @ProposedDescription, @Category, @InferredState,
@ChangeType, @Significance, @ExistingPlotLineID, @ExistingPlotThreadID, @RelatedPlotLineID, @ParentCandidateKey,
@ParentNarrativeCandidateID, @Confidence);
SET @NarrativeCandidateID = CAST(SCOPE_IDENTITY() AS int);
END
ELSE
BEGIN
UPDATE dbo.StoryIntelligenceNarrativeCandidates
SET ProposedTitle = @ProposedTitle,
ProposedDescription = @ProposedDescription,
Category = @Category,
InferredState = @InferredState,
ChangeType = @ChangeType,
Significance = @Significance,
ExistingPlotLineID = @ExistingPlotLineID,
ExistingPlotThreadID = @ExistingPlotThreadID,
RelatedPlotLineID = @RelatedPlotLineID,
ParentCandidateKey = @ParentCandidateKey,
ParentNarrativeCandidateID = @ParentNarrativeCandidateID,
Confidence = @Confidence,
UpdatedUtc = SYSUTCDATETIME()
WHERE NarrativeCandidateID = @NarrativeCandidateID
AND Status = N'Pending';
END;
SELECT @NarrativeCandidateID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceNarrativeCandidate_ListByBook
@BookID int
AS
BEGIN
SET NOCOUNT ON;
SELECT nc.NarrativeCandidateID, nc.ProjectID, nc.BookID, nc.CandidateType, nc.CandidateKey,
nc.ProposedTitle, nc.ProposedDescription, nc.Category, nc.InferredState, nc.ChangeType, nc.Significance,
nc.ExistingPlotLineID, epl.PlotLineName AS ExistingPlotLineName,
nc.ExistingPlotThreadID, ept.ThreadTitle AS ExistingPlotThreadTitle,
nc.RelatedPlotLineID, rpl.PlotLineName AS RelatedPlotLineName,
nc.ParentCandidateKey, nc.ParentNarrativeCandidateID, parent.ProposedTitle AS ParentCandidateTitle,
nc.MergeTargetNarrativeCandidateID,
nc.Confidence, nc.Status, nc.ReviewAction, nc.AcceptedPlotLineID, apl.PlotLineName AS AcceptedPlotLineName,
nc.AcceptedPlotThreadID, apt.ThreadTitle AS AcceptedPlotThreadTitle,
nc.ReviewedByUserID, nc.ReviewedUtc, nc.CreatedUtc, nc.UpdatedUtc
FROM dbo.StoryIntelligenceNarrativeCandidates nc
LEFT JOIN dbo.PlotLines epl ON epl.PlotLineID = nc.ExistingPlotLineID
LEFT JOIN dbo.PlotThreads ept ON ept.PlotThreadID = nc.ExistingPlotThreadID
LEFT JOIN dbo.PlotLines rpl ON rpl.PlotLineID = nc.RelatedPlotLineID
LEFT JOIN dbo.StoryIntelligenceNarrativeCandidates parent ON parent.NarrativeCandidateID = nc.ParentNarrativeCandidateID
LEFT JOIN dbo.PlotLines apl ON apl.PlotLineID = nc.AcceptedPlotLineID
LEFT JOIN dbo.PlotThreads apt ON apt.PlotThreadID = nc.AcceptedPlotThreadID
WHERE nc.BookID = @BookID
ORDER BY CASE nc.Status WHEN N'Pending' THEN 0 ELSE 1 END,
CASE nc.CandidateType WHEN N'PlotLine' THEN 0 ELSE 1 END,
COALESCE(parent.ProposedTitle, nc.ProposedTitle), nc.CandidateType, nc.ProposedTitle;
SELECT e.NarrativeCandidateEvidenceID, e.NarrativeCandidateID, e.SceneID,
s.SceneNumber, s.SceneTitle, c.ChapterNumber, c.ChapterTitle,
e.ContributionType, e.Evidence, e.SortOrder, e.CreatedUtc
FROM dbo.StoryIntelligenceNarrativeCandidateEvidence e
INNER JOIN dbo.StoryIntelligenceNarrativeCandidates nc ON nc.NarrativeCandidateID = e.NarrativeCandidateID
INNER JOIN dbo.Scenes s ON s.SceneID = e.SceneID
INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID
WHERE nc.BookID = @BookID
ORDER BY e.NarrativeCandidateID, c.SortOrder, s.SortOrder, e.SortOrder;
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceNarrativeCandidate_MarkReviewed
@NarrativeCandidateID int,
@Status nvarchar(20),
@ReviewAction nvarchar(40),
@ReviewedByUserID int,
@AcceptedPlotLineID int = NULL,
@AcceptedPlotThreadID int = NULL,
@MergeTargetNarrativeCandidateID int = NULL
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.StoryIntelligenceNarrativeCandidates
SET Status = @Status,
ReviewAction = @ReviewAction,
ReviewedByUserID = @ReviewedByUserID,
ReviewedUtc = SYSUTCDATETIME(),
AcceptedPlotLineID = @AcceptedPlotLineID,
AcceptedPlotThreadID = @AcceptedPlotThreadID,
MergeTargetNarrativeCandidateID = @MergeTargetNarrativeCandidateID,
UpdatedUtc = SYSUTCDATETIME()
WHERE NarrativeCandidateID = @NarrativeCandidateID;
END;
GO

View File

@ -212,7 +212,10 @@ public sealed class StoryIntelligenceNarrativeReviewViewModel
public bool HasCommittedScenes { get; init; } public bool HasCommittedScenes { get; init; }
public bool IsComplete { get; init; } public bool IsComplete { get; init; }
public IReadOnlyList<StoryIntelligenceNarrativeReviewCandidateViewModel> Candidates { get; init; } = []; public IReadOnlyList<StoryIntelligenceNarrativeReviewCandidateViewModel> Candidates { get; init; } = [];
public IReadOnlyList<StoryIntelligenceNarrativeReviewCandidateViewModel> PlotLineCandidates { get; init; } = [];
public IReadOnlyList<StoryIntelligenceNarrativeReviewCandidateViewModel> UnassignedThreadCandidates { get; init; } = [];
public IReadOnlyList<SelectListItem> PlotLineOptions { get; init; } = []; public IReadOnlyList<SelectListItem> PlotLineOptions { get; init; } = [];
public IReadOnlyList<SelectListItem> CandidatePlotLineOptions { get; init; } = [];
} }
public sealed class StoryIntelligenceNarrativeReviewCandidateViewModel public sealed class StoryIntelligenceNarrativeReviewCandidateViewModel
@ -223,10 +226,15 @@ public sealed class StoryIntelligenceNarrativeReviewCandidateViewModel
public string ProposedDescription { get; init; } = string.Empty; public string ProposedDescription { get; init; } = string.Empty;
public string Category { get; init; } = string.Empty; public string Category { get; init; } = string.Empty;
public string InferredState { get; init; } = string.Empty; public string InferredState { get; init; } = string.Empty;
public string ChangeType { get; init; } = string.Empty;
public string Significance { get; init; } = string.Empty;
public string Confidence { get; init; } = "Unknown"; public string Confidence { get; init; } = "Unknown";
public int? ExistingPlotLineID { get; init; } public int? ExistingPlotLineID { get; init; }
public int? RelatedPlotLineID { get; init; } public int? RelatedPlotLineID { get; init; }
public string? RelatedPlotLineName { get; init; } public string? RelatedPlotLineName { get; init; }
public int? ParentNarrativeCandidateID { get; init; }
public string? ParentCandidateTitle { get; init; }
public IReadOnlyList<StoryIntelligenceNarrativeReviewCandidateViewModel> ChildThreads { get; init; } = [];
public string DefaultAction { get; init; } = StoryIntelligenceNarrativeReviewActions.AcceptNew; public string DefaultAction { get; init; } = StoryIntelligenceNarrativeReviewActions.AcceptNew;
public IReadOnlyList<StoryIntelligenceNarrativeEvidenceViewModel> Evidence { get; init; } = []; public IReadOnlyList<StoryIntelligenceNarrativeEvidenceViewModel> Evidence { get; init; } = [];
} }
@ -254,6 +262,8 @@ public sealed class StoryIntelligenceNarrativeImportChoiceForm
public string? Description { get; set; } public string? Description { get; set; }
public int? ExistingPlotLineID { get; set; } public int? ExistingPlotLineID { get; set; }
public int? RelatedPlotLineID { get; set; } public int? RelatedPlotLineID { get; set; }
public int? ParentNarrativeCandidateID { get; set; }
public int? MergeTargetNarrativeCandidateID { get; set; }
} }
public sealed class StoryIntelligenceNarrativeImportResultViewModel public sealed class StoryIntelligenceNarrativeImportResultViewModel

View File

@ -66,8 +66,12 @@
</div> </div>
@if (string.Equals(candidate.CandidateType, StoryIntelligenceNarrativeCandidateTypes.Thread, StringComparison.OrdinalIgnoreCase)) @if (string.Equals(candidate.CandidateType, StoryIntelligenceNarrativeCandidateTypes.Thread, StringComparison.OrdinalIgnoreCase))
{ {
@if (!string.IsNullOrWhiteSpace(candidate.ParentCandidateTitle))
{
<p class="text-muted">Proposed parent: @candidate.ParentCandidateTitle</p>
}
<div> <div>
<label>Related Plot Line</label> <label>Existing related Plot Line</label>
<select class="form-select" name="Candidates[@i].RelatedPlotLineID"> <select class="form-select" name="Candidates[@i].RelatedPlotLineID">
<option value="">Choose Plot Line</option> <option value="">Choose Plot Line</option>
@foreach (var option in Model.NarrativeReview.PlotLineOptions) @foreach (var option in Model.NarrativeReview.PlotLineOptions)
@ -76,11 +80,22 @@
} }
</select> </select>
</div> </div>
<div>
<label>Proposed parent Plot Line</label>
<select class="form-select" name="Candidates[@i].ParentNarrativeCandidateID">
<option value="">No proposed parent</option>
@foreach (var option in Model.NarrativeReview.CandidatePlotLineOptions)
{
<option value="@option.Value" selected="@(candidate.ParentNarrativeCandidateID?.ToString() == option.Value)">@option.Text</option>
}
</select>
</div>
} }
<fieldset> <fieldset>
<legend>Decision</legend> <legend>Decision</legend>
<label><input type="radio" name="Candidates[@i].Action" value="@StoryIntelligenceNarrativeReviewActions.AcceptNew" checked="@(candidate.DefaultAction == StoryIntelligenceNarrativeReviewActions.AcceptNew)" /> Accept new</label> <label><input type="radio" name="Candidates[@i].Action" value="@StoryIntelligenceNarrativeReviewActions.AcceptNew" checked="@(candidate.DefaultAction == StoryIntelligenceNarrativeReviewActions.AcceptNew)" /> Accept new</label>
<label><input type="radio" name="Candidates[@i].Action" value="@StoryIntelligenceNarrativeReviewActions.MergeExisting" checked="@(candidate.DefaultAction == StoryIntelligenceNarrativeReviewActions.MergeExisting)" /> Merge with existing</label> <label><input type="radio" name="Candidates[@i].Action" value="@StoryIntelligenceNarrativeReviewActions.MergeExisting" checked="@(candidate.DefaultAction == StoryIntelligenceNarrativeReviewActions.MergeExisting)" /> Merge with existing</label>
<label><input type="radio" name="Candidates[@i].Action" value="@StoryIntelligenceNarrativeReviewActions.MergeCandidate" /> Merge with candidate</label>
<label><input type="radio" name="Candidates[@i].Action" value="@StoryIntelligenceNarrativeReviewActions.Reject" /> Reject</label> <label><input type="radio" name="Candidates[@i].Action" value="@StoryIntelligenceNarrativeReviewActions.Reject" /> Reject</label>
</fieldset> </fieldset>
<div> <div>
@ -93,6 +108,28 @@
} }
</select> </select>
</div> </div>
<div>
<label>Candidate for merge</label>
<select class="form-select" name="Candidates[@i].MergeTargetNarrativeCandidateID">
<option value="">Choose proposed candidate</option>
@foreach (var option in Model.NarrativeReview.Candidates.Where(item => item.NarrativeCandidateID != candidate.NarrativeCandidateID && string.Equals(item.CandidateType, candidate.CandidateType, StringComparison.OrdinalIgnoreCase)).Select(item => new SelectListItem(item.ProposedTitle, item.NarrativeCandidateID.ToString())))
{
<option value="@option.Value">@option.Text</option>
}
</select>
</div>
@if (candidate.ChildThreads.Count > 0)
{
<div class="story-review-note">
<strong>Suggested child Threads</strong>
<ul>
@foreach (var child in candidate.ChildThreads)
{
<li>@child.ProposedTitle</li>
}
</ul>
</div>
}
<div class="story-review-note"> <div class="story-review-note">
<strong>Supporting scenes</strong> <strong>Supporting scenes</strong>
<ul> <ul>