diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 2ea8511..1e8d8bf 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -177,6 +177,15 @@ var tests = new (string Name, Action Test)[] ("Story Intelligence narrative candidates require review", StoryIntelligenceNarrativeCandidatesRequireReview), ("Story Intelligence discovers Plot Lines without preconfiguration", StoryIntelligenceDiscoversPlotLinesWithoutPreconfiguration), ("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 stage follows knowledge", StoryIntelligenceNarrativeStageFollowsKnowledge), ("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(sceneModels.Contains("SceneIntelligenceNarrativeArc", StringComparison.Ordinal), "Scene intelligence should expose explicit narrative arc signals."); Assert(prompt.Contains("narrativeArcs", StringComparison.Ordinal) && prompt.Contains("matchingExistingPlotLineId", StringComparison.Ordinal), "Scene prompt should request narrative arc discovery and existing Plot Line matching."); - Assert(service.Contains("Where(signal => signal.SceneIds.Count > 1)", StringComparison.Ordinal), "Thread discovery should filter one-off incidental details."); + Assert(service.Contains("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("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."); @@ -4042,9 +4051,9 @@ static void StoryIntelligenceDiscoversPlotLinesWithoutPreconfiguration() { var signals = new[] { - new StoryIntelligenceNarrativeSignalInput(101, "Maggie's Disappearance", "Beth notices Maggie is gone and starts looking for her.", "Plot Line", "disappearance", 0.86m, null, "Maggie is missing."), - new StoryIntelligenceNarrativeSignalInput(102, "Search for Maggie", "Beth follows a clue that may explain where Maggie went.", "Plot Line", "investigation", 0.82m, null, "Beth follows a clue."), - new StoryIntelligenceNarrativeSignalInput(103, "Finding Maggie", "The search for Maggie reaches a possible answer.", "Plot Line", "disappearance", 0.8m, null, "Maggie may have been found.") + 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."), + 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."), + 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, []); @@ -4070,9 +4079,9 @@ static void StoryIntelligenceMatchesExistingPlotLinesWithoutDuplicates() }; var signals = new[] { - new StoryIntelligenceNarrativeSignalInput(201, "Search for Maggie", "Beth follows a clue in Maggie's disappearance.", "Plot Line", "investigation", 0.84m, null, "Beth follows a clue."), - new StoryIntelligenceNarrativeSignalInput(202, "Missing Maggie", "Maggie's absence drives the investigation.", "Plot Line", "disappearance", 0.81m, null, "Maggie remains missing."), - new StoryIntelligenceNarrativeSignalInput(203, "Where is Maggie?", "A lead suggests where Maggie may have gone.", "Plot Line", "disappearance", 0.79m, null, "A lead points to Maggie.") + 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."), + NarrativeSignal(202, "Missing Maggie", "Maggie's absence drives the investigation.", "Plot Line", "Developed", "major", null, null, "disappearance", 0.81m, null, null, "Maggie remains missing."), + 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); @@ -4082,6 +4091,196 @@ static void StoryIntelligenceMatchesExistingPlotLinesWithoutDuplicates() 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() { 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 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("plots.SavePlotThreadAsync(new PlotThread", StringComparison.Ordinal), "Accepted Thread candidates should create canonical PlotThreads through the existing plot service."); - Assert(service.Contains("plots.SaveThreadEventAsync(new ThreadEvent", StringComparison.Ordinal), "Accepted Thread evidence should create canonical ThreadEvents."); + Assert(service.Contains("\"dbo.PlotLine_Save\"", StringComparison.Ordinal), "Accepted Plot Line candidates should create canonical PlotLines through a stored procedure."); + Assert(service.Contains("\"dbo.PlotThread_Save\"", StringComparison.Ordinal), "Accepted Thread candidates should create canonical PlotThreads through a stored procedure."); + 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.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."); diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index a6c6467..58db14e 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -173,7 +173,7 @@ public interface IStoryIntelligenceNarrativeCandidateRepository Task SaveAsync(StoryIntelligenceNarrativeCandidateSeed candidate); Task SaveEvidenceAsync(int candidateId, int sceneId, string? contributionType, string? evidence, int sortOrder); Task> 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 @@ -3256,9 +3256,13 @@ public sealed class StoryIntelligenceNarrativeCandidateRepository(ISqlConnection candidate.ProposedDescription, candidate.Category, candidate.InferredState, + candidate.ChangeType, + candidate.Significance, candidate.ExistingPlotLineID, candidate.ExistingPlotThreadID, candidate.RelatedPlotLineID, + candidate.ParentCandidateKey, + candidate.ParentNarrativeCandidateID, candidate.Confidence }, commandType: CommandType.StoredProcedure); @@ -3301,7 +3305,7 @@ public sealed class StoryIntelligenceNarrativeCandidateRepository(ISqlConnection 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(); await connection.ExecuteAsync( @@ -3313,7 +3317,8 @@ public sealed class StoryIntelligenceNarrativeCandidateRepository(ISqlConnection ReviewAction = reviewAction, ReviewedByUserID = userId, AcceptedPlotLineID = acceptedPlotLineId, - AcceptedPlotThreadID = acceptedPlotThreadId + AcceptedPlotThreadID = acceptedPlotThreadId, + MergeTargetNarrativeCandidateID = mergeTargetCandidateId }, commandType: CommandType.StoredProcedure); } diff --git a/PlotLine/Docs/AI/Scene-Prompt-V2.md b/PlotLine/Docs/AI/Scene-Prompt-V2.md index ab5358a..dbab588 100644 --- a/PlotLine/Docs/AI/Scene-Prompt-V2.md +++ b/PlotLine/Docs/AI/Scene-Prompt-V2.md @@ -421,12 +421,25 @@ When `existingPlotLines` is supplied in `sceneContext`: - if no supplied Plot Line clearly matches, set `matchingExistingPlotLineId` to `null`; - do not invent Plot Line ids. -For each narrative arc, include exactly these properties: `title`, `description`, `arcType`, `scale`, `matchingExistingPlotLineId`, `evidence`, `confidence`. +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 `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: ```json @@ -435,7 +448,12 @@ Good: "description": "Mara's search for Lena advances through absence, clues and investigation.", "arcType": "disappearance", "scale": "major", + "changeType": "Developed", + "significance": "major", + "continuityKey": "Lena disappearance", + "parentArcTitle": null, "matchingExistingPlotLineId": null, + "matchingExistingThreadId": null, "evidence": "Mara follows a clue that may explain where Lena went.", "confidence": 0.82 } @@ -689,7 +707,12 @@ Return exactly one JSON object matching this shape: "description": "", "arcType": "other", "scale": "major", + "changeType": "Introduced", + "significance": "major", + "continuityKey": "", + "parentArcTitle": null, "matchingExistingPlotLineId": null, + "matchingExistingThreadId": null, "evidence": "", "confidence": 0.0 } diff --git a/PlotLine/Models/SceneIntelligenceModels.cs b/PlotLine/Models/SceneIntelligenceModels.cs index 1f4e07e..d783d9e 100644 --- a/PlotLine/Models/SceneIntelligenceModels.cs +++ b/PlotLine/Models/SceneIntelligenceModels.cs @@ -214,7 +214,12 @@ public sealed class SceneIntelligenceNarrativeArc public string? Description { get; init; } public string? ArcType { 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? MatchingExistingThreadId { get; init; } public string? Evidence { get; init; } public decimal? Confidence { get; init; } diff --git a/PlotLine/Models/StoryIntelligenceNarrativeModels.cs b/PlotLine/Models/StoryIntelligenceNarrativeModels.cs index c4a3adb..770453e 100644 --- a/PlotLine/Models/StoryIntelligenceNarrativeModels.cs +++ b/PlotLine/Models/StoryIntelligenceNarrativeModels.cs @@ -18,6 +18,7 @@ public static class StoryIntelligenceNarrativeReviewActions { public const string AcceptNew = "AcceptNew"; public const string MergeExisting = "MergeExisting"; + public const string MergeCandidate = "MergeCandidate"; public const string Reject = "Reject"; } @@ -32,12 +33,18 @@ public sealed class StoryIntelligenceNarrativeCandidate public string? ProposedDescription { get; set; } public string? Category { get; set; } public string? InferredState { get; set; } + public string? ChangeType { get; set; } + public string? Significance { get; set; } public int? ExistingPlotLineID { get; set; } public string? ExistingPlotLineName { get; set; } public int? ExistingPlotThreadID { get; set; } public string? ExistingPlotThreadTitle { get; set; } public int? RelatedPlotLineID { 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 string Status { get; set; } = StoryIntelligenceNarrativeCandidateStatuses.Pending; public string? ReviewAction { get; set; } @@ -77,8 +84,12 @@ public sealed class StoryIntelligenceNarrativeCandidateSeed public string? ProposedDescription { get; init; } public string? Category { get; init; } public string? InferredState { get; init; } + public string? ChangeType { get; init; } + public string? Significance { get; init; } public int? ExistingPlotLineID { get; init; } public int? ExistingPlotThreadID { get; init; } public int? RelatedPlotLineID { get; init; } + public string? ParentCandidateKey { get; init; } + public int? ParentNarrativeCandidateID { get; init; } public decimal? Confidence { get; init; } } diff --git a/PlotLine/Services/PersistedStoryIntelligenceRunner.cs b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs index 08575bf..0193abe 100644 --- a/PlotLine/Services/PersistedStoryIntelligenceRunner.cs +++ b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs @@ -96,10 +96,18 @@ public sealed class PersistedStoryIntelligenceRunner( .ThenBy(plotLine => plotLine.PlotLineName) .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( run, configuredMetrics, existingPlotLines, + existingThreads, chapterPromptVersion, stageModels, totals, @@ -453,6 +461,7 @@ public sealed class PersistedStoryIntelligenceRunner( StoryIntelligenceQueuedRun run, IReadOnlyList configuredMetrics, IReadOnlyList existingPlotLines, + IReadOnlyList existingThreads, string chapterPromptVersion, StoryIntelligenceStageModels stageModels, TokenTotals totals, @@ -461,7 +470,7 @@ public sealed class PersistedStoryIntelligenceRunner( { 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); @@ -471,7 +480,7 @@ public sealed class PersistedStoryIntelligenceRunner( "Story Intelligence mode: LegacyBoundaryDetection. RunID={RunID} ChapterID={ChapterID} has no active canonical scenes.", run.StoryIntelligenceRunID, 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); @@ -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.", run.StoryIntelligenceRunID, 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); @@ -516,7 +525,8 @@ public sealed class PersistedStoryIntelligenceRunner( sourcesByScene[canonicalScenes[index].SceneID], index + 1, configuredMetrics, - existingPlotLines)); + existingPlotLines, + existingThreads)); } if (sceneBlocks.Any(block => !block.SplitValid)) @@ -544,6 +554,7 @@ public sealed class PersistedStoryIntelligenceRunner( StoryIntelligenceQueuedRun run, IReadOnlyList configuredMetrics, IReadOnlyList existingPlotLines, + IReadOnlyList existingThreads, string chapterPromptVersion, StoryIntelligenceStageModels stageModels, TokenTotals totals, @@ -678,7 +689,7 @@ public sealed class PersistedStoryIntelligenceRunner( } var sceneBlocks = chapterForProcessing.SceneBoundaries - .Select(boundary => BuildLegacySceneBlock(run, paragraphs, boundary, configuredMetrics, existingPlotLines)) + .Select(boundary => BuildLegacySceneBlock(run, paragraphs, boundary, configuredMetrics, existingPlotLines, existingThreads)) .ToList(); return new SceneAnalysisModeSelection( @@ -1011,7 +1022,8 @@ public sealed class PersistedStoryIntelligenceRunner( SceneManuscriptSourceRecord source, int ordinal, IReadOnlyList configuredMetrics, - IReadOnlyList existingPlotLines) + IReadOnlyList existingPlotLines, + IReadOnlyList existingThreads) { var sceneNumber = SceneDisplayNumber(scene.SceneNumber, ordinal); 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); var context = splitValid - ? await sceneContexts.BuildAsync(run, orderedChapterScenes, scene, source, configuredMetrics, existingPlotLines) + ? await sceneContexts.BuildAsync(run, orderedChapterScenes, scene, source, configuredMetrics, existingPlotLines, existingThreads) : null; return new StorySceneTextBlock( @@ -1046,7 +1058,8 @@ public sealed class PersistedStoryIntelligenceRunner( IReadOnlyList paragraphs, ChapterSceneBoundary boundary, IReadOnlyList configuredMetrics, - IReadOnlyList existingPlotLines) + IReadOnlyList existingPlotLines, + IReadOnlyList existingThreads) { var sceneNumber = boundary.SceneNumber ?? 0; var start = boundary.StartParagraph ?? 0; @@ -1073,7 +1086,8 @@ public sealed class PersistedStoryIntelligenceRunner( boundary.StructuralSummary, sourceLabel, configuredMetrics, - existingPlotLines), + existingPlotLines, + existingThreads), SceneTitleDisplay.Format(sceneNumber, null), boundary.StructuralSummary, true); @@ -1097,7 +1111,8 @@ public sealed class PersistedStoryIntelligenceRunner( string? structuralSummary, string sourceLabel, IReadOnlyList configuredMetrics, - IReadOnlyList existingPlotLines) + IReadOnlyList existingPlotLines, + IReadOnlyList existingThreads) => JsonSerializer.Serialize(new { projectId = run.ProjectID, @@ -1126,6 +1141,16 @@ public sealed class PersistedStoryIntelligenceRunner( description = plotLine.Description, status = plotLine.PlotImportanceName, 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); diff --git a/PlotLine/Services/StoryIntelligenceNarrativeReviewService.cs b/PlotLine/Services/StoryIntelligenceNarrativeReviewService.cs index 423fa27..ec5850d 100644 --- a/PlotLine/Services/StoryIntelligenceNarrativeReviewService.cs +++ b/PlotLine/Services/StoryIntelligenceNarrativeReviewService.cs @@ -1,4 +1,6 @@ using System.Text.Json; +using System.Data; +using Dapper; using Microsoft.AspNetCore.Mvc.Rendering; using PlotLine.Data; using PlotLine.Models; @@ -18,6 +20,7 @@ public sealed class StoryIntelligenceNarrativeReviewService( IStoryIntelligenceResultRepository runs, ISceneRepository scenes, IPlotRepository plots, + ISqlConnectionFactory connectionFactory, IStoryIntelligencePipelineStateService pipelineState) : IStoryIntelligenceNarrativeReviewService { private static readonly JsonSerializerOptions JsonOptions = new() @@ -45,7 +48,20 @@ public sealed class StoryIntelligenceNarrativeReviewService( .ThenBy(plotLine => plotLine.PlotLineName) .Select(plotLine => new SelectListItem(plotLine.PlotLineName, plotLine.PlotLineID.ToString())) .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 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(); + var mergedPlotLineTargets = new Dictionary(); + + foreach (var choice in choices.Values) { if (!rows.TryGetValue(choice.NarrativeCandidateID, out var candidate) || !string.Equals(candidate.Status, StoryIntelligenceNarrativeCandidateStatuses.Pending, StringComparison.OrdinalIgnoreCase)) @@ -86,50 +114,110 @@ public sealed class StoryIntelligenceNarrativeReviewService( continue; } + if (!string.Equals(candidate.CandidateType, StoryIntelligenceNarrativeCandidateTypes.PlotLine, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + var action = string.IsNullOrWhiteSpace(choice.Action) ? StoryIntelligenceNarrativeReviewActions.AcceptNew : choice.Action; if (string.Equals(action, StoryIntelligenceNarrativeReviewActions.Reject, StringComparison.OrdinalIgnoreCase)) { - await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Rejected, action, userId, null, null); + await MarkReviewedAsync(connection, transaction, candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Rejected, action, userId, null, null); rejected++; 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++; - continue; + } + else + { + rejected++; } - var plotLineId = await plots.SavePlotLineAsync(new PlotLineItem - { - ProjectID = batch.ProjectID, - BookID = batch.BookID, - PlotLineName = CleanTitle(choice.Title, candidate.ProposedTitle), - Description = CleanNullable(choice.Description) ?? candidate.ProposedDescription, - PlotLineTypeID = plotLineTypeId, - PlotImportanceID = plotImportanceId, - SortOrder = 0, - Colour = "#2f6f63", - IsVisibleOnTimeline = true - }); - await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Accepted, action, userId, plotLineId, null); - plotLinesCreated++; + continue; + } + + if (string.Equals(action, StoryIntelligenceNarrativeReviewActions.MergeExisting, StringComparison.OrdinalIgnoreCase) && choice.ExistingPlotLineID is > 0) + { + await MarkReviewedAsync(connection, transaction, candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Merged, action, userId, choice.ExistingPlotLineID, null); + mergedPlotLineTargets[candidate.NarrativeCandidateID] = choice.ExistingPlotLineID.Value; + plotLinesMerged++; + continue; + } + + var plotLineId = await SavePlotLineAsync(connection, transaction, new PlotLineItem + { + ProjectID = batch.ProjectID, + BookID = batch.BookID, + PlotLineName = CleanTitle(choice.Title, candidate.ProposedTitle), + Description = CleanNullable(choice.Description) ?? candidate.ProposedDescription, + PlotLineTypeID = plotLineTypeId, + PlotImportanceID = plotImportanceId, + SortOrder = 0, + Colour = "#2f6f63", + IsVisibleOnTimeline = true + }); + await MarkReviewedAsync(connection, transaction, candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Accepted, action, userId, plotLineId, null); + acceptedPlotLines[candidate.NarrativeCandidateID] = plotLineId; + plotLinesCreated++; + continue; + } + + foreach (var choice in choices.Values) + { + if (!rows.TryGetValue(choice.NarrativeCandidateID, out var candidate) + || !string.Equals(candidate.Status, StoryIntelligenceNarrativeCandidateStatuses.Pending, StringComparison.OrdinalIgnoreCase) + || string.Equals(candidate.CandidateType, StoryIntelligenceNarrativeCandidateTypes.PlotLine, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var action = string.IsNullOrWhiteSpace(choice.Action) ? StoryIntelligenceNarrativeReviewActions.AcceptNew : choice.Action; + if (string.Equals(action, StoryIntelligenceNarrativeReviewActions.Reject, StringComparison.OrdinalIgnoreCase)) + { + await MarkReviewedAsync(connection, transaction, candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Rejected, action, userId, null, null); + rejected++; + continue; + } + + if (string.Equals(action, StoryIntelligenceNarrativeReviewActions.MergeCandidate, StringComparison.OrdinalIgnoreCase) + && choice.MergeTargetNarrativeCandidateID is > 0 + && rows.TryGetValue(choice.MergeTargetNarrativeCandidateID.Value, out var target) + && string.Equals(target.CandidateType, candidate.CandidateType, StringComparison.OrdinalIgnoreCase)) + { + await MarkReviewedAsync(connection, transaction, candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Merged, action, userId, null, null, target.NarrativeCandidateID); + rejected++; continue; } var relatedPlotLineId = choice.RelatedPlotLineID ?? candidate.RelatedPlotLineID ?? choice.ExistingPlotLineID; + if (relatedPlotLineId is not > 0 && choice.ParentNarrativeCandidateID is > 0) + { + relatedPlotLineId = ResolveCreatedOrMergedPlotLine(choice.ParentNarrativeCandidateID.Value, acceptedPlotLines, mergedPlotLineTargets, rows, choices); + } + + if (relatedPlotLineId is not > 0 && candidate.ParentNarrativeCandidateID is > 0) + { + relatedPlotLineId = ResolveCreatedOrMergedPlotLine(candidate.ParentNarrativeCandidateID.Value, acceptedPlotLines, mergedPlotLineTargets, rows, choices); + } + if (relatedPlotLineId is not > 0) { - await 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++; continue; } 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, ThreadTitle = CleanTitle(choice.Title, candidate.ProposedTitle), @@ -140,9 +228,9 @@ public sealed class StoryIntelligenceNarrativeReviewService( 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, 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++; } + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + batch.LastNarrativeImportResult = new StoryIntelligenceNarrativeImportBatchResult { PlotLinesCreated = plotLinesCreated, @@ -233,9 +329,14 @@ public sealed class StoryIntelligenceNarrativeReviewService( CleanNullable(arc.Title), CleanNullable(arc.Description), category, + CleanNullable(arc.ChangeType) ?? CleanNullable(arc.ArcType) ?? "Developed", + CleanNullable(arc.Significance) ?? (string.Equals(category, "Plot Line", StringComparison.OrdinalIgnoreCase) ? "major" : "moderate"), + CleanNullable(arc.ContinuityKey), + CleanNullable(arc.ParentArcTitle), CleanNullable(arc.ArcType) ?? "Narrative arc", arc.Confidence, arc.MatchingExistingPlotLineId, + arc.MatchingExistingThreadId, arc.Evidence)); } @@ -247,8 +348,13 @@ public sealed class StoryIntelligenceNarrativeReviewService( null, "Thread", "Open", + IsPlotScope(question.Scope) ? "minor" : "minor", + null, + null, + CleanNullable(question.Scope) ?? "question", question.Confidence, null, + null, question.Evidence)); } @@ -260,38 +366,55 @@ public sealed class StoryIntelligenceNarrativeReviewService( null, "Thread", "Introduced", + "minor", + null, + null, + "clue", clue.Confidence, null, + null, clue.Evidence)); } } } - var signals = StoryIntelligenceNarrativeSignalConsolidator.Consolidate(signalInputs, existingPlotLines); - foreach (var signal in signals.Where(signal => signal.SceneIds.Count > 1).OrderBy(signal => signal.Title)) + var signals = StoryIntelligenceNarrativeSignalConsolidator.Consolidate(signalInputs, existingPlotLines, existingThreads); + var savedByKey = new Dictionary(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) ? StoryIntelligenceNarrativeCandidateTypes.PlotLine : StoryIntelligenceNarrativeCandidateTypes.Thread; + var candidateKey = $"{candidateType}:{signal.Key}"; + var parentCandidateId = signal.ParentCandidateKey is not null && savedByKey.TryGetValue($"PlotLine:{signal.ParentCandidateKey}", out var savedParentId) + ? savedParentId + : (int?)null; var candidateId = await candidates.SaveAsync(new StoryIntelligenceNarrativeCandidateSeed { ProjectID = batch.ProjectID, BookID = batch.BookID, CandidateType = candidateType, - CandidateKey = $"{candidateType}:{Key(signal.Title)}", + CandidateKey = candidateKey, ProposedTitle = signal.Title, ProposedDescription = signal.Description, Category = signal.Category, InferredState = signal.State, + ChangeType = signal.ChangeType, + Significance = signal.Significance, ExistingPlotLineID = signal.ExistingPlotLineID, ExistingPlotThreadID = existingThread?.PlotThreadID, - RelatedPlotLineID = existingThread?.PlotLineID, + RelatedPlotLineID = existingThread?.PlotLineID ?? signal.RelatedPlotLineID, + ParentCandidateKey = signal.ParentCandidateKey is null ? null : $"PlotLine:{signal.ParentCandidateKey}", + ParentNarrativeCandidateID = parentCandidateId, Confidence = signal.Confidence }); + savedByKey[candidateKey] = candidateId; var sortOrder = 0; - foreach (var item in signal.Evidence.OrderBy(item => item.SceneID)) + foreach (var item in signal.Evidence) { await candidates.SaveEvidenceAsync(candidateId, item.SceneID, item.ContributionType, item.Evidence, sortOrder++); } @@ -312,7 +435,102 @@ public sealed class StoryIntelligenceNarrativeReviewService( return false; } - private static StoryIntelligenceNarrativeReviewCandidateViewModel ToViewModel(StoryIntelligenceNarrativeCandidate candidate) + private static Task SavePlotLineAsync(IDbConnection connection, IDbTransaction transaction, PlotLineItem plotLine) + => connection.QuerySingleAsync( + "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 SavePlotThreadAsync(IDbConnection connection, IDbTransaction transaction, PlotThread plotThread) + => connection.QuerySingleAsync( + "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 SaveThreadEventAsync(IDbConnection connection, IDbTransaction transaction, ThreadEvent threadEvent) + => connection.QuerySingleAsync( + "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 BuildCandidateHierarchy(IReadOnlyList 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? childThreads = null) => new() { NarrativeCandidateID = candidate.NarrativeCandidateID, @@ -321,10 +539,15 @@ public sealed class StoryIntelligenceNarrativeReviewService( ProposedDescription = candidate.ProposedDescription ?? string.Empty, Category = candidate.Category ?? string.Empty, InferredState = candidate.InferredState ?? string.Empty, + ChangeType = candidate.ChangeType ?? string.Empty, + Significance = candidate.Significance ?? string.Empty, Confidence = candidate.Confidence.HasValue ? $"{candidate.Confidence.Value:P0}" : "Unknown", ExistingPlotLineID = candidate.ExistingPlotLineID, RelatedPlotLineID = candidate.RelatedPlotLineID, RelatedPlotLineName = candidate.RelatedPlotLineName, + ParentNarrativeCandidateID = candidate.ParentNarrativeCandidateID, + ParentCandidateTitle = candidate.ParentCandidateTitle, + ChildThreads = childThreads ?? [], DefaultAction = candidate.ExistingPlotLineID.HasValue || candidate.ExistingPlotThreadID.HasValue ? StoryIntelligenceNarrativeReviewActions.MergeExisting : StoryIntelligenceNarrativeReviewActions.AcceptNew, @@ -396,6 +619,71 @@ public sealed class StoryIntelligenceNarrativeReviewService( private static string? CleanNullable(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + private static bool IsPlotScope(string? scope) + => string.Equals(scope, "plot", StringComparison.OrdinalIgnoreCase); + + private static int? ResolveCreatedOrMergedPlotLine( + int candidateId, + IReadOnlyDictionary acceptedPlotLines, + IReadOnlyDictionary mergedPlotLineTargets, + IReadOnlyDictionary candidates, + IReadOnlyDictionary 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 EffectiveEvidence( + StoryIntelligenceNarrativeCandidate candidate, + IReadOnlyDictionary candidates, + IReadOnlyDictionary choices) + { + var rows = new List(); + 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) => StoryIntelligenceNarrativeSignalConsolidator.Key(value); } @@ -406,21 +694,52 @@ public sealed record StoryIntelligenceNarrativeSignalInput( string? Description, string Category, string State, + string Significance, + string? ContinuityKey, + string? ParentTitle, + string ArcType, decimal? Confidence, int? MatchingExistingPlotLineID, + int? MatchingExistingThreadID, string? Evidence); public sealed record StoryIntelligenceNarrativeSignalEvidence(int SceneID, string ContributionType, string? Evidence); public sealed record StoryIntelligenceNarrativeConsolidatedSignal( + string Key, string Title, string Description, string Category, string State, + string ChangeType, + string Significance, + string? ParentCandidateKey, int? ExistingPlotLineID, + int? ExistingPlotThreadID, + int? RelatedPlotLineID, decimal? Confidence, IReadOnlyCollection SceneIds, - IReadOnlyList Evidence); + IReadOnlyList 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 { @@ -469,10 +788,14 @@ public static class StoryIntelligenceNarrativeSignalConsolidator public static IReadOnlyList Consolidate( IEnumerable inputs, - IReadOnlyList existingPlotLines) + IReadOnlyList existingPlotLines, + IReadOnlyList? existingThreads = null) { var groups = new Dictionary(StringComparer.OrdinalIgnoreCase); var plotLinesById = existingPlotLines.Where(plotLine => plotLine.PlotLineID > 0).ToDictionary(plotLine => plotLine.PlotLineID); + var threadsById = (existingThreads ?? []) + .Where(thread => thread.PlotThreadID > 0) + .ToDictionary(thread => thread.PlotThreadID); foreach (var input in inputs) { @@ -482,19 +805,31 @@ public static class StoryIntelligenceNarrativeSignalConsolidator } var matchingPlotLine = ResolveExistingPlotLine(input, existingPlotLines, plotLinesById); + var matchingThread = input.MatchingExistingThreadID is > 0 && threadsById.TryGetValue(input.MatchingExistingThreadID.Value, out var suppliedThread) + ? suppliedThread + : ResolveExistingThread(input, existingThreads ?? []); var key = matchingPlotLine is not null ? $"existing:{matchingPlotLine.PlotLineID}" - : 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)) { - 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( + KeyFromInput(input), input.Title.Trim(), - string.IsNullOrWhiteSpace(input.Description) ? $"{input.Category} recurring across scenes." : input.Description.Trim(), - matchingPlotLine is not null ? "Plot Line" : input.Category, - input.State, + string.IsNullOrWhiteSpace(input.Description) ? $"{category} recurring across scenes." : input.Description.Trim(), + category, + NormalizeChangeType(input.State), + NormalizeChangeType(input.State), + NormalizeSignificance(input.Significance), + ParentCandidateKey(input.ParentTitle), matchingPlotLine?.PlotLineID, + matchingThread?.PlotThreadID, + matchingThread?.PlotLineID, ArcFamily(terms), terms.ToHashSet(StringComparer.OrdinalIgnoreCase)); groups[key] = signal; @@ -504,19 +839,33 @@ public static class StoryIntelligenceNarrativeSignalConsolidator signal.Confidence = input.Confidence.HasValue ? Math.Max(signal.Confidence ?? 0m, input.Confidence.Value) : signal.Confidence; - signal.Evidence.Add(new StoryIntelligenceNarrativeSignalEvidence(input.SceneID, input.Category, input.Evidence)); + signal.Significance = MaxSignificance(signal.Significance, input.Significance); + signal.ChangeType = LatestChange(signal.ChangeType, input.State); + var contributionType = NormalizeChangeType(input.State); + if (!signal.Evidence.Any(evidence => evidence.SceneID == input.SceneID + && string.Equals(evidence.ContributionType, contributionType, StringComparison.OrdinalIgnoreCase) + && string.Equals(evidence.Evidence ?? string.Empty, input.Evidence ?? string.Empty, StringComparison.OrdinalIgnoreCase))) + { + signal.Evidence.Add(new StoryIntelligenceNarrativeSignalEvidence(input.SceneID, contributionType, input.Evidence)); + } } return groups.Values .Select(signal => new StoryIntelligenceNarrativeConsolidatedSignal( + signal.Key, signal.Title, signal.Description, signal.Category, signal.State, + signal.ChangeType, + signal.Significance, + signal.ParentCandidateKey, signal.ExistingPlotLineID, + signal.ExistingPlotThreadID, + signal.RelatedPlotLineID, signal.Confidence, signal.SceneIds, - signal.Evidence)) + signal.Evidence.OrderBy(evidence => evidence.SceneID).ToList())) .ToList(); } @@ -538,7 +887,7 @@ public static class StoryIntelligenceNarrativeSignalConsolidator 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}"); if (signalTerms.Count == 0 || plotLineTerms.Count == 0) { @@ -558,15 +907,31 @@ public static class StoryIntelligenceNarrativeSignalConsolidator 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 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 groups, 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 continuityKey = Key(input.ContinuityKey); + var parentKey = ParentCandidateKey(input.ParentTitle); if (terms.Count == 0) { return null; @@ -574,12 +939,35 @@ public static class StoryIntelligenceNarrativeSignalConsolidator 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; } - 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; } @@ -588,9 +976,15 @@ public static class StoryIntelligenceNarrativeSignalConsolidator 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 anchors = terms .Where(term => !ArcFamilies.ContainsKey(term)) @@ -610,6 +1004,88 @@ public static class StoryIntelligenceNarrativeSignalConsolidator private static string ArcFamily(IEnumerable terms) => terms.Select(term => ArcFamilies.GetValueOrDefault(term)).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? "general"; + private static string KeyFromInput(StoryIntelligenceNarrativeSignalInput input) + { + var explicitKey = Key(input.ContinuityKey); + return explicitKey.Length > 0 ? explicitKey : Key($"{input.Title} {input.ArcType}"); + } + + private static string? ParentCandidateKey(string? parentTitle) + { + var key = Key(parentTitle); + return key.Length == 0 ? null : $"continuity:{key}"; + } + + private static string ClassifyCategory( + StoryIntelligenceNarrativeSignalInput input, + PlotLineItem? matchingPlotLine, + PlotThread? matchingThread) + { + if (matchingThread is not null) + { + return "Thread"; + } + + if (matchingPlotLine is not null) + { + return "Plot Line"; + } + + if (string.Equals(input.Category, "Plot Line", StringComparison.OrdinalIgnoreCase) + && (string.Equals(input.Significance, "major", StringComparison.OrdinalIgnoreCase) + || string.Equals(input.State, "Resolved", StringComparison.OrdinalIgnoreCase) + || string.Equals(input.State, "Reopened", StringComparison.OrdinalIgnoreCase))) + { + return "Plot Line"; + } + + return string.Equals(input.Category, "Plot Line", StringComparison.OrdinalIgnoreCase) + ? "Plot Line" + : "Thread"; + } + + private static string NormalizeChangeType(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return "Developed"; + } + + var normalized = value.Trim(); + return normalized.Equals("Open", StringComparison.OrdinalIgnoreCase) ? "Introduced" : normalized; + } + + private static string NormalizeSignificance(string? value) + => string.Equals(value, "major", StringComparison.OrdinalIgnoreCase) ? "major" + : string.Equals(value, "moderate", StringComparison.OrdinalIgnoreCase) ? "moderate" + : "minor"; + + private static string MaxSignificance(string current, string next) + { + var currentRank = SignificanceRank(current); + var nextRank = SignificanceRank(next); + return nextRank > currentRank ? NormalizeSignificance(next) : NormalizeSignificance(current); + } + + private static int SignificanceRank(string? value) + => string.Equals(value, "major", StringComparison.OrdinalIgnoreCase) ? 3 + : string.Equals(value, "moderate", StringComparison.OrdinalIgnoreCase) ? 2 + : 1; + + private static string LatestChange(string current, string next) + => IsTerminalOrEscalating(next) ? NormalizeChangeType(next) : current; + + private static bool IsStrongProgression(string? value) + => IsTerminalOrEscalating(value) + || string.Equals(value, "Developed", StringComparison.OrdinalIgnoreCase); + + private static bool IsTerminalOrEscalating(string? value) + => string.Equals(value, "Resolved", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "Reopened", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "Revealed", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "Escalated", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "Complicated", StringComparison.OrdinalIgnoreCase); + private static List Terms(string? value) => (value ?? string.Empty) .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; } - private sealed record NarrativeSignal( - string Title, - string Description, - string Category, - string State, - int? ExistingPlotLineID, - string Family, - HashSet Terms) + private sealed class NarrativeSignal( + string key, + string title, + string description, + string category, + string state, + string changeType, + string significance, + string? parentCandidateKey, + int? existingPlotLineId, + int? existingPlotThreadId, + int? relatedPlotLineId, + string family, + HashSet 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 Terms { get; } = terms; public HashSet SceneIds { get; } = []; public List Evidence { get; } = []; public decimal? Confidence { get; set; } diff --git a/PlotLine/Services/StoryIntelligenceSceneContextService.cs b/PlotLine/Services/StoryIntelligenceSceneContextService.cs index 4e3e246..2708611 100644 --- a/PlotLine/Services/StoryIntelligenceSceneContextService.cs +++ b/PlotLine/Services/StoryIntelligenceSceneContextService.cs @@ -13,7 +13,8 @@ public interface IStoryIntelligenceSceneContextService Scene scene, SceneManuscriptSourceRecord source, IReadOnlyList configuredMetrics, - IReadOnlyList existingPlotLines); + IReadOnlyList existingPlotLines, + IReadOnlyList existingThreads); } public sealed class StoryIntelligenceSceneContextService( @@ -35,7 +36,8 @@ public sealed class StoryIntelligenceSceneContextService( Scene scene, SceneManuscriptSourceRecord source, IReadOnlyList configuredMetrics, - IReadOnlyList existingPlotLines) + IReadOnlyList existingPlotLines, + IReadOnlyList existingThreads) { var book = run.BookID.HasValue ? await books.GetAsync(run.BookID.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.PlotImportanceName, 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); } @@ -277,6 +287,16 @@ public sealed class StoryIntelligenceSceneContextService( plotLine.Description, plotLine.Status, 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); } @@ -289,6 +309,7 @@ public sealed record StoryIntelligenceCanonicalSceneContext( IReadOnlyList SceneCharacters, IReadOnlyList ConfiguredMetrics, IReadOnlyList ExistingPlotLines, + IReadOnlyList ExistingThreads, string SourceLabel); 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 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 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); diff --git a/PlotLine/Services/StorySceneValidator.cs b/PlotLine/Services/StorySceneValidator.cs index 13e917b..64b5acf 100644 --- a/PlotLine/Services/StorySceneValidator.cs +++ b/PlotLine/Services/StorySceneValidator.cs @@ -356,7 +356,12 @@ public sealed class StorySceneValidator : IStorySceneValidator RequiredString(item.Description, $"{path}.description", result); RequiredString(item.ArcType, $"{path}.arcType", 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.MatchingExistingThreadId, $"{path}.matchingExistingThreadId", result, allowNull: true); RequiredString(item.Evidence, $"{path}.evidence", result); Confidence(item.Confidence, $"{path}.confidence", result); Unknown(item.ExtensionData, path, result); diff --git a/PlotLine/Sql/168_Phase26D_NarrativeCandidateHierarchy.sql b/PlotLine/Sql/168_Phase26D_NarrativeCandidateHierarchy.sql new file mode 100644 index 0000000..64eb64e --- /dev/null +++ b/PlotLine/Sql/168_Phase26D_NarrativeCandidateHierarchy.sql @@ -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 diff --git a/PlotLine/ViewModels/OnboardingViewModels.cs b/PlotLine/ViewModels/OnboardingViewModels.cs index 625854c..8f6a4f4 100644 --- a/PlotLine/ViewModels/OnboardingViewModels.cs +++ b/PlotLine/ViewModels/OnboardingViewModels.cs @@ -212,7 +212,10 @@ public sealed class StoryIntelligenceNarrativeReviewViewModel public bool HasCommittedScenes { get; init; } public bool IsComplete { get; init; } public IReadOnlyList Candidates { get; init; } = []; + public IReadOnlyList PlotLineCandidates { get; init; } = []; + public IReadOnlyList UnassignedThreadCandidates { get; init; } = []; public IReadOnlyList PlotLineOptions { get; init; } = []; + public IReadOnlyList CandidatePlotLineOptions { get; init; } = []; } public sealed class StoryIntelligenceNarrativeReviewCandidateViewModel @@ -223,10 +226,15 @@ public sealed class StoryIntelligenceNarrativeReviewCandidateViewModel public string ProposedDescription { get; init; } = string.Empty; public string Category { 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 int? ExistingPlotLineID { get; init; } public int? RelatedPlotLineID { get; init; } public string? RelatedPlotLineName { get; init; } + public int? ParentNarrativeCandidateID { get; init; } + public string? ParentCandidateTitle { get; init; } + public IReadOnlyList ChildThreads { get; init; } = []; public string DefaultAction { get; init; } = StoryIntelligenceNarrativeReviewActions.AcceptNew; public IReadOnlyList Evidence { get; init; } = []; } @@ -254,6 +262,8 @@ public sealed class StoryIntelligenceNarrativeImportChoiceForm public string? Description { get; set; } public int? ExistingPlotLineID { get; set; } public int? RelatedPlotLineID { get; set; } + public int? ParentNarrativeCandidateID { get; set; } + public int? MergeTargetNarrativeCandidateID { get; set; } } public sealed class StoryIntelligenceNarrativeImportResultViewModel diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceNarrative.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceNarrative.cshtml index 83ef4d6..5d8bbc0 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceNarrative.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceNarrative.cshtml @@ -66,8 +66,12 @@ @if (string.Equals(candidate.CandidateType, StoryIntelligenceNarrativeCandidateTypes.Thread, StringComparison.OrdinalIgnoreCase)) { + @if (!string.IsNullOrWhiteSpace(candidate.ParentCandidateTitle)) + { +

Proposed parent: @candidate.ParentCandidateTitle

+ }
- +
+
+ + +
}
Decision +
@@ -93,6 +108,28 @@ }
+
+ + +
+ @if (candidate.ChildThreads.Count > 0) + { +
+ Suggested child Threads +
    + @foreach (var child in candidate.ChildThreads) + { +
  • @child.ProposedTitle
  • + } +
+
+ }
Supporting scenes