Improve story intelligence narrative hierarchy
This commit is contained in:
parent
9c27ba6ffa
commit
27c7d6917b
@ -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,195 @@ 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(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");
|
||||
|
||||
@ -173,7 +173,7 @@ public interface IStoryIntelligenceNarrativeCandidateRepository
|
||||
Task<int> SaveAsync(StoryIntelligenceNarrativeCandidateSeed candidate);
|
||||
Task SaveEvidenceAsync(int candidateId, int sceneId, string? contributionType, string? evidence, int sortOrder);
|
||||
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
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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<SceneMetricType> configuredMetrics,
|
||||
IReadOnlyList<PlotLineItem> existingPlotLines,
|
||||
IReadOnlyList<PlotThread> 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<SceneMetricType> configuredMetrics,
|
||||
IReadOnlyList<PlotLineItem> existingPlotLines,
|
||||
IReadOnlyList<PlotThread> 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<SceneMetricType> configuredMetrics,
|
||||
IReadOnlyList<PlotLineItem> existingPlotLines)
|
||||
IReadOnlyList<PlotLineItem> existingPlotLines,
|
||||
IReadOnlyList<PlotThread> 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<string> paragraphs,
|
||||
ChapterSceneBoundary boundary,
|
||||
IReadOnlyList<SceneMetricType> configuredMetrics,
|
||||
IReadOnlyList<PlotLineItem> existingPlotLines)
|
||||
IReadOnlyList<PlotLineItem> existingPlotLines,
|
||||
IReadOnlyList<PlotThread> 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<SceneMetricType> configuredMetrics,
|
||||
IReadOnlyList<PlotLineItem> existingPlotLines)
|
||||
IReadOnlyList<PlotLineItem> existingPlotLines,
|
||||
IReadOnlyList<PlotThread> 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);
|
||||
|
||||
|
||||
@ -45,7 +45,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 +91,13 @@ public sealed class StoryIntelligenceNarrativeReviewService(
|
||||
var threadsCreated = 0;
|
||||
var rejected = 0;
|
||||
|
||||
foreach (var choice in form.Candidates.Where(choice => choice.NarrativeCandidateID > 0))
|
||||
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)
|
||||
|| !string.Equals(candidate.Status, StoryIntelligenceNarrativeCandidateStatuses.Pending, StringComparison.OrdinalIgnoreCase))
|
||||
@ -86,6 +105,11 @@ 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))
|
||||
{
|
||||
@ -94,11 +118,28 @@ public sealed class StoryIntelligenceNarrativeReviewService(
|
||||
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 candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Merged, action, userId, null, null, target.NarrativeCandidateID);
|
||||
if (string.Equals(candidate.CandidateType, StoryIntelligenceNarrativeCandidateTypes.PlotLine, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
plotLinesMerged++;
|
||||
}
|
||||
else
|
||||
{
|
||||
rejected++;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(action, StoryIntelligenceNarrativeReviewActions.MergeExisting, StringComparison.OrdinalIgnoreCase) && choice.ExistingPlotLineID is > 0)
|
||||
{
|
||||
await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Merged, action, userId, choice.ExistingPlotLineID, null);
|
||||
mergedPlotLineTargets[candidate.NarrativeCandidateID] = choice.ExistingPlotLineID.Value;
|
||||
plotLinesMerged++;
|
||||
continue;
|
||||
}
|
||||
@ -116,11 +157,49 @@ public sealed class StoryIntelligenceNarrativeReviewService(
|
||||
IsVisibleOnTimeline = true
|
||||
});
|
||||
await candidates.MarkReviewedAsync(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 candidates.MarkReviewedAsync(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 candidates.MarkReviewedAsync(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);
|
||||
@ -140,7 +219,7 @@ 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
|
||||
{
|
||||
@ -233,9 +312,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 +331,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 +349,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<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)
|
||||
? 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 +418,20 @@ public sealed class StoryIntelligenceNarrativeReviewService(
|
||||
return false;
|
||||
}
|
||||
|
||||
private static StoryIntelligenceNarrativeReviewCandidateViewModel ToViewModel(StoryIntelligenceNarrativeCandidate candidate)
|
||||
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()
|
||||
{
|
||||
NarrativeCandidateID = candidate.NarrativeCandidateID,
|
||||
@ -321,10 +440,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 +520,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<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)
|
||||
=> StoryIntelligenceNarrativeSignalConsolidator.Key(value);
|
||||
}
|
||||
@ -406,21 +595,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<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
|
||||
{
|
||||
@ -469,10 +689,14 @@ public static class StoryIntelligenceNarrativeSignalConsolidator
|
||||
|
||||
public static IReadOnlyList<StoryIntelligenceNarrativeConsolidatedSignal> Consolidate(
|
||||
IEnumerable<StoryIntelligenceNarrativeSignalInput> inputs,
|
||||
IReadOnlyList<PlotLineItem> existingPlotLines)
|
||||
IReadOnlyList<PlotLineItem> existingPlotLines,
|
||||
IReadOnlyList<PlotThread>? existingThreads = null)
|
||||
{
|
||||
var groups = new Dictionary<string, NarrativeSignal>(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 +706,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 +740,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 +788,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 +808,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<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,
|
||||
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 +840,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 +877,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 +905,88 @@ public static class StoryIntelligenceNarrativeSignalConsolidator
|
||||
private static string ArcFamily(IEnumerable<string> 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<string> Terms(string? value)
|
||||
=> (value ?? string.Empty)
|
||||
.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
@ -623,15 +1000,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<string> 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<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 List<StoryIntelligenceNarrativeSignalEvidence> Evidence { get; } = [];
|
||||
public decimal? Confidence { get; set; }
|
||||
|
||||
@ -13,7 +13,8 @@ public interface IStoryIntelligenceSceneContextService
|
||||
Scene scene,
|
||||
SceneManuscriptSourceRecord source,
|
||||
IReadOnlyList<SceneMetricType> configuredMetrics,
|
||||
IReadOnlyList<PlotLineItem> existingPlotLines);
|
||||
IReadOnlyList<PlotLineItem> existingPlotLines,
|
||||
IReadOnlyList<PlotThread> existingThreads);
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceSceneContextService(
|
||||
@ -35,7 +36,8 @@ public sealed class StoryIntelligenceSceneContextService(
|
||||
Scene scene,
|
||||
SceneManuscriptSourceRecord source,
|
||||
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 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<StoryIntelligenceSceneCharacterContext> SceneCharacters,
|
||||
IReadOnlyList<StoryIntelligenceMetricContext> ConfiguredMetrics,
|
||||
IReadOnlyList<StoryIntelligencePlotLineContext> ExistingPlotLines,
|
||||
IReadOnlyList<StoryIntelligencePlotThreadContext> 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<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 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);
|
||||
|
||||
@ -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);
|
||||
|
||||
187
PlotLine/Sql/168_Phase26D_NarrativeCandidateHierarchy.sql
Normal file
187
PlotLine/Sql/168_Phase26D_NarrativeCandidateHierarchy.sql
Normal 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
|
||||
@ -212,7 +212,10 @@ public sealed class StoryIntelligenceNarrativeReviewViewModel
|
||||
public bool HasCommittedScenes { get; init; }
|
||||
public bool IsComplete { 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> 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<StoryIntelligenceNarrativeReviewCandidateViewModel> ChildThreads { get; init; } = [];
|
||||
public string DefaultAction { get; init; } = StoryIntelligenceNarrativeReviewActions.AcceptNew;
|
||||
public IReadOnlyList<StoryIntelligenceNarrativeEvidenceViewModel> 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
|
||||
|
||||
@ -66,8 +66,12 @@
|
||||
</div>
|
||||
@if (string.Equals(candidate.CandidateType, StoryIntelligenceNarrativeCandidateTypes.Thread, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@if (!string.IsNullOrWhiteSpace(candidate.ParentCandidateTitle))
|
||||
{
|
||||
<p class="text-muted">Proposed parent: @candidate.ParentCandidateTitle</p>
|
||||
}
|
||||
<div>
|
||||
<label>Related Plot Line</label>
|
||||
<label>Existing related Plot Line</label>
|
||||
<select class="form-select" name="Candidates[@i].RelatedPlotLineID">
|
||||
<option value="">Choose Plot Line</option>
|
||||
@foreach (var option in Model.NarrativeReview.PlotLineOptions)
|
||||
@ -76,11 +80,22 @@
|
||||
}
|
||||
</select>
|
||||
</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>
|
||||
<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.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>
|
||||
</fieldset>
|
||||
<div>
|
||||
@ -93,6 +108,28 @@
|
||||
}
|
||||
</select>
|
||||
</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">
|
||||
<strong>Supporting scenes</strong>
|
||||
<ul>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user