From 99d818ed9f020ad281f29e9b06c6bddccab8b780 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sat, 29 Aug 2026 20:25:40 +0000 Subject: [PATCH] Preserve author plot seed provenance --- PlotLine.Tests/Program.cs | 141 ++++++++++++++++++ .../StoryIntelligenceNarrativeModels.cs | 2 + ...StoryIntelligenceNarrativeReviewService.cs | 7 + .../WholeBookPlotIntelligenceService.cs | 55 ++++++- .../174_Phase27_WholeBookPlotIntelligence.sql | 5 +- PlotLine/ViewModels/OnboardingViewModels.cs | 3 + .../StoryIntelligenceNarrative.cshtml | 19 +++ 7 files changed, 230 insertions(+), 2 deletions(-) diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 73777c3..e346db7 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -204,6 +204,12 @@ var tests = new (string Name, Action Test)[] ("Narrative production code remains fixture agnostic", NarrativeProductionCodeRemainsFixtureAgnostic), ("Whole-book Plot Intelligence prompt carries full manuscript context", WholeBookPlotIntelligencePromptCarriesFullManuscriptContext), ("Whole-book Plot Intelligence schema requires timeline event types", WholeBookPlotIntelligenceSchemaRequiresTimelineEventTypes), + ("Whole-book Plot Intelligence author seed inputs persist safely", WholeBookPlotIntelligenceAuthorSeedInputsPersistSafely), + ("Whole-book Plot Intelligence structured author seed match survives", WholeBookPlotIntelligenceStructuredAuthorSeedMatchSurvives), + ("Whole-book Plot Intelligence unmatched seeds become review candidates", WholeBookPlotIntelligenceUnmatchedSeedsBecomeReviewCandidates), + ("Story Intelligence narrative review displays author seed provenance", StoryIntelligenceNarrativeReviewDisplaysAuthorSeedProvenance), + ("Story Intelligence narrative review avoids raw author-facing IDs", StoryIntelligenceNarrativeReviewAvoidsRawAuthorFacingIds), + ("Story Intelligence author seed decisions are preserved", StoryIntelligenceAuthorSeedDecisionsArePreserved), ("Whole-book Plot Intelligence production code remains fixture agnostic", WholeBookPlotIntelligenceProductionCodeRemainsFixtureAgnostic), ("Story Intelligence narrative review creates canonical records via existing procs", StoryIntelligenceNarrativeReviewCreatesCanonicalRecordsViaExistingProcs), ("Story Intelligence narrative stage follows knowledge", StoryIntelligenceNarrativeStageFollowsKnowledge), @@ -5386,6 +5392,141 @@ static void WholeBookPlotIntelligenceSchemaRequiresTimelineEventTypes() Assert(WholeBookPlotSynthesisStructuredOutputSchema.Contract.Strict, "Whole-book schema should use strict structured output."); } +static void WholeBookPlotIntelligenceAuthorSeedInputsPersistSafely() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs")); + var contextView = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceAuthorPlotContext.cshtml")); + var repository = File.ReadAllText(Path.Combine(root, "Data/WholeBookPlotIntelligenceRepository.cs")); + var sql = File.ReadAllText(Path.Combine(root, "Sql/174_Phase27_WholeBookPlotIntelligence.sql")); + + Assert(contextView.Contains("Descriptions.Count >= 3", StringComparison.Ordinal) && contextView.Contains("Enumerable.Repeat(string.Empty, 3 - Model.Descriptions.Count)", StringComparison.Ordinal), "Author context page should render the three default seed inputs."); + Assert(contextView.Contains("data-add-plot-context", StringComparison.Ordinal) && contextView.Contains("PlotSeeds[${index}]", StringComparison.Ordinal), "Author context page should allow arbitrary additional seed inputs."); + Assert(repository.Contains(".Where(description => !string.IsNullOrWhiteSpace(description))", StringComparison.Ordinal), "Repository should ignore empty seed text before persistence."); + Assert(repository.Contains(".Select(description => new { description = description.Trim() })", StringComparison.Ordinal), "Repository should persist trimmed non-empty seed text."); + Assert(sql.Contains("NULLIF(LTRIM(RTRIM(JSON_VALUE(value, '$.description'))), N'') IS NOT NULL", StringComparison.Ordinal), "Database save procedure should also ignore empty seed text."); + + var saveIndex = controller.IndexOf("await wholeBookPlotIntelligence.SaveAuthorPlotContextAsync", StringComparison.Ordinal); + var startBookIndex = controller.IndexOf("await storyIntelligence.StartBookAsync(form.BookID)", StringComparison.Ordinal); + Assert(saveIndex >= 0 && startBookIndex > saveIndex, "Author seed persistence must complete before Analyse My Book queues analysis."); +} + +static void WholeBookPlotIntelligenceStructuredAuthorSeedMatchSurvives() +{ + const string json = """ + { + "metadata": { "bookStatus": "Draft", "summary": "A search is under way." }, + "plotLines": [ + { + "key": "PL-A", + "proposedTitle": "Hidden partnership", + "description": "A visible rivalry conceals cooperation.", + "significance": "Central author intent.", + "bookStatus": "Active", + "existingPlotLineID": 7001, + "existingPlotLineMatchConfidence": 0.82, + "existingPlotLineMatchReason": "Same long-running project line.", + "authorPlotSeedID": 9001, + "seedMatchConfidence": 0.91, + "seedMatchReason": "Matches the supplied author seed.", + "confidence": 0.88, + "threads": [] + } + ] + } + """; + var parsed = WholeBookPlotSynthesisResult.FromJson(json); + var result = parsed.PlotLines.Single(); + + Assert(result.AuthorPlotSeedID == 9001, "Structured AuthorPlotSeedID should survive response parsing."); + Assert(result.SeedMatchConfidence == 0.91m, "Seed match confidence should survive response parsing."); + Assert(result.SeedMatchReason == "Matches the supplied author seed.", "Seed match reason should survive response parsing."); + + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var repository = File.ReadAllText(Path.Combine(root, "Data/WholeBookPlotIntelligenceRepository.cs")); + var sql = File.ReadAllText(Path.Combine(root, "Sql/174_Phase27_WholeBookPlotIntelligence.sql")); + Assert(repository.Contains("plotLine.AuthorPlotSeedID", StringComparison.Ordinal), "Candidate materialisation should pass AuthorPlotSeedID to persistence."); + Assert(repository.Contains("plotLine.SeedMatchConfidence", StringComparison.Ordinal), "Candidate materialisation should pass seed confidence to persistence."); + Assert(repository.Contains("plotLine.SeedMatchReason", StringComparison.Ordinal), "Candidate materialisation should pass seed reason to persistence."); + Assert(sql.Contains("AuthorPlotSeedID = @AuthorPlotSeedID", StringComparison.Ordinal), "Candidate save procedure should retain AuthorPlotSeedID updates."); +} + +static void WholeBookPlotIntelligenceUnmatchedSeedsBecomeReviewCandidates() +{ + var result = new WholeBookPlotSynthesisResult + { + Metadata = new WholeBookPlotSynthesisMetadata { BookStatus = "Draft", Summary = "One detected line." }, + PlotLines = + [ + new WholeBookPlotSynthesisPlotLineResult + { + Key = "detected", + ProposedTitle = "Detected line", + Description = "A detected line.", + Significance = "Detected", + BookStatus = "Active", + AuthorPlotSeedID = 1, + Threads = [] + } + ] + }; + var seeds = new[] + { + new AuthorPlotSeed { AuthorPlotSeedID = 1, Description = "A matched seed." }, + new AuthorPlotSeed { AuthorPlotSeedID = 2, Description = "A dormant author seed with no current-book evidence.", MatchedPlotLineID = 42 } + }; + + var preserved = WholeBookPlotIntelligenceService.PreserveUnmatchedAuthorSeedsForReviewForTest(result, seeds); + var unmatched = preserved.PlotLines.Single(plotLine => plotLine.AuthorPlotSeedID == 2); + + Assert(unmatched.Description == "A dormant author seed with no current-book evidence.", "Unmatched author seed candidate should show the exact seed description."); + Assert(unmatched.BookStatus == "Limited current-book evidence", "Unmatched author seed candidate should explicitly represent limited evidence."); + Assert(unmatched.Threads.Count == 0, "Unmatched author seed candidate must not fabricate thread or scene evidence."); + Assert(unmatched.ExistingPlotLineID == 42, "Unmatched author seed candidate should retain an existing Plot Line match when one is already linked."); + Assert(unmatched.ExistingPlotLineMatchConfidence == 1m, "Existing author-linked Plot Line should default to merge rather than duplicate creation."); +} + +static void StoryIntelligenceNarrativeReviewDisplaysAuthorSeedProvenance() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var models = File.ReadAllText(Path.Combine(root, "Models/StoryIntelligenceNarrativeModels.cs")); + var viewModels = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs")); + var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceNarrativeReviewService.cs")); + var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceNarrative.cshtml")); + var sql = File.ReadAllText(Path.Combine(root, "Sql/174_Phase27_WholeBookPlotIntelligence.sql")); + + Assert(models.Contains("AuthorPlotSeedDescription", StringComparison.Ordinal), "Narrative candidate model should carry author seed text."); + Assert(viewModels.Contains("AuthorPlotSeedDescription", StringComparison.Ordinal), "Review ViewModel should expose author seed text."); + Assert(service.Contains("AuthorPlotSeedDescription = candidate.AuthorPlotSeedDescription", StringComparison.Ordinal), "Review ViewModel mapping should retain author seed text."); + Assert(sql.Contains("aps.Description AS AuthorPlotSeedDescription", StringComparison.Ordinal), "Candidate list proc should join author seed descriptions."); + Assert(view.Contains("Author provided", StringComparison.Ordinal), "Review UI should render quiet author provenance."); + Assert(view.Contains("@candidate.AuthorPlotSeedDescription", StringComparison.Ordinal), "Review UI should render exact author seed text."); + Assert(view.Contains("@candidate.SeedMatchReason", StringComparison.Ordinal), "Review UI should show the match or limited-evidence reason."); + Assert(view.Contains("Discovered by PlotDirector", StringComparison.Ordinal), "Review UI should distinguish discovered candidates."); + Assert(view.Contains("Existing Plot Line: @candidate.ExistingPlotLineName", StringComparison.Ordinal), "Review UI should display existing Plot Line names, not database IDs."); +} + +static void StoryIntelligenceNarrativeReviewAvoidsRawAuthorFacingIds() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceNarrative.cshtml")); + + Assert(!view.Contains("Scene @item.SceneID", StringComparison.Ordinal), "Review UI should not expose raw SceneIDs as visible text."); + Assert(!view.Contains(">PlotLineID", StringComparison.Ordinal) && !view.Contains("PlotLineID:", StringComparison.Ordinal), "Review UI should not expose raw PlotLineID labels."); + Assert(!view.Contains(">AuthorPlotSeedID", StringComparison.Ordinal) && !view.Contains("AuthorPlotSeedID:", StringComparison.Ordinal), "Review UI should not expose raw AuthorPlotSeedID labels."); + Assert(view.Contains("@item.SceneLabel", StringComparison.Ordinal), "Review UI should display readable chapter/scene labels."); +} + +static void StoryIntelligenceAuthorSeedDecisionsArePreserved() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var sql = File.ReadAllText(Path.Combine(root, "Sql/174_Phase27_WholeBookPlotIntelligence.sql")); + var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceNarrativeReviewService.cs")); + + Assert(sql.Contains("AND Status = N'Pending'", StringComparison.Ordinal), "Candidate save/update should not overwrite already reviewed author decisions."); + Assert(service.Contains("string.Equals(candidate.Status, StoryIntelligenceNarrativeCandidateStatuses.Pending", StringComparison.Ordinal), "Narrative import should only apply pending candidates."); +} + static void WholeBookPlotIntelligenceProductionCodeRemainsFixtureAgnostic() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); diff --git a/PlotLine/Models/StoryIntelligenceNarrativeModels.cs b/PlotLine/Models/StoryIntelligenceNarrativeModels.cs index 0dccd6e..162a311 100644 --- a/PlotLine/Models/StoryIntelligenceNarrativeModels.cs +++ b/PlotLine/Models/StoryIntelligenceNarrativeModels.cs @@ -41,6 +41,8 @@ public sealed class StoryIntelligenceNarrativeCandidate public string? ExistingPlotThreadTitle { get; set; } public int? WholeBookPlotSynthesisRunID { get; set; } public int? AuthorPlotSeedID { get; set; } + public string? AuthorPlotSeedDescription { get; set; } + public string? AuthorPlotSeedSource { get; set; } public decimal? ExistingPlotLineMatchConfidence { get; set; } public string? ExistingPlotLineMatchReason { get; set; } public decimal? SeedMatchConfidence { get; set; } diff --git a/PlotLine/Services/StoryIntelligenceNarrativeReviewService.cs b/PlotLine/Services/StoryIntelligenceNarrativeReviewService.cs index 6cf198c..b349094 100644 --- a/PlotLine/Services/StoryIntelligenceNarrativeReviewService.cs +++ b/PlotLine/Services/StoryIntelligenceNarrativeReviewService.cs @@ -600,6 +600,13 @@ public sealed class StoryIntelligenceNarrativeReviewService( Significance = candidate.Significance ?? string.Empty, Confidence = candidate.Confidence.HasValue ? $"{candidate.Confidence.Value:P0}" : "Unknown", ExistingPlotLineID = candidate.ExistingPlotLineID, + ExistingPlotLineName = candidate.ExistingPlotLineName, + AuthorPlotSeedID = candidate.AuthorPlotSeedID, + AuthorPlotSeedDescription = candidate.AuthorPlotSeedDescription ?? string.Empty, + AuthorPlotSeedSource = candidate.AuthorPlotSeedSource ?? string.Empty, + Source = candidate.Source ?? string.Empty, + ExistingPlotLineMatchReason = candidate.ExistingPlotLineMatchReason, + SeedMatchReason = candidate.SeedMatchReason, RelatedPlotLineID = candidate.RelatedPlotLineID, RelatedPlotLineName = candidate.RelatedPlotLineName, ParentNarrativeCandidateID = candidate.ParentNarrativeCandidateID, diff --git a/PlotLine/Services/WholeBookPlotIntelligenceService.cs b/PlotLine/Services/WholeBookPlotIntelligenceService.cs index c06e7b7..364c765 100644 --- a/PlotLine/Services/WholeBookPlotIntelligenceService.cs +++ b/PlotLine/Services/WholeBookPlotIntelligenceService.cs @@ -138,7 +138,9 @@ public sealed class WholeBookPlotIntelligenceService( settings.EffectiveWholeBookPlotSynthesisModel, responseContract: WholeBookPlotSynthesisStructuredOutputSchema.Contract); var outputJson = ExtractOutputText(result.RawResponseText); - var parsed = WholeBookPlotSynthesisResult.FromJson(outputJson); + var parsed = PreserveUnmatchedAuthorSeedsForReview( + WholeBookPlotSynthesisResult.FromJson(outputJson), + context.AuthorSeeds); stopwatch.Stop(); await repository.CompleteAsync(new WholeBookPlotSynthesisCompletionRequest @@ -181,6 +183,57 @@ public sealed class WholeBookPlotIntelligenceService( public StoryIntelligenceResponseContract BuildResponseContractForTest() => WholeBookPlotSynthesisStructuredOutputSchema.Contract; + public static WholeBookPlotSynthesisResult PreserveUnmatchedAuthorSeedsForReviewForTest( + WholeBookPlotSynthesisResult result, + IReadOnlyList authorSeeds) + => PreserveUnmatchedAuthorSeedsForReview(result, authorSeeds); + + private static WholeBookPlotSynthesisResult PreserveUnmatchedAuthorSeedsForReview( + WholeBookPlotSynthesisResult result, + IReadOnlyList authorSeeds) + { + var matchedSeedIds = result.PlotLines + .Where(plotLine => plotLine.AuthorPlotSeedID.HasValue) + .Select(plotLine => plotLine.AuthorPlotSeedID!.Value) + .ToHashSet(); + var unmatchedSeeds = authorSeeds + .Where(seed => !matchedSeedIds.Contains(seed.AuthorPlotSeedID)) + .ToList(); + if (unmatchedSeeds.Count == 0) + { + return result; + } + + var plotLines = result.PlotLines.ToList(); + foreach (var seed in unmatchedSeeds) + { + plotLines.Add(new WholeBookPlotSynthesisPlotLineResult + { + Key = $"author-seed-{seed.AuthorPlotSeedID}", + ProposedTitle = "Author-provided Plot Line", + Description = seed.Description, + Significance = "Author intent", + BookStatus = "Limited current-book evidence", + ExistingPlotLineID = seed.MatchedPlotLineID, + ExistingPlotLineMatchConfidence = seed.MatchedPlotLineID.HasValue ? 1m : null, + ExistingPlotLineMatchReason = seed.MatchedPlotLineID.HasValue + ? "Author seed is already linked to an existing Project Plot Line." + : null, + AuthorPlotSeedID = seed.AuthorPlotSeedID, + SeedMatchConfidence = null, + SeedMatchReason = "Author-provided seed retained for review; no detected whole-book Plot Line referenced this seed.", + Confidence = null, + Threads = [] + }); + } + + return new WholeBookPlotSynthesisResult + { + Metadata = result.Metadata, + PlotLines = plotLines + }; + } + private static string BuildPrompt(WholeBookPlotSynthesisContext context, bool includeOptionalContext = true) { var builder = new StringBuilder(); diff --git a/PlotLine/Sql/174_Phase27_WholeBookPlotIntelligence.sql b/PlotLine/Sql/174_Phase27_WholeBookPlotIntelligence.sql index b444666..ae71d91 100644 --- a/PlotLine/Sql/174_Phase27_WholeBookPlotIntelligence.sql +++ b/PlotLine/Sql/174_Phase27_WholeBookPlotIntelligence.sql @@ -696,7 +696,9 @@ BEGIN 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.WholeBookPlotSynthesisRunID, nc.AuthorPlotSeedID, nc.ExistingPlotLineMatchConfidence, + nc.WholeBookPlotSynthesisRunID, nc.AuthorPlotSeedID, + aps.Description AS AuthorPlotSeedDescription, aps.Source AS AuthorPlotSeedSource, + nc.ExistingPlotLineMatchConfidence, nc.ExistingPlotLineMatchReason, nc.SeedMatchConfidence, nc.SeedMatchReason, nc.Source, nc.RelatedPlotLineID, rpl.PlotLineName AS RelatedPlotLineName, nc.ParentCandidateKey, nc.ParentNarrativeCandidateID, parent.ProposedTitle AS ParentCandidateTitle, @@ -706,6 +708,7 @@ BEGIN nc.CreatedUtc, nc.UpdatedUtc FROM dbo.StoryIntelligenceNarrativeCandidates nc LEFT JOIN dbo.PlotLines epl ON epl.PlotLineID = nc.ExistingPlotLineID + LEFT JOIN dbo.AuthorPlotSeeds aps ON aps.AuthorPlotSeedID = nc.AuthorPlotSeedID 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 diff --git a/PlotLine/ViewModels/OnboardingViewModels.cs b/PlotLine/ViewModels/OnboardingViewModels.cs index 7440de3..343db52 100644 --- a/PlotLine/ViewModels/OnboardingViewModels.cs +++ b/PlotLine/ViewModels/OnboardingViewModels.cs @@ -279,7 +279,10 @@ public sealed class StoryIntelligenceNarrativeReviewCandidateViewModel public string Significance { get; init; } = string.Empty; public string Confidence { get; init; } = "Unknown"; public int? ExistingPlotLineID { get; init; } + public string? ExistingPlotLineName { get; init; } public int? AuthorPlotSeedID { get; init; } + public string AuthorPlotSeedDescription { get; init; } = string.Empty; + public string AuthorPlotSeedSource { get; init; } = string.Empty; public string Source { get; init; } = string.Empty; public string? ExistingPlotLineMatchReason { get; init; } public string? SeedMatchReason { get; init; } diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceNarrative.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceNarrative.cshtml index 5849979..fb2e56a 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceNarrative.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceNarrative.cshtml @@ -76,6 +76,25 @@ + @if (!string.IsNullOrWhiteSpace(candidate.AuthorPlotSeedDescription)) + { +
+ Author provided +

@candidate.AuthorPlotSeedDescription

+ @if (!string.IsNullOrWhiteSpace(candidate.SeedMatchReason)) + { +

@candidate.SeedMatchReason

+ } +
+ } + else if (string.Equals(candidate.Source, WholeBookPlotSynthesisSources.WholeBook, StringComparison.OrdinalIgnoreCase)) + { +

Discovered by PlotDirector

+ } + @if (candidate.ExistingPlotLineID.HasValue && !string.IsNullOrWhiteSpace(candidate.ExistingPlotLineName)) + { +

Existing Plot Line: @candidate.ExistingPlotLineName

+ } @if (string.Equals(candidate.CandidateType, StoryIntelligenceNarrativeCandidateTypes.Thread, StringComparison.OrdinalIgnoreCase)) { @if (!string.IsNullOrWhiteSpace(candidate.ParentCandidateTitle))