Stabilise story intelligence review rendering

This commit is contained in:
Nick Beckley 2026-08-30 21:07:25 +00:00
parent d6d52b09e3
commit decb975255
10 changed files with 83 additions and 114 deletions

View File

@ -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");

View File

@ -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;

View File

@ -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<AuthorPlotSeed> authorSeeds)

View File

@ -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 @@
<div class="story-review-card-actions">
@if (hiddenCandidateCount > 0)
{
<a class="btn btn-outline-secondary btn-sm" asp-action="StoryIntelligenceAssets" asp-route-batchId="@Model.BatchID" asp-route-view="all">View all @Model.AssetReview.Candidates.Count.ToString("N0")</a>
<span class="story-review-page-status">Showing @assetCandidates.Count.ToString("N0") of @Model.AssetReview.Candidates.Count.ToString("N0")</span>
}
<button class="btn btn-outline-primary btn-sm" type="button" data-asset-bulk="create-all">Create all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-asset-bulk="link-existing">Link existing matches</button>
@ -132,7 +129,7 @@
<label class="form-label" for="asset-alias-target-@i">Alias/variant target</label>
<select id="asset-alias-target-@i" class="form-select" name="Assets[@i].AliasTargetKey" data-asset-alias-target>
<option value="">Choose asset...</option>
@foreach (var target in Model.AssetReview.Candidates.Where(target => !string.Equals(target.Key, candidate.Key, StringComparison.OrdinalIgnoreCase)))
@foreach (var target in assetCandidates.Where(target => !string.Equals(target.Key, candidate.Key, StringComparison.OrdinalIgnoreCase)))
{
<option value="@target.Key">@target.AssetName@(target.IsExistingMatch ? $" -> {target.ExistingAssetName}" : string.Empty)</option>
}
@ -146,7 +143,7 @@
<div class="onboarding-actions">
@if (hiddenCandidateCount > 0)
{
<span class="text-muted">@hiddenCandidateCount.ToString("N0") more asset decision@(hiddenCandidateCount == 1 ? string.Empty : "s") available after this batch.</span>
<span class="text-muted">Save this batch to continue with @hiddenCandidateCount.ToString("N0") more asset decision@(hiddenCandidateCount == 1 ? string.Empty : "s").</span>
}
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
<button class="btn btn-primary" type="submit" data-busy-label="Saving assets...">Save asset decisions</button>

View File

@ -1,6 +1,9 @@
@model StoryIntelligenceProgressViewModel
@{
ViewData["Title"] = "Review characters";
const int previewLimit = 25;
var characterCandidates = Model.CharacterReview.Candidates.Take(previewLimit).ToList();
var hiddenCandidateCount = Math.Max(0, Model.CharacterReview.Candidates.Count - characterCandidates.Count);
}
<section class="onboarding-shell" aria-labelledby="story-character-title">
@ -47,14 +50,18 @@
<button class="btn btn-outline-secondary btn-sm" type="button" data-character-bulk="ignore-all">Ignore all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-character-bulk="expand">Expand all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-character-bulk="collapse">Collapse all</button>
@if (hiddenCandidateCount > 0)
{
<span class="story-review-page-status">Showing @characterCandidates.Count.ToString("N0") of @Model.CharacterReview.Candidates.Count.ToString("N0")</span>
}
</div>
<form asp-action="ImportStoryIntelligenceCharacters" method="post" data-character-review-form data-story-review-form>
<input type="hidden" name="BatchID" value="@Model.BatchID" />
<div class="story-character-card-grid">
@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];
<details class="story-character-card" open data-character-card>
<summary>
<span>
@ -145,6 +152,10 @@
</div>
<div class="onboarding-actions">
@if (hiddenCandidateCount > 0)
{
<span class="text-muted">Save this batch to continue with @hiddenCandidateCount.ToString("N0") more character decision@(hiddenCandidateCount == 1 ? string.Empty : "s").</span>
}
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
<button class="btn btn-primary" type="submit" data-busy-label="Saving characters...">Save character decisions</button>
</div>

View File

@ -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 @@
<div class="story-review-card-actions">
@if (hiddenCandidateCount > 0)
{
<a class="btn btn-outline-secondary btn-sm" asp-action="StoryIntelligenceKnowledge" asp-route-batchId="@Model.BatchID" asp-route-view="all">View all @Model.KnowledgeReview.Candidates.Count.ToString("N0")</a>
<span class="story-review-page-status">Showing @knowledgeCandidates.Count.ToString("N0") of @Model.KnowledgeReview.Candidates.Count.ToString("N0")</span>
}
<button class="btn btn-outline-primary btn-sm" type="button" data-knowledge-bulk="create-all">Create all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-knowledge-bulk="link-existing">Link existing matches</button>
@ -138,7 +135,7 @@
<label class="form-label" for="knowledge-alias-target-@i">Merge target</label>
<select id="knowledge-alias-target-@i" class="form-select" name="Knowledge[@i].AliasTargetKey" data-knowledge-alias-target>
<option value="">Choose knowledge item...</option>
@foreach (var target in Model.KnowledgeReview.Candidates.Where(target => !string.Equals(target.Key, candidate.Key, StringComparison.OrdinalIgnoreCase)))
@foreach (var target in knowledgeCandidates.Where(target => !string.Equals(target.Key, candidate.Key, StringComparison.OrdinalIgnoreCase)))
{
<option value="@target.Key">@target.CharacterName - @target.KnowledgeStatement@(target.IsExistingMatch ? $" -> {target.ExistingKnowledgeLabel}" : string.Empty)</option>
}
@ -152,7 +149,7 @@
<div class="onboarding-actions">
@if (hiddenCandidateCount > 0)
{
<span class="text-muted">@hiddenCandidateCount.ToString("N0") more knowledge decision@(hiddenCandidateCount == 1 ? string.Empty : "s") available after this batch.</span>
<span class="text-muted">Save this batch to continue with @hiddenCandidateCount.ToString("N0") more knowledge decision@(hiddenCandidateCount == 1 ? string.Empty : "s").</span>
}
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
<button class="btn btn-primary" type="submit" data-busy-label="Saving knowledge...">Save knowledge decisions</button>

View File

@ -1,7 +1,9 @@
@model StoryIntelligenceProgressViewModel
@{
ViewData["Title"] = "Review locations";
var locationCandidates = Model.LocationReview.Candidates.ToList();
const int previewLimit = 25;
var locationCandidates = Model.LocationReview.Candidates.Take(previewLimit).ToList();
var hiddenCandidateCount = Math.Max(0, Model.LocationReview.Candidates.Count - locationCandidates.Count);
}
<section class="onboarding-shell" aria-labelledby="story-location-title">
@ -38,32 +40,14 @@
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="link-existing">Link existing matches</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="expand">Expand all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="collapse">Collapse all</button>
@if (hiddenCandidateCount > 0)
{
<span class="story-review-page-status">Showing @locationCandidates.Count.ToString("N0") of @Model.LocationReview.Candidates.Count.ToString("N0")</span>
}
</div>
<form asp-action="ImportStoryIntelligenceLocations" method="post" data-location-review-form data-story-review-form>
<input type="hidden" name="BatchID" value="@Model.BatchID" />
<div class="story-review-filters" aria-label="Location candidate filters">
<div>
<label class="form-label" for="location-candidate-search">Search</label>
<input id="location-candidate-search" class="form-control" type="search" placeholder="Search locations or matches" data-location-search />
</div>
<div>
<label class="form-label" for="location-candidate-status">Filter</label>
<select id="location-candidate-status" class="form-select" data-location-status-filter>
<option value="needs-review">Needs review</option>
<option value="existing">Existing matches</option>
<option value="new">New locations</option>
<option value="all">All visible candidates</option>
</select>
</div>
<div class="story-review-page-status" data-location-page-status>
Showing @Math.Min(locationCandidates.Count, 24).ToString("N0") of @locationCandidates.Count.ToString("N0")
</div>
<div class="story-review-pager">
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-page-prev>Previous</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-page-next>Next</button>
</div>
</div>
<div class="story-character-card-grid">
@for (var i = 0; i < locationCandidates.Count; i++)
{
@ -146,7 +130,7 @@
<label class="form-label" for="location-alias-target-@i">Alias/variant target</label>
<select id="location-alias-target-@i" class="form-select" name="Locations[@i].AliasTargetKey" data-location-alias-target>
<option value="">Choose location...</option>
@foreach (var target in Model.LocationReview.Candidates.Where(target => !string.Equals(target.Key, candidate.Key, StringComparison.OrdinalIgnoreCase)))
@foreach (var target in locationCandidates.Where(target => !string.Equals(target.Key, candidate.Key, StringComparison.OrdinalIgnoreCase)))
{
<option value="@target.Key">@target.LocationName@(target.IsExistingMatch ? $" -> {target.ExistingLocationName}" : string.Empty)</option>
}
@ -158,6 +142,10 @@
</div>
<div class="onboarding-actions">
@if (hiddenCandidateCount > 0)
{
<span class="text-muted">Save this batch to continue with @hiddenCandidateCount.ToString("N0") more location decision@(hiddenCandidateCount == 1 ? string.Empty : "s").</span>
}
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
<button class="btn btn-primary" type="submit" data-busy-label="Saving locations...">Save location decisions</button>
</div>
@ -173,14 +161,7 @@
const form = document.querySelector("[data-location-review-form]");
if (!form) return;
const pageSize = 24;
let currentPage = 1;
const cards = () => Array.from(form.querySelectorAll("[data-location-card]"));
const search = form.querySelector("[data-location-search]");
const statusFilter = form.querySelector("[data-location-status-filter]");
const pageStatus = form.querySelector("[data-location-page-status]");
const previousPage = form.querySelector("[data-location-page-prev]");
const nextPage = form.querySelector("[data-location-page-next]");
const setAction = (card, action) => {
const input = card.querySelector(`[data-location-action][value="${action}"]`);
if (input) {
@ -222,35 +203,6 @@
if (aliasPanel) aliasPanel.hidden = action !== "@StoryIntelligenceLocationImportActions.Alias";
updateAliasTargets();
};
const matchesFilters = (card) => {
const term = (search?.value || "").trim().toLocaleLowerCase();
const status = statusFilter?.value || "needs-review";
const cardStatus = card.getAttribute("data-location-status") || "new";
const haystack = (card.getAttribute("data-location-search-text") || "").toLocaleLowerCase();
if (term && !haystack.includes(term)) return false;
if (status === "existing" && cardStatus !== "existing") return false;
if (status === "new" && cardStatus !== "new") return false;
return true;
};
const renderPage = () => {
const allCards = cards();
const filtered = allCards.filter(matchesFilters);
const pages = Math.max(1, Math.ceil(filtered.length / pageSize));
if (currentPage > pages) currentPage = pages;
const start = (currentPage - 1) * pageSize;
const visible = new Set(filtered.slice(start, start + pageSize));
for (const card of allCards) {
card.hidden = !visible.has(card);
}
if (pageStatus) {
const end = filtered.length === 0 ? 0 : Math.min(filtered.length, start + pageSize);
pageStatus.textContent = filtered.length === 0
? "No matching location candidates"
: `Showing ${start + 1}-${end} of ${filtered.length} location candidates · Page ${currentPage} of ${pages}`;
}
if (previousPage) previousPage.disabled = currentPage <= 1;
if (nextPage) nextPage.disabled = currentPage >= pages;
};
document.querySelectorAll("[data-location-bulk]").forEach(button => {
button.addEventListener("click", () => {
const action = button.getAttribute("data-location-bulk");
@ -261,18 +213,12 @@
});
if (action === "expand") cards().forEach(card => card.open = true);
if (action === "collapse") cards().forEach(card => card.open = false);
renderPage();
});
});
previousPage?.addEventListener("click", () => { currentPage = Math.max(1, currentPage - 1); renderPage(); });
nextPage?.addEventListener("click", () => { currentPage += 1; renderPage(); });
search?.addEventListener("input", () => { currentPage = 1; renderPage(); });
statusFilter?.addEventListener("change", () => { currentPage = 1; renderPage(); });
form.querySelectorAll("[data-location-action]").forEach(input => {
input.addEventListener("change", () => updateCard(input.closest("[data-location-card]")));
});
cards().forEach(updateCard);
renderPage();
})();
</script>
<script src="~/js/story-review-actions.js" asp-append-version="true"></script>

View File

@ -1,11 +1,8 @@
@model StoryIntelligenceProgressViewModel
@{
ViewData["Title"] = "Review Plot Lines & Threads";
const int previewLimit = 50;
var showAll = string.Equals(Context.Request.Query["view"], "all", StringComparison.OrdinalIgnoreCase);
var narrativeCandidates = showAll
? Model.NarrativeReview.Candidates.ToList()
: Model.NarrativeReview.Candidates.Take(previewLimit).ToList();
const int previewLimit = 25;
var narrativeCandidates = Model.NarrativeReview.Candidates.Take(previewLimit).ToList();
var hiddenCandidateCount = Math.Max(0, Model.NarrativeReview.Candidates.Count - narrativeCandidates.Count);
var eventTypes = new[]
{
@ -50,7 +47,7 @@
@if (hiddenCandidateCount > 0)
{
<div class="story-review-card-actions">
<a class="btn btn-outline-secondary btn-sm" asp-action="StoryIntelligenceNarrative" asp-route-batchId="@Model.BatchID" asp-route-view="all">View all @Model.NarrativeReview.Candidates.Count.ToString("N0")</a>
<span class="story-review-page-status">Showing @narrativeCandidates.Count.ToString("N0") of @Model.NarrativeReview.Candidates.Count.ToString("N0")</span>
</div>
}
<div class="story-character-card-grid">
@ -143,7 +140,7 @@
<label>Candidate for merge</label>
<select class="form-select" name="Candidates[@i].MergeTargetNarrativeCandidateID">
<option value="">Choose proposed candidate</option>
@foreach (var option in Model.NarrativeReview.Candidates.Where(item => item.NarrativeCandidateID != candidate.NarrativeCandidateID && string.Equals(item.CandidateType, candidate.CandidateType, StringComparison.OrdinalIgnoreCase)).Select(item => new SelectListItem(item.ProposedTitle, item.NarrativeCandidateID.ToString())))
@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())))
{
<option value="@option.Value">@option.Text</option>
}
@ -222,7 +219,7 @@
<div class="onboarding-actions">
@if (hiddenCandidateCount > 0)
{
<span class="text-muted">@hiddenCandidateCount.ToString("N0") more narrative decision@(hiddenCandidateCount == 1 ? string.Empty : "s") available after this batch.</span>
<span class="text-muted">Save this batch to continue with @hiddenCandidateCount.ToString("N0") more narrative decision@(hiddenCandidateCount == 1 ? string.Empty : "s").</span>
}
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
<button class="btn btn-primary" type="submit" data-busy-label="Saving plot lines...">Save Plot Lines & Threads</button>

View File

@ -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 @@
<div class="story-review-card-actions">
@if (hiddenCandidateCount > 0)
{
<a class="btn btn-outline-secondary btn-sm" asp-action="StoryIntelligenceRelationships" asp-route-batchId="@Model.BatchID" asp-route-view="all">View all @Model.RelationshipReview.Candidates.Count.ToString("N0")</a>
<span class="story-review-page-status">Showing @relationshipCandidates.Count.ToString("N0") of @Model.RelationshipReview.Candidates.Count.ToString("N0")</span>
}
<button class="btn btn-outline-primary btn-sm" type="button" data-relationship-bulk="create-all">Create all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-relationship-bulk="link-existing">Link existing matches</button>
@ -148,7 +145,7 @@
<label class="form-label" for="relationship-alias-target-@i">Merge target</label>
<select id="relationship-alias-target-@i" class="form-select" name="Relationships[@i].AliasTargetKey" data-relationship-alias-target>
<option value="">Choose relationship...</option>
@foreach (var target in Model.RelationshipReview.Candidates.Where(target => !string.Equals(target.Key, candidate.Key, StringComparison.OrdinalIgnoreCase)))
@foreach (var target in relationshipCandidates.Where(target => !string.Equals(target.Key, candidate.Key, StringComparison.OrdinalIgnoreCase)))
{
<option value="@target.Key">@target.CharacterAName / @target.RelationshipType / @target.CharacterBName@(target.IsExistingMatch ? $" -> {target.ExistingRelationshipLabel}" : string.Empty)</option>
}
@ -162,7 +159,7 @@
<div class="onboarding-actions">
@if (hiddenCandidateCount > 0)
{
<span class="text-muted">@hiddenCandidateCount.ToString("N0") more relationship decision@(hiddenCandidateCount == 1 ? string.Empty : "s") available after this batch.</span>
<span class="text-muted">Save this batch to continue with @hiddenCandidateCount.ToString("N0") more relationship decision@(hiddenCandidateCount == 1 ? string.Empty : "s").</span>
}
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
<button class="btn btn-primary" type="submit" data-busy-label="Saving relationships...">Save relationship decisions</button>

View File

@ -29,6 +29,7 @@
"SceneIntelligenceModel": "gpt-5-mini",
"WholeBookPlotSynthesisModel": "gpt-5",
"SceneIntelligenceMaxOutputTokens": 10000,
"WholeBookPlotSynthesisMaxOutputTokens": 12000,
"MaxGlobalConcurrentAiRequests": 4,
"MaxConcurrentAiRequestsPerBook": 2,
"MaxConcurrentAiRequestsPerUser": 3,