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 metric persistence uses configured keys", StoryIntelligenceMetricPersistenceUsesConfiguredKeys),
|
||||
("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 stage follows knowledge", StoryIntelligenceNarrativeStageFollowsKnowledge),
|
||||
("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 sql = File.ReadAllText(Path.Combine(root, "Sql/155_Phase25_StoryIntelligenceNarrativeReview.sql"));
|
||||
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 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 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("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(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(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.");
|
||||
@ -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.");
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
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`.
|
||||
|
||||
## 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
|
||||
|
||||
Report answers supplied by the scene.
|
||||
@ -641,6 +690,17 @@ Return exactly one JSON object matching this shape:
|
||||
"confidence": 0.0
|
||||
}
|
||||
],
|
||||
"narrativeArcs": [
|
||||
{
|
||||
"title": "",
|
||||
"description": "",
|
||||
"arcType": "other",
|
||||
"scale": "major",
|
||||
"matchingExistingPlotLineId": null,
|
||||
"evidence": "",
|
||||
"confidence": 0.0
|
||||
}
|
||||
],
|
||||
"questionsRaised": [
|
||||
{
|
||||
"question": "",
|
||||
|
||||
@ -18,6 +18,7 @@ public sealed class SceneIntelligenceScene
|
||||
public List<SceneIntelligenceRelationship>? Relationships { get; init; }
|
||||
public List<SceneIntelligenceKnowledgeChange>? KnowledgeChanges { get; init; }
|
||||
public List<SceneIntelligenceTimelineClue>? TimelineClues { get; init; }
|
||||
public List<SceneIntelligenceNarrativeArc>? NarrativeArcs { get; init; }
|
||||
public List<SceneIntelligenceQuestionRaised>? QuestionsRaised { get; init; }
|
||||
public List<SceneIntelligenceQuestionAnswered>? QuestionsAnswered { get; init; }
|
||||
public List<SceneIntelligenceObservation>? Observations { get; init; }
|
||||
@ -207,6 +208,20 @@ public sealed class SceneIntelligenceQuestionRaised
|
||||
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 string? Question { get; init; }
|
||||
|
||||
@ -190,13 +190,10 @@ public sealed class StoryIntelligenceNarrativeReviewService(
|
||||
{
|
||||
var existingPlotLines = await plots.ListPlotLinesAsync(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
|
||||
.GroupBy(thread => Key(thread.ThreadTitle), 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)
|
||||
{
|
||||
@ -224,23 +221,55 @@ public sealed class StoryIntelligenceNarrativeReviewService(
|
||||
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 ?? [])
|
||||
{
|
||||
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 ?? [])
|
||||
{
|
||||
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 existingPlotLine = existingPlotLineByKey.GetValueOrDefault(Key(signal.Title));
|
||||
var candidateType = signal.SceneIds.Count >= 3 && existingThread is null
|
||||
var candidateType = string.Equals(signal.Category, "Plot Line", StringComparison.OrdinalIgnoreCase) || (signal.ExistingPlotLineID.HasValue && existingThread is null)
|
||||
? StoryIntelligenceNarrativeCandidateTypes.PlotLine
|
||||
: StoryIntelligenceNarrativeCandidateTypes.Thread;
|
||||
var candidateId = await candidates.SaveAsync(new StoryIntelligenceNarrativeCandidateSeed
|
||||
@ -253,7 +282,7 @@ public sealed class StoryIntelligenceNarrativeReviewService(
|
||||
ProposedDescription = signal.Description,
|
||||
Category = signal.Category,
|
||||
InferredState = signal.State,
|
||||
ExistingPlotLineID = existingPlotLine?.PlotLineID,
|
||||
ExistingPlotLineID = signal.ExistingPlotLineID,
|
||||
ExistingPlotThreadID = existingThread?.PlotThreadID,
|
||||
RelatedPlotLineID = existingThread?.PlotLineID,
|
||||
Confidence = signal.Confidence
|
||||
@ -306,35 +335,6 @@ public sealed class StoryIntelligenceNarrativeReviewService(
|
||||
}).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(
|
||||
StoryIntelligenceSavedSceneResult sceneResult,
|
||||
IReadOnlyDictionary<string, Scene> importedByRange,
|
||||
@ -389,15 +389,243 @@ public sealed class StoryIntelligenceNarrativeReviewService(
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static string Key(string? value)
|
||||
=> new((value ?? string.Empty).Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray());
|
||||
=> StoryIntelligenceNarrativeSignalConsolidator.Key(value);
|
||||
}
|
||||
|
||||
private sealed record NarrativeSignal(int ProjectID, int BookID, string Title, string Category, string State)
|
||||
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)
|
||||
{
|
||||
public HashSet<int> SceneIds { get; } = [];
|
||||
public List<NarrativeSignalEvidence> Evidence { get; } = [];
|
||||
public decimal? Confidence { get; set; }
|
||||
public string Description => $"{Category} recurring across {SceneIds.Count:N0} scenes.";
|
||||
"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();
|
||||
}
|
||||
|
||||
private sealed record NarrativeSignalEvidence(int SceneID, string ContributionType, string? Evidence);
|
||||
public static string Key(string? value)
|
||||
=> new((value ?? string.Empty).Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray());
|
||||
|
||||
private static PlotLineItem? ResolveExistingPlotLine(
|
||||
StoryIntelligenceNarrativeSignalInput input,
|
||||
IReadOnlyList<PlotLineItem> existingPlotLines,
|
||||
IReadOnlyDictionary<int, PlotLineItem> plotLinesById)
|
||||
{
|
||||
if (input.MatchingExistingPlotLineID is > 0 && plotLinesById.TryGetValue(input.MatchingExistingPlotLineID.Value, out var suppliedMatch))
|
||||
{
|
||||
return suppliedMatch;
|
||||
}
|
||||
|
||||
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);
|
||||
ValidateKnowledgeChanges(scene.KnowledgeChanges, result);
|
||||
ValidateTimelineClues(scene.TimelineClues, result);
|
||||
ValidateNarrativeArcs(scene.NarrativeArcs, result);
|
||||
ValidateQuestionsRaised(scene.QuestionsRaised, result);
|
||||
ValidateQuestionsAnswered(scene.QuestionsAnswered, result);
|
||||
ValidateObservations(scene.Observations, result);
|
||||
@ -80,6 +81,7 @@ public sealed class StorySceneValidator : IStorySceneValidator
|
||||
RequiredArray(scene.Relationships, "relationships", result);
|
||||
RequiredArray(scene.KnowledgeChanges, "knowledgeChanges", result);
|
||||
RequiredArray(scene.TimelineClues, "timelineClues", result);
|
||||
RequiredArray(scene.NarrativeArcs, "narrativeArcs", result);
|
||||
RequiredArray(scene.QuestionsRaised, "questionsRaised", result);
|
||||
RequiredArray(scene.QuestionsAnswered, "questionsAnswered", 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)
|
||||
{
|
||||
if (values is null)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user