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