From decb975255ac937acc70c75263d512538091eb42 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sun, 30 Aug 2026 21:07:25 +0000 Subject: [PATCH] Stabilise story intelligence review rendering --- PlotLine.Tests/Program.cs | 44 +++++++---- PlotLine/Models/StoryIntelligenceModels.cs | 1 + .../WholeBookPlotIntelligenceService.cs | 6 ++ .../Onboarding/StoryIntelligenceAssets.cshtml | 13 ++-- .../StoryIntelligenceCharacters.cshtml | 15 +++- .../StoryIntelligenceKnowledge.cshtml | 13 ++-- .../StoryIntelligenceLocations.cshtml | 78 +++---------------- .../StoryIntelligenceNarrative.cshtml | 13 ++-- .../StoryIntelligenceRelationships.cshtml | 13 ++-- PlotLine/appsettings.json | 1 + 10 files changed, 83 insertions(+), 114 deletions(-) diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index c6b4dd9..d8cb8db 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -222,6 +222,7 @@ var tests = new (string Name, Action Test)[] ("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), + ("Whole-book Plot Intelligence uses dedicated output token headroom", WholeBookPlotIntelligenceUsesDedicatedOutputTokenHeadroom), ("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), @@ -1014,6 +1015,7 @@ static void StoryIntelligenceHeavyReviewModulesOfferBoundedRendering() var root = PlotLineRoot(); var heavyViews = new[] { + "StoryIntelligenceCharacters.cshtml", "StoryIntelligenceLocations.cshtml", "StoryIntelligenceAssets.cshtml", "StoryIntelligenceRelationships.cshtml", @@ -1024,18 +1026,19 @@ static void StoryIntelligenceHeavyReviewModulesOfferBoundedRendering() foreach (var viewName in heavyViews) { var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding", viewName)); - if (viewName == "StoryIntelligenceLocations.cshtml") - { - Assert(view.Contains("const pageSize = 24;", StringComparison.Ordinal), "StoryIntelligenceLocations.cshtml should cap visible candidates with DOM-preserving pagination."); - Assert(view.Contains("data-location-page-prev", StringComparison.Ordinal), "StoryIntelligenceLocations.cshtml should provide previous-page navigation."); - Assert(view.Contains("data-location-page-next", StringComparison.Ordinal), "StoryIntelligenceLocations.cshtml should provide next-page navigation."); - continue; - } - - Assert(view.Contains("const int previewLimit = 50", StringComparison.Ordinal), $"{viewName} should cap default rendering."); - Assert(view.Contains("asp-route-view=\"all\"", StringComparison.Ordinal), $"{viewName} should provide a View All route."); + Assert(view.Contains("const int previewLimit = 25", StringComparison.Ordinal) || view.Contains("const pageSize = 24;", StringComparison.Ordinal), $"{viewName} should cap visible candidates to a small page."); + Assert(!view.Contains("asp-route-view=\"all\"", StringComparison.Ordinal), $"{viewName} should not expose an unbounded View All path."); Assert(view.Contains("hiddenCandidateCount", StringComparison.Ordinal), $"{viewName} should disclose hidden remaining decisions."); } + + var assets = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceAssets.cshtml")); + var relationships = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceRelationships.cshtml")); + var knowledge = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceKnowledge.cshtml")); + var narrative = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceNarrative.cshtml")); + Assert(!assets.Contains("Model.AssetReview.Candidates.Where(target => !string.Equals(target.Key, candidate.Key", StringComparison.Ordinal), "Asset merge options should not enumerate the full candidate set per visible card."); + Assert(!relationships.Contains("Model.RelationshipReview.Candidates.Where(target => !string.Equals(target.Key, candidate.Key", StringComparison.Ordinal), "Relationship merge options should not enumerate the full candidate set per visible card."); + Assert(!knowledge.Contains("Model.KnowledgeReview.Candidates.Where(target => !string.Equals(target.Key, candidate.Key", StringComparison.Ordinal), "Knowledge merge options should not enumerate the full candidate set per visible card."); + Assert(!narrative.Contains("Model.NarrativeReview.Candidates.Where(item => item.NarrativeCandidateID != candidate.NarrativeCandidateID", StringComparison.Ordinal), "Narrative merge options should not enumerate the full candidate set per visible card."); } static void StoryIntelligenceReviewCentreAddsNoAiCalls() @@ -2429,10 +2432,10 @@ static void LocationReviewUxSupportsLargeCandidateSets() var viewModels = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs")); var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceLocationImportService.cs")); - Assert(view.Contains("data-location-search", StringComparison.Ordinal), "Location review should provide candidate search."); - Assert(view.Contains("data-location-status-filter", StringComparison.Ordinal), "Location review should provide status filtering."); - Assert(view.Contains("const pageSize = 24;", StringComparison.Ordinal), "Location review should show roughly 20-25 candidates at once."); - Assert(view.Contains("data-location-page-status", StringComparison.Ordinal), "Location review should show paging/review counts."); + Assert(view.Contains("const int previewLimit = 25", StringComparison.Ordinal), "Location review should render a bounded candidate batch."); + Assert(view.Contains("hiddenCandidateCount", StringComparison.Ordinal), "Location review should disclose remaining decisions outside the current batch."); + Assert(view.Contains("story-review-page-status", StringComparison.Ordinal), "Location review should show bounded review counts."); + Assert(!view.Contains("data-location-page-next", StringComparison.Ordinal), "Location review should not hide a full candidate DOM behind client-side paging."); Assert(view.Contains("EvidenceSummaries", StringComparison.Ordinal), "Location review should expose compact aggregated evidence."); Assert(!view.Contains("data-location-bulk=\"create-all\"", StringComparison.Ordinal), "Location review should not offer bulk create-all."); Assert(!view.Contains("data-location-bulk=\"ignore-all\"", StringComparison.Ordinal), "Location review should not offer bulk ignore-all."); @@ -6026,6 +6029,19 @@ static void WholeBookPlotIntelligenceUnmatchedSeedsBecomeReviewCandidates() Assert(unmatched.ExistingPlotLineMatchConfidence == 1m, "Existing author-linked Plot Line should default to merge rather than duplicate creation."); } +static void WholeBookPlotIntelligenceUsesDedicatedOutputTokenHeadroom() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var settings = File.ReadAllText(Path.Combine(root, "Models/StoryIntelligenceModels.cs")); + var appsettings = File.ReadAllText(Path.Combine(root, "appsettings.json")); + var service = File.ReadAllText(Path.Combine(root, "Services/WholeBookPlotIntelligenceService.cs")); + + Assert(settings.Contains("WholeBookPlotSynthesisMaxOutputTokens", StringComparison.Ordinal), "Whole-book Plot synthesis should have stage-specific output token configuration."); + Assert(appsettings.Contains("\"WholeBookPlotSynthesisMaxOutputTokens\": 12000", StringComparison.Ordinal), "Whole-book Plot synthesis should have headroom beyond the generic 4000-token default."); + Assert(service.Contains("WholeBookPlotSynthesisMaxOutputTokens()", StringComparison.Ordinal), "Whole-book Plot synthesis should pass its dedicated max output token override to the OpenAI client."); + Assert(service.Contains("settings.WholeBookPlotSynthesisMaxOutputTokens.GetValueOrDefault(settings.MaxOutputTokens)", StringComparison.Ordinal), "Whole-book Plot synthesis should fall back to the generic setting when the dedicated value is not configured."); +} + static void StoryIntelligenceNarrativeReviewDisplaysAuthorSeedProvenance() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); diff --git a/PlotLine/Models/StoryIntelligenceModels.cs b/PlotLine/Models/StoryIntelligenceModels.cs index 4c0e425..129d385 100644 --- a/PlotLine/Models/StoryIntelligenceModels.cs +++ b/PlotLine/Models/StoryIntelligenceModels.cs @@ -114,6 +114,7 @@ public sealed class StoryIntelligenceOptions public int MaxAssetImagesPerImport { get; init; } = 2; public int MaxOutputTokens { get; init; } = 4000; public int? SceneIntelligenceMaxOutputTokens { get; init; } + public int? WholeBookPlotSynthesisMaxOutputTokens { get; init; } public int TimeoutSeconds { get; init; } = 120; public int MaxGlobalConcurrentAiRequests { get; init; } = 4; public int MaxConcurrentAiRequestsPerBook { get; init; } = 2; diff --git a/PlotLine/Services/WholeBookPlotIntelligenceService.cs b/PlotLine/Services/WholeBookPlotIntelligenceService.cs index 364c765..1c26905 100644 --- a/PlotLine/Services/WholeBookPlotIntelligenceService.cs +++ b/PlotLine/Services/WholeBookPlotIntelligenceService.cs @@ -136,6 +136,7 @@ public sealed class WholeBookPlotIntelligenceService( PromptVersion, cancellationToken, settings.EffectiveWholeBookPlotSynthesisModel, + WholeBookPlotSynthesisMaxOutputTokens(), responseContract: WholeBookPlotSynthesisStructuredOutputSchema.Contract); var outputJson = ExtractOutputText(result.RawResponseText); var parsed = PreserveUnmatchedAuthorSeedsForReview( @@ -183,6 +184,11 @@ public sealed class WholeBookPlotIntelligenceService( public StoryIntelligenceResponseContract BuildResponseContractForTest() => WholeBookPlotSynthesisStructuredOutputSchema.Contract; + private int WholeBookPlotSynthesisMaxOutputTokens() + => settings.WholeBookPlotSynthesisMaxOutputTokens.GetValueOrDefault(settings.MaxOutputTokens) > 0 + ? settings.WholeBookPlotSynthesisMaxOutputTokens.GetValueOrDefault(settings.MaxOutputTokens) + : settings.MaxOutputTokens; + public static WholeBookPlotSynthesisResult PreserveUnmatchedAuthorSeedsForReviewForTest( WholeBookPlotSynthesisResult result, IReadOnlyList authorSeeds) diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceAssets.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceAssets.cshtml index 856a797..9671015 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceAssets.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceAssets.cshtml @@ -1,11 +1,8 @@ @model StoryIntelligenceProgressViewModel @{ ViewData["Title"] = "Review assets"; - const int previewLimit = 50; - var showAll = string.Equals(Context.Request.Query["view"], "all", StringComparison.OrdinalIgnoreCase); - var assetCandidates = showAll - ? Model.AssetReview.Candidates.ToList() - : Model.AssetReview.Candidates.Take(previewLimit).ToList(); + const int previewLimit = 25; + var assetCandidates = Model.AssetReview.Candidates.Take(previewLimit).ToList(); var hiddenCandidateCount = Math.Max(0, Model.AssetReview.Candidates.Count - assetCandidates.Count); } @@ -46,7 +43,7 @@
@if (hiddenCandidateCount > 0) { - View all @Model.AssetReview.Candidates.Count.ToString("N0") + Showing @assetCandidates.Count.ToString("N0") of @Model.AssetReview.Candidates.Count.ToString("N0") } @@ -132,7 +129,7 @@
- @for (var i = 0; i < Model.CharacterReview.Candidates.Count; i++) + @for (var i = 0; i < characterCandidates.Count; i++) { - var candidate = Model.CharacterReview.Candidates[i]; + var candidate = characterCandidates[i];
@@ -145,6 +152,10 @@
+ @if (hiddenCandidateCount > 0) + { + Save this batch to continue with @hiddenCandidateCount.ToString("N0") more character decision@(hiddenCandidateCount == 1 ? string.Empty : "s"). + } Back to Review Centre
diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceKnowledge.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceKnowledge.cshtml index c77aeb5..986799d 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceKnowledge.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceKnowledge.cshtml @@ -1,11 +1,8 @@ @model StoryIntelligenceProgressViewModel @{ ViewData["Title"] = "Review knowledge"; - const int previewLimit = 50; - var showAll = string.Equals(Context.Request.Query["view"], "all", StringComparison.OrdinalIgnoreCase); - var knowledgeCandidates = showAll - ? Model.KnowledgeReview.Candidates.ToList() - : Model.KnowledgeReview.Candidates.Take(previewLimit).ToList(); + const int previewLimit = 25; + var knowledgeCandidates = Model.KnowledgeReview.Candidates.Take(previewLimit).ToList(); var hiddenCandidateCount = Math.Max(0, Model.KnowledgeReview.Candidates.Count - knowledgeCandidates.Count); } @@ -46,7 +43,7 @@
@if (hiddenCandidateCount > 0) { - View all @Model.KnowledgeReview.Candidates.Count.ToString("N0") + Showing @knowledgeCandidates.Count.ToString("N0") of @Model.KnowledgeReview.Candidates.Count.ToString("N0") } @@ -138,7 +135,7 @@ -
-
- - -
-
- - -
-
- Showing @Math.Min(locationCandidates.Count, 24).ToString("N0") of @locationCandidates.Count.ToString("N0") -
-
- - -
-
@for (var i = 0; i < locationCandidates.Count; i++) { @@ -146,7 +130,7 @@ - @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()))) + @foreach (var option in narrativeCandidates.Where(item => item.NarrativeCandidateID != candidate.NarrativeCandidateID && string.Equals(item.CandidateType, candidate.CandidateType, StringComparison.OrdinalIgnoreCase)).Select(item => new SelectListItem(item.ProposedTitle, item.NarrativeCandidateID.ToString()))) { } @@ -222,7 +219,7 @@
@if (hiddenCandidateCount > 0) { - @hiddenCandidateCount.ToString("N0") more narrative decision@(hiddenCandidateCount == 1 ? string.Empty : "s") available after this batch. + Save this batch to continue with @hiddenCandidateCount.ToString("N0") more narrative decision@(hiddenCandidateCount == 1 ? string.Empty : "s"). } Back to Review Centre diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceRelationships.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceRelationships.cshtml index d470b47..dd42d9b 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceRelationships.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceRelationships.cshtml @@ -1,11 +1,8 @@ @model StoryIntelligenceProgressViewModel @{ ViewData["Title"] = "Review relationships"; - const int previewLimit = 50; - var showAll = string.Equals(Context.Request.Query["view"], "all", StringComparison.OrdinalIgnoreCase); - var relationshipCandidates = showAll - ? Model.RelationshipReview.Candidates.ToList() - : Model.RelationshipReview.Candidates.Take(previewLimit).ToList(); + const int previewLimit = 25; + var relationshipCandidates = Model.RelationshipReview.Candidates.Take(previewLimit).ToList(); var hiddenCandidateCount = Math.Max(0, Model.RelationshipReview.Candidates.Count - relationshipCandidates.Count); } @@ -46,7 +43,7 @@
@if (hiddenCandidateCount > 0) { - View all @Model.RelationshipReview.Candidates.Count.ToString("N0") + Showing @relationshipCandidates.Count.ToString("N0") of @Model.RelationshipReview.Candidates.Count.ToString("N0") } @@ -148,7 +145,7 @@