Consolidate Story Intelligence review candidates

This commit is contained in:
Nick Beckley 2026-08-30 22:03:38 +00:00
parent 1fab92f0e5
commit 9c1f9c9182
22 changed files with 1296 additions and 159 deletions

View File

@ -275,12 +275,13 @@ var tests = new (string Name, Action Test)[]
("Story Intelligence review module pages omit wizard chrome", StoryIntelligenceReviewModulePagesOmitWizardChrome), ("Story Intelligence review module pages omit wizard chrome", StoryIntelligenceReviewModulePagesOmitWizardChrome),
("Story Intelligence downstream review uses completed scene analysis", StoryIntelligenceDownstreamReviewUsesCompletedSceneAnalysis), ("Story Intelligence downstream review uses completed scene analysis", StoryIntelligenceDownstreamReviewUsesCompletedSceneAnalysis),
("Story Intelligence review posts do not use success interstitials", StoryIntelligenceReviewPostsDoNotUseSuccessInterstitials), ("Story Intelligence review posts do not use success interstitials", StoryIntelligenceReviewPostsDoNotUseSuccessInterstitials),
("Story Intelligence legacy review completion routes redirect to centre", StoryIntelligenceLegacyReviewCompletionRoutesRedirectToCentre), ("Story Intelligence legacy review completion routes are deleted", StoryIntelligenceLegacyReviewCompletionRoutesAreDeleted),
("Story Intelligence review forms share busy submit guard", StoryIntelligenceReviewFormsShareBusySubmitGuard), ("Story Intelligence review forms share busy submit guard", StoryIntelligenceReviewFormsShareBusySubmitGuard),
("Story Intelligence heavy review modules offer bounded rendering", StoryIntelligenceHeavyReviewModulesOfferBoundedRendering), ("Story Intelligence heavy review modules offer bounded rendering", StoryIntelligenceHeavyReviewModulesOfferBoundedRendering),
("Story Intelligence Review Centre adds no AI calls", StoryIntelligenceReviewCentreAddsNoAiCalls), ("Story Intelligence Review Centre adds no AI calls", StoryIntelligenceReviewCentreAddsNoAiCalls),
("Story Intelligence Review Centre uses bounded summary query", StoryIntelligenceReviewCentreUsesBoundedSummaryQuery), ("Story Intelligence Review Centre uses bounded summary query", StoryIntelligenceReviewCentreUsesBoundedSummaryQuery),
("Story Intelligence detailed review GETs use selected module paths", StoryIntelligenceDetailedReviewGetsUseSelectedModulePaths), ("Story Intelligence detailed review GETs use selected module paths", StoryIntelligenceDetailedReviewGetsUseSelectedModulePaths),
("Story Intelligence detailed reviews read persisted candidate pages", StoryIntelligenceDetailedReviewsReadPersistedCandidatePages),
("Story Intelligence Job 6 uses bounded concurrent chapter execution", StoryIntelligenceJob6UsesBoundedConcurrentChapterExecution), ("Story Intelligence Job 6 uses bounded concurrent chapter execution", StoryIntelligenceJob6UsesBoundedConcurrentChapterExecution),
("Story Intelligence global progress uses whole run scene totals", StoryIntelligenceGlobalProgressUsesWholeRunSceneTotals), ("Story Intelligence global progress uses whole run scene totals", StoryIntelligenceGlobalProgressUsesWholeRunSceneTotals),
("Story Intelligence recovered provider failures stay operational", StoryIntelligenceRecoveredProviderFailuresStayOperational), ("Story Intelligence recovered provider failures stay operational", StoryIntelligenceRecoveredProviderFailuresStayOperational),
@ -946,12 +947,12 @@ static void StoryIntelligenceReviewPostsDoNotUseSuccessInterstitials()
{ {
var root = PlotLineRoot(); var root = PlotLineRoot();
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs")); var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
var postBlock = ExtractBetween(controller, "[HttpPost(\"story-intelligence/characters\")]", "[HttpGet(\"story-intelligence/characters/complete\")]") var postBlock = ExtractBetween(controller, "[HttpPost(\"story-intelligence/characters\")]", "[HttpPost(\"story-intelligence/locations\")]")
+ ExtractBetween(controller, "[HttpPost(\"story-intelligence/locations\")]", "[HttpGet(\"story-intelligence/locations/complete\")]") + ExtractBetween(controller, "[HttpPost(\"story-intelligence/locations\")]", "[HttpPost(\"story-intelligence/assets\")]")
+ ExtractBetween(controller, "[HttpPost(\"story-intelligence/assets\")]", "[HttpGet(\"story-intelligence/assets/complete\")]") + ExtractBetween(controller, "[HttpPost(\"story-intelligence/assets\")]", "[HttpPost(\"story-intelligence/relationships\")]")
+ ExtractBetween(controller, "[HttpPost(\"story-intelligence/relationships\")]", "[HttpGet(\"story-intelligence/relationships/complete\")]") + ExtractBetween(controller, "[HttpPost(\"story-intelligence/relationships\")]", "[HttpGet(\"story-intelligence/knowledge\")]")
+ ExtractBetween(controller, "[HttpPost(\"story-intelligence/knowledge\")]", "[HttpGet(\"story-intelligence/knowledge/complete\")]") + ExtractBetween(controller, "[HttpPost(\"story-intelligence/knowledge\")]", "[HttpGet(\"story-intelligence/narrative\")]")
+ ExtractBetween(controller, "[HttpPost(\"story-intelligence/narrative\")]", "[HttpGet(\"story-intelligence/narrative/complete\")]"); + ExtractBetween(controller, "[HttpPost(\"story-intelligence/narrative\")]", "[HttpPost(\"story-intelligence/plot-synthesis/retry\")]");
Assert(postBlock.Contains("StoryIntelligenceReviewCentre", StringComparison.Ordinal), "Review posts should return to the centre."); Assert(postBlock.Contains("StoryIntelligenceReviewCentre", StringComparison.Ordinal), "Review posts should return to the centre.");
Assert(!postBlock.Contains("StoryIntelligenceCharacterComplete", StringComparison.Ordinal), "Character post should not target success interstitial."); Assert(!postBlock.Contains("StoryIntelligenceCharacterComplete", StringComparison.Ordinal), "Character post should not target success interstitial.");
@ -962,27 +963,32 @@ static void StoryIntelligenceReviewPostsDoNotUseSuccessInterstitials()
Assert(!postBlock.Contains("StoryIntelligenceNarrativeComplete", StringComparison.Ordinal), "Narrative post should not target success interstitial."); Assert(!postBlock.Contains("StoryIntelligenceNarrativeComplete", StringComparison.Ordinal), "Narrative post should not target success interstitial.");
} }
static void StoryIntelligenceLegacyReviewCompletionRoutesRedirectToCentre() static void StoryIntelligenceLegacyReviewCompletionRoutesAreDeleted()
{ {
var root = PlotLineRoot(); var root = PlotLineRoot();
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs")); var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
var completionActions = new[] var deletedRoutes = new[]
{ {
("StoryIntelligenceCharacterComplete", "characters"), "story-intelligence/characters/complete",
("StoryIntelligenceLocationComplete", "locations"), "story-intelligence/locations/complete",
("StoryIntelligenceAssetComplete", "assets"), "story-intelligence/assets/complete",
("StoryIntelligenceRelationshipComplete", "relationships"), "story-intelligence/relationships/complete",
("StoryIntelligenceKnowledgeComplete", "knowledge"), "story-intelligence/knowledge/complete",
("StoryIntelligenceNarrativeComplete", "narrative") "story-intelligence/narrative/complete",
"story-intelligence/complete"
}; };
foreach (var (action, route) in completionActions) foreach (var route in deletedRoutes)
{ {
var block = ExtractBetween(controller, $"public IActionResult {action}", "}"); Assert(!controller.Contains($"[HttpGet(\"{route}\")]", StringComparison.Ordinal), $"{route} should be deleted, not redirected.");
Assert(controller.Contains($"[HttpGet(\"story-intelligence/{route}/complete\")]", StringComparison.Ordinal), $"{action} legacy route should still exist.");
Assert(block.Contains("RedirectToAction(nameof(StoryIntelligenceReviewCentre)", StringComparison.Ordinal), $"{action} should redirect to Review Centre.");
Assert(!block.Contains("View(", StringComparison.Ordinal), $"{action} should not render a success interstitial.");
} }
Assert(!controller.Contains("StoryIntelligenceCharacterComplete", StringComparison.Ordinal), "Character completion action should be deleted.");
Assert(!controller.Contains("StoryIntelligenceLocationComplete", StringComparison.Ordinal), "Location completion action should be deleted.");
Assert(!controller.Contains("StoryIntelligenceAssetComplete", StringComparison.Ordinal), "Asset completion action should be deleted.");
Assert(!controller.Contains("StoryIntelligenceRelationshipComplete", StringComparison.Ordinal), "Relationship completion action should be deleted.");
Assert(!controller.Contains("StoryIntelligenceKnowledgeComplete", StringComparison.Ordinal), "Knowledge completion action should be deleted.");
Assert(!controller.Contains("StoryIntelligenceNarrativeComplete", StringComparison.Ordinal), "Narrative completion action should be deleted.");
} }
static void StoryIntelligenceReviewFormsShareBusySubmitGuard() static void StoryIntelligenceReviewFormsShareBusySubmitGuard()
@ -1088,11 +1094,11 @@ static void StoryIntelligenceDetailedReviewGetsUseSelectedModulePaths()
var expectedActions = new Dictionary<string, string>(StringComparer.Ordinal) var expectedActions = new Dictionary<string, string>(StringComparer.Ordinal)
{ {
["StoryIntelligenceCharacters"] = "GetCharacterReviewAsync(batchId)", ["StoryIntelligenceCharacters"] = "GetCharacterReviewAsync(batchId, page)",
["StoryIntelligenceLocations"] = "GetLocationReviewAsync(batchId)", ["StoryIntelligenceLocations"] = "GetLocationReviewAsync(batchId, page)",
["StoryIntelligenceAssets"] = "GetAssetReviewAsync(batchId)", ["StoryIntelligenceAssets"] = "GetAssetReviewAsync(batchId, page)",
["StoryIntelligenceRelationships"] = "GetRelationshipReviewAsync(batchId)", ["StoryIntelligenceRelationships"] = "GetRelationshipReviewAsync(batchId, page)",
["StoryIntelligenceKnowledge"] = "GetKnowledgeReviewAsync(batchId)", ["StoryIntelligenceKnowledge"] = "GetKnowledgeReviewAsync(batchId, page)",
["StoryIntelligenceNarrative"] = "GetNarrativeReviewAsync(batchId)" ["StoryIntelligenceNarrative"] = "GetNarrativeReviewAsync(batchId)"
}; };
@ -1120,6 +1126,47 @@ static void StoryIntelligenceDetailedReviewGetsUseSelectedModulePaths()
var narrativeMethod = ExtractBetween(service, "public Task<StoryIntelligenceProgressViewModel?> GetNarrativeReviewAsync", "private async Task<StoryIntelligenceProgressViewModel?> GetSelectedReviewAsync"); var narrativeMethod = ExtractBetween(service, "public Task<StoryIntelligenceProgressViewModel?> GetNarrativeReviewAsync", "private async Task<StoryIntelligenceProgressViewModel?> GetSelectedReviewAsync");
Assert(narrativeMethod.Contains("narrativeReview.GetReviewAsync(frame.Batch)", StringComparison.Ordinal), "Plot Lines & Threads should use persisted narrative candidates."); Assert(narrativeMethod.Contains("narrativeReview.GetReviewAsync(frame.Batch)", StringComparison.Ordinal), "Plot Lines & Threads should use persisted narrative candidates.");
Assert(!narrativeMethod.Contains("EnsureCandidatesAsync", StringComparison.Ordinal), "Plot Lines & Threads GET should not generate narrative candidates."); Assert(!narrativeMethod.Contains("EnsureCandidatesAsync", StringComparison.Ordinal), "Plot Lines & Threads GET should not generate narrative candidates.");
var persistedReviewService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceReviewCandidateService.cs"));
Assert(service.Contains("reviewCandidates.GetCharacterReviewAsync(frame.Batch, pageNumber, pageSize)", StringComparison.Ordinal), "Character GET should read persisted review candidates.");
Assert(service.Contains("reviewCandidates.GetLocationReviewAsync(frame.Batch, pageNumber, pageSize)", StringComparison.Ordinal), "Location GET should read persisted review candidates.");
Assert(service.Contains("reviewCandidates.GetAssetReviewAsync(frame.Batch, pageNumber, pageSize)", StringComparison.Ordinal), "Asset GET should read persisted review candidates.");
Assert(service.Contains("reviewCandidates.GetRelationshipReviewAsync(frame.Batch, pageNumber, pageSize)", StringComparison.Ordinal), "Relationship GET should read persisted review candidates.");
Assert(service.Contains("reviewCandidates.GetKnowledgeReviewAsync(frame.Batch, pageNumber, pageSize)", StringComparison.Ordinal), "Knowledge GET should read persisted review candidates.");
Assert(persistedReviewService.Contains("ListPageAsync", StringComparison.Ordinal), "Persisted review service should page through candidate rows.");
Assert(!persistedReviewService.Contains("ListSceneResultsAsync", StringComparison.Ordinal), "Persisted review GET service must not parse retained Scene Intelligence JSON.");
Assert(!persistedReviewService.Contains("GetRunAsync", StringComparison.Ordinal), "Persisted review GET service must not traverse old run batches.");
}
static void StoryIntelligenceDetailedReviewsReadPersistedCandidatePages()
{
var root = PlotLineRoot();
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceReviewCandidateService.cs"));
var repository = File.ReadAllText(Path.Combine(root, "Data/StoryIntelligenceReviewCandidateRepository.cs"));
var migration = File.ReadAllText(Path.Combine(root, "Sql/177_Phase27D_StoryIntelligencePersistedReviewCandidates.sql"));
var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
var program = File.ReadAllText(Path.Combine(root, "Program.cs"));
foreach (var module in new[] { "Characters", "Locations", "Assets", "Relationships", "Knowledge" })
{
Assert(migration.Contains($"Module = @Module", StringComparison.Ordinal), $"{module} persisted reads should be module scoped.");
}
Assert(migration.Contains("CREATE TABLE dbo.StoryIntelligenceReviewCandidateGenerations", StringComparison.Ordinal), "Candidate preparation generations should be durable.");
Assert(migration.Contains("CREATE TABLE dbo.StoryIntelligenceReviewCandidates", StringComparison.Ordinal), "Review candidates should be durable.");
Assert(migration.Contains("CREATE TABLE dbo.StoryIntelligenceReviewCandidateEvidence", StringComparison.Ordinal), "Review evidence should be durable.");
Assert(migration.Contains("StoryIntelligenceReviewCandidate_ListPage", StringComparison.Ordinal), "Review GET should use a bounded page procedure.");
Assert(migration.Contains("IF @PageSize > 100 SET @PageSize = 100", StringComparison.Ordinal), "Page size should be bounded server-side.");
Assert(repository.Contains("CommandType.StoredProcedure", StringComparison.Ordinal), "Candidate repository should use stored procedures.");
Assert(!repository.Contains("SELECT ", StringComparison.Ordinal), "Candidate repository should not use inline SELECT SQL.");
Assert(onboarding.Contains("reviewCandidates.GetLocationReviewAsync(frame.Batch, pageNumber, pageSize)", StringComparison.Ordinal), "Normal Location GET should use persisted candidate pages.");
Assert(onboarding.Contains("reviewCandidates.GetRelationshipReviewAsync(frame.Batch, pageNumber, pageSize)", StringComparison.Ordinal), "Normal Relationship GET should use persisted candidate pages.");
Assert(service.Contains("PreparePeerCandidatesAsync", StringComparison.Ordinal), "Scene JSON candidate builders should be isolated to deliberate preparation.");
Assert(service.Contains("LoadPageAsync", StringComparison.Ordinal), "Normal review reads should use page loading.");
Assert(!ExtractBetween(service, "public async Task<StoryIntelligenceLocationReviewViewModel> GetLocationReviewAsync", "public async Task<StoryIntelligenceAssetReviewViewModel> GetAssetReviewAsync").Contains("BuildReviewAsync", StringComparison.Ordinal), "Location GET must not call the old builder.");
Assert(!ExtractBetween(service, "public async Task<StoryIntelligenceRelationshipReviewViewModel> GetRelationshipReviewAsync", "public async Task<StoryIntelligenceKnowledgeReviewViewModel> GetKnowledgeReviewAsync").Contains("BuildReviewAsync", StringComparison.Ordinal), "Relationship GET must not call the old builder.");
Assert(program.Contains("IStoryIntelligenceReviewCandidateRepository, StoryIntelligenceReviewCandidateRepository", StringComparison.Ordinal), "Persisted candidate repository should be registered.");
Assert(program.Contains("IStoryIntelligenceReviewCandidateService, StoryIntelligenceReviewCandidateService", StringComparison.Ordinal), "Persisted candidate service should be registered.");
} }
static string ExtractAction(string value, string signature) static string ExtractAction(string value, string signature)

View File

@ -321,9 +321,9 @@ public sealed class OnboardingController(
} }
[HttpGet("story-intelligence/characters")] [HttpGet("story-intelligence/characters")]
public async Task<IActionResult> StoryIntelligenceCharacters(Guid batchId) public async Task<IActionResult> StoryIntelligenceCharacters(Guid batchId, int page = 1)
{ {
var model = await storyIntelligence.GetCharacterReviewAsync(batchId); var model = await storyIntelligence.GetCharacterReviewAsync(batchId, page);
if (model is null) if (model is null)
{ {
return NotFound(); return NotFound();
@ -345,9 +345,9 @@ public sealed class OnboardingController(
} }
[HttpGet("story-intelligence/locations")] [HttpGet("story-intelligence/locations")]
public async Task<IActionResult> StoryIntelligenceLocations(Guid batchId) public async Task<IActionResult> StoryIntelligenceLocations(Guid batchId, int page = 1)
{ {
var model = await storyIntelligence.GetLocationReviewAsync(batchId); var model = await storyIntelligence.GetLocationReviewAsync(batchId, page);
if (model is null) if (model is null)
{ {
return NotFound(); return NotFound();
@ -369,9 +369,9 @@ public sealed class OnboardingController(
} }
[HttpGet("story-intelligence/assets")] [HttpGet("story-intelligence/assets")]
public async Task<IActionResult> StoryIntelligenceAssets(Guid batchId) public async Task<IActionResult> StoryIntelligenceAssets(Guid batchId, int page = 1)
{ {
var model = await storyIntelligence.GetAssetReviewAsync(batchId); var model = await storyIntelligence.GetAssetReviewAsync(batchId, page);
if (model is null) if (model is null)
{ {
return NotFound(); return NotFound();
@ -396,9 +396,9 @@ public sealed class OnboardingController(
} }
[HttpGet("story-intelligence/relationships")] [HttpGet("story-intelligence/relationships")]
public async Task<IActionResult> StoryIntelligenceRelationships(Guid batchId) public async Task<IActionResult> StoryIntelligenceRelationships(Guid batchId, int page = 1)
{ {
var model = await storyIntelligence.GetRelationshipReviewAsync(batchId); var model = await storyIntelligence.GetRelationshipReviewAsync(batchId, page);
if (model is null) if (model is null)
{ {
return NotFound(); return NotFound();
@ -480,12 +480,6 @@ public sealed class OnboardingController(
: RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId = form.BatchID }); : RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId = form.BatchID });
} }
[HttpGet("story-intelligence/characters/complete")]
public IActionResult StoryIntelligenceCharacterComplete(Guid batchId)
{
return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
}
[HttpPost("story-intelligence/locations")] [HttpPost("story-intelligence/locations")]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> ImportStoryIntelligenceLocations(StoryIntelligenceLocationImportForm form) public async Task<IActionResult> ImportStoryIntelligenceLocations(StoryIntelligenceLocationImportForm form)
@ -502,12 +496,6 @@ public sealed class OnboardingController(
: RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = form.BatchID }); : RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = form.BatchID });
} }
[HttpGet("story-intelligence/locations/complete")]
public IActionResult StoryIntelligenceLocationComplete(Guid batchId)
{
return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
}
[HttpPost("story-intelligence/assets")] [HttpPost("story-intelligence/assets")]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> ImportStoryIntelligenceAssets(StoryIntelligenceAssetImportForm form) public async Task<IActionResult> ImportStoryIntelligenceAssets(StoryIntelligenceAssetImportForm form)
@ -524,12 +512,6 @@ public sealed class OnboardingController(
: RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId = form.BatchID }); : RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId = form.BatchID });
} }
[HttpGet("story-intelligence/assets/complete")]
public IActionResult StoryIntelligenceAssetComplete(Guid batchId)
{
return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
}
[HttpPost("story-intelligence/relationships")] [HttpPost("story-intelligence/relationships")]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> ImportStoryIntelligenceRelationships(StoryIntelligenceRelationshipImportForm form) public async Task<IActionResult> ImportStoryIntelligenceRelationships(StoryIntelligenceRelationshipImportForm form)
@ -546,16 +528,10 @@ public sealed class OnboardingController(
: RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId = form.BatchID }); : RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId = form.BatchID });
} }
[HttpGet("story-intelligence/relationships/complete")]
public IActionResult StoryIntelligenceRelationshipComplete(Guid batchId)
{
return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
}
[HttpGet("story-intelligence/knowledge")] [HttpGet("story-intelligence/knowledge")]
public async Task<IActionResult> StoryIntelligenceKnowledge(Guid batchId) public async Task<IActionResult> StoryIntelligenceKnowledge(Guid batchId, int page = 1)
{ {
var model = await storyIntelligence.GetKnowledgeReviewAsync(batchId); var model = await storyIntelligence.GetKnowledgeReviewAsync(batchId, page);
if (model is null) if (model is null)
{ {
return NotFound(); return NotFound();
@ -595,12 +571,6 @@ public sealed class OnboardingController(
: RedirectToAction(nameof(StoryIntelligenceKnowledge), new { batchId = form.BatchID }); : RedirectToAction(nameof(StoryIntelligenceKnowledge), new { batchId = form.BatchID });
} }
[HttpGet("story-intelligence/knowledge/complete")]
public IActionResult StoryIntelligenceKnowledgeComplete(Guid batchId)
{
return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
}
[HttpGet("story-intelligence/narrative")] [HttpGet("story-intelligence/narrative")]
public async Task<IActionResult> StoryIntelligenceNarrative(Guid batchId) public async Task<IActionResult> StoryIntelligenceNarrative(Guid batchId)
{ {
@ -647,7 +617,7 @@ public sealed class OnboardingController(
[HttpPost("story-intelligence/plot-synthesis/retry")] [HttpPost("story-intelligence/plot-synthesis/retry")]
public async Task<IActionResult> RetryStoryIntelligencePlotSynthesis(Guid batchId) public async Task<IActionResult> RetryStoryIntelligencePlotSynthesis(Guid batchId)
{ {
var model = await storyIntelligence.GetProgressAsync(batchId); var model = await storyIntelligence.GetReviewCentreAsync(batchId);
if (model is null) if (model is null)
{ {
return NotFound(); return NotFound();
@ -663,30 +633,6 @@ public sealed class OnboardingController(
return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }); return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
} }
[HttpGet("story-intelligence/narrative/complete")]
public IActionResult StoryIntelligenceNarrativeComplete(Guid batchId)
{
return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
}
[HttpGet("story-intelligence/complete")]
public async Task<IActionResult> StoryIntelligenceComplete(Guid batchId)
{
var progress = await storyIntelligence.GetProgressAsync(batchId);
if (progress is null)
{
return NotFound();
}
if (!progress.CharacterGateSatisfied)
{
return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
}
var model = await storyIntelligence.GetCompletionAsync(batchId);
return model is null ? NotFound() : View(model);
}
private IActionResult RedirectToBook(StoryIntelligenceProgressViewModel model) private IActionResult RedirectToBook(StoryIntelligenceProgressViewModel model)
=> RedirectToAction("Details", "Books", new { id = model.BookID }); => RedirectToAction("Details", "Books", new { id = model.BookID });

View File

@ -0,0 +1,131 @@
using System.Data;
using Dapper;
using PlotLine.Models;
namespace PlotLine.Data;
public interface IStoryIntelligenceReviewCandidateRepository
{
Task<StoryIntelligenceReviewCandidateGeneration> BeginGenerationAsync(int projectId, int bookId, string module, string source);
Task<StoryIntelligenceReviewCandidateGeneration?> GetCurrentGenerationAsync(int bookId, string module);
Task<StoryIntelligenceReviewCandidateRow> SaveCandidateAsync(StoryIntelligenceReviewCandidateSaveRequest request);
Task SaveEvidenceAsync(int reviewCandidateId, int? sceneId, string evidenceText, decimal? confidence, int sortOrder);
Task<StoryIntelligenceReviewCandidateGeneration?> CompleteGenerationAsync(int generationId);
Task FailGenerationAsync(int generationId, string errorMessage);
Task<IReadOnlyList<StoryIntelligenceReviewCandidateRow>> ListPageAsync(int bookId, string module, int pageNumber, int pageSize);
Task<StoryIntelligenceReviewCandidateGeneration?> MarkReviewedAsync(StoryIntelligenceReviewCandidateDecisionRequest request);
Task ResetBookAsync(int bookId);
}
public sealed class StoryIntelligenceReviewCandidateRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligenceReviewCandidateRepository
{
public async Task<StoryIntelligenceReviewCandidateGeneration> BeginGenerationAsync(int projectId, int bookId, string module, string source)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<StoryIntelligenceReviewCandidateGeneration>(
"dbo.StoryIntelligenceReviewCandidateGeneration_Begin",
new { ProjectID = projectId, BookID = bookId, Module = module, Source = source },
commandType: CommandType.StoredProcedure);
}
public async Task<StoryIntelligenceReviewCandidateGeneration?> GetCurrentGenerationAsync(int bookId, string module)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceReviewCandidateGeneration>(
"dbo.StoryIntelligenceReviewCandidateGeneration_GetCurrent",
new { BookID = bookId, Module = module },
commandType: CommandType.StoredProcedure);
}
public async Task<StoryIntelligenceReviewCandidateRow> SaveCandidateAsync(StoryIntelligenceReviewCandidateSaveRequest request)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<StoryIntelligenceReviewCandidateRow>(
"dbo.StoryIntelligenceReviewCandidate_Save",
new
{
request.ReviewCandidateGenerationID,
request.ProjectID,
request.BookID,
request.Module,
request.StableKey,
request.CandidateType,
request.DisplayName,
request.SortOrder,
request.Confidence,
request.ExistingCanonicalID,
request.CandidatePayloadJson
},
commandType: CommandType.StoredProcedure);
}
public async Task SaveEvidenceAsync(int reviewCandidateId, int? sceneId, string evidenceText, decimal? confidence, int sortOrder)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.StoryIntelligenceReviewCandidateEvidence_Save",
new
{
ReviewCandidateID = reviewCandidateId,
SceneID = sceneId,
EvidenceText = evidenceText,
Confidence = confidence,
SortOrder = sortOrder
},
commandType: CommandType.StoredProcedure);
}
public async Task<StoryIntelligenceReviewCandidateGeneration?> CompleteGenerationAsync(int generationId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceReviewCandidateGeneration>(
"dbo.StoryIntelligenceReviewCandidateGeneration_Complete",
new { ReviewCandidateGenerationID = generationId },
commandType: CommandType.StoredProcedure);
}
public async Task FailGenerationAsync(int generationId, string errorMessage)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.StoryIntelligenceReviewCandidateGeneration_Fail",
new { ReviewCandidateGenerationID = generationId, ErrorMessage = errorMessage },
commandType: CommandType.StoredProcedure);
}
public async Task<IReadOnlyList<StoryIntelligenceReviewCandidateRow>> ListPageAsync(int bookId, string module, int pageNumber, int pageSize)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<StoryIntelligenceReviewCandidateRow>(
"dbo.StoryIntelligenceReviewCandidate_ListPage",
new { BookID = bookId, Module = module, PageNumber = pageNumber, PageSize = pageSize },
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<StoryIntelligenceReviewCandidateGeneration?> MarkReviewedAsync(StoryIntelligenceReviewCandidateDecisionRequest request)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceReviewCandidateGeneration>(
"dbo.StoryIntelligenceReviewCandidate_MarkReviewed",
new
{
request.BookID,
request.Module,
request.StableKey,
request.DecisionAction,
request.DecisionTargetKey,
request.DecisionCanonicalID
},
commandType: CommandType.StoredProcedure);
}
public async Task ResetBookAsync(int bookId)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.StoryIntelligenceReviewCandidate_ResetBook",
new { BookID = bookId },
commandType: CommandType.StoredProcedure);
}
}

View File

@ -0,0 +1,85 @@
namespace PlotLine.Models;
public static class StoryIntelligenceReviewModules
{
public const string Characters = "Characters";
public const string Locations = "Locations";
public const string Assets = "Assets";
public const string Relationships = "Relationships";
public const string Knowledge = "Knowledge";
}
public static class StoryIntelligenceReviewCandidateGenerationStatuses
{
public const string Preparing = "Preparing";
public const string Ready = "Ready";
public const string Failed = "Failed";
}
public sealed class StoryIntelligenceReviewCandidateGeneration
{
public int ReviewCandidateGenerationID { get; init; }
public int ProjectID { get; init; }
public int BookID { get; init; }
public string Module { get; init; } = string.Empty;
public string Status { get; init; } = string.Empty;
public string Source { get; init; } = string.Empty;
public int CandidateCount { get; init; }
public int PendingCount { get; init; }
public string? ErrorMessage { get; init; }
public bool IsCurrent { get; init; }
public DateTime CreatedUtc { get; init; }
public DateTime? PreparedUtc { get; init; }
public DateTime? FailedUtc { get; init; }
public DateTime UpdatedUtc { get; init; }
}
public sealed class StoryIntelligenceReviewCandidateRow
{
public int ReviewCandidateID { get; init; }
public int ReviewCandidateGenerationID { get; init; }
public int ProjectID { get; init; }
public int BookID { get; init; }
public string Module { get; init; } = string.Empty;
public string StableKey { get; init; } = string.Empty;
public string? CandidateType { get; init; }
public string DisplayName { get; init; } = string.Empty;
public int SortOrder { get; init; }
public decimal? Confidence { get; init; }
public int? ExistingCanonicalID { get; init; }
public string DecisionStatus { get; init; } = string.Empty;
public string? DecisionAction { get; init; }
public string? DecisionTargetKey { get; init; }
public int? DecisionCanonicalID { get; init; }
public string CandidatePayloadJson { get; init; } = string.Empty;
public DateTime CreatedUtc { get; init; }
public DateTime? ReviewedUtc { get; init; }
public DateTime UpdatedUtc { get; init; }
public int TotalCount { get; init; }
public int PendingCount { get; init; }
}
public sealed class StoryIntelligenceReviewCandidateSaveRequest
{
public int ReviewCandidateGenerationID { get; init; }
public int ProjectID { get; init; }
public int BookID { get; init; }
public string Module { get; init; } = string.Empty;
public string StableKey { get; init; } = string.Empty;
public string? CandidateType { get; init; }
public string DisplayName { get; init; } = string.Empty;
public int SortOrder { get; init; }
public decimal? Confidence { get; init; }
public int? ExistingCanonicalID { get; init; }
public string CandidatePayloadJson { get; init; } = string.Empty;
}
public sealed class StoryIntelligenceReviewCandidateDecisionRequest
{
public int BookID { get; init; }
public string Module { get; init; } = string.Empty;
public string StableKey { get; init; } = string.Empty;
public string DecisionAction { get; init; } = string.Empty;
public string? DecisionTargetKey { get; init; }
public int? DecisionCanonicalID { get; init; }
}

View File

@ -123,6 +123,7 @@ public class Program
builder.Services.AddScoped<ISceneMetricTypeRepository, SceneMetricTypeRepository>(); builder.Services.AddScoped<ISceneMetricTypeRepository, SceneMetricTypeRepository>();
builder.Services.AddScoped<IPlotRepository, PlotRepository>(); builder.Services.AddScoped<IPlotRepository, PlotRepository>();
builder.Services.AddScoped<IStoryIntelligenceNarrativeCandidateRepository, StoryIntelligenceNarrativeCandidateRepository>(); builder.Services.AddScoped<IStoryIntelligenceNarrativeCandidateRepository, StoryIntelligenceNarrativeCandidateRepository>();
builder.Services.AddScoped<IStoryIntelligenceReviewCandidateRepository, StoryIntelligenceReviewCandidateRepository>();
builder.Services.AddScoped<IAssetRepository, AssetRepository>(); builder.Services.AddScoped<IAssetRepository, AssetRepository>();
builder.Services.AddScoped<ICharacterRepository, CharacterRepository>(); builder.Services.AddScoped<ICharacterRepository, CharacterRepository>();
builder.Services.AddScoped<ILocationRepository, LocationRepository>(); builder.Services.AddScoped<ILocationRepository, LocationRepository>();
@ -231,6 +232,7 @@ public class Program
builder.Services.AddScoped<IStoryIntelligenceRelationshipImportService, StoryIntelligenceRelationshipImportService>(); builder.Services.AddScoped<IStoryIntelligenceRelationshipImportService, StoryIntelligenceRelationshipImportService>();
builder.Services.AddScoped<IStoryIntelligenceKnowledgeImportService, StoryIntelligenceKnowledgeImportService>(); builder.Services.AddScoped<IStoryIntelligenceKnowledgeImportService, StoryIntelligenceKnowledgeImportService>();
builder.Services.AddScoped<IStoryIntelligenceNarrativeReviewService, StoryIntelligenceNarrativeReviewService>(); builder.Services.AddScoped<IStoryIntelligenceNarrativeReviewService, StoryIntelligenceNarrativeReviewService>();
builder.Services.AddScoped<IStoryIntelligenceReviewCandidateService, StoryIntelligenceReviewCandidateService>();
builder.Services.AddScoped<IWholeBookPlotIntelligenceService, WholeBookPlotIntelligenceService>(); builder.Services.AddScoped<IWholeBookPlotIntelligenceService, WholeBookPlotIntelligenceService>();
builder.Services.AddScoped<IStoryIntelligenceSceneContextService, StoryIntelligenceSceneContextService>(); builder.Services.AddScoped<IStoryIntelligenceSceneContextService, StoryIntelligenceSceneContextService>();
builder.Services.AddScoped<IStoryMemoryService, StoryMemoryService>(); builder.Services.AddScoped<IStoryMemoryService, StoryMemoryService>();

View File

@ -13,11 +13,11 @@ public interface IOnboardingStoryIntelligenceService
Task<OnboardingStoryIntelligenceBatch?> StartAsync(string? appearancePreference = null, string? primaryAgeGroup = null, string? storyEra = null, string? storyLocation = null); Task<OnboardingStoryIntelligenceBatch?> StartAsync(string? appearancePreference = null, string? primaryAgeGroup = null, string? storyEra = null, string? storyLocation = null);
Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid batchId); Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid batchId);
Task<StoryIntelligenceProgressViewModel?> GetReviewCentreAsync(Guid batchId); Task<StoryIntelligenceProgressViewModel?> GetReviewCentreAsync(Guid batchId);
Task<StoryIntelligenceProgressViewModel?> GetCharacterReviewAsync(Guid batchId); Task<StoryIntelligenceProgressViewModel?> GetCharacterReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25);
Task<StoryIntelligenceProgressViewModel?> GetLocationReviewAsync(Guid batchId); Task<StoryIntelligenceProgressViewModel?> GetLocationReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25);
Task<StoryIntelligenceProgressViewModel?> GetAssetReviewAsync(Guid batchId); Task<StoryIntelligenceProgressViewModel?> GetAssetReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25);
Task<StoryIntelligenceProgressViewModel?> GetRelationshipReviewAsync(Guid batchId); Task<StoryIntelligenceProgressViewModel?> GetRelationshipReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25);
Task<StoryIntelligenceProgressViewModel?> GetKnowledgeReviewAsync(Guid batchId); Task<StoryIntelligenceProgressViewModel?> GetKnowledgeReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25);
Task<StoryIntelligenceProgressViewModel?> GetNarrativeReviewAsync(Guid batchId); Task<StoryIntelligenceProgressViewModel?> GetNarrativeReviewAsync(Guid batchId);
Task<StoryIntelligenceProgressViewModel?> CancelAsync(Guid batchId); Task<StoryIntelligenceProgressViewModel?> CancelAsync(Guid batchId);
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAsync(Guid batchId, int runId); Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAsync(Guid batchId, int runId);
@ -54,6 +54,7 @@ public sealed class OnboardingStoryIntelligenceService(
IWholeBookPlotIntelligenceService wholeBookPlotIntelligence, IWholeBookPlotIntelligenceService wholeBookPlotIntelligence,
IStoryIntelligenceSourceRepository sources, IStoryIntelligenceSourceRepository sources,
IStoryIntelligencePipelineStateService pipelineState, IStoryIntelligencePipelineStateService pipelineState,
IStoryIntelligenceReviewCandidateService reviewCandidates,
IStoryMemoryRepository storyMemory, IStoryMemoryRepository storyMemory,
IBookRepository books, IBookRepository books,
IChapterRepository chapters, IChapterRepository chapters,
@ -647,19 +648,19 @@ public sealed class OnboardingStoryIntelligenceService(
}); });
} }
var characterReview = await characterImport.BuildReviewAsync(batch); var characterReview = await reviewCandidates.GetCharacterReviewAsync(batch, 1, 25);
var locationReview = await locationImport.BuildReviewAsync(batch); var locationReview = await reviewCandidates.GetLocationReviewAsync(batch, 1, 25);
var assetReview = await assetImport.BuildReviewAsync(batch); var assetReview = await reviewCandidates.GetAssetReviewAsync(batch, 1, 25);
var relationshipReview = await relationshipImport.BuildReviewAsync(batch); var relationshipReview = await reviewCandidates.GetRelationshipReviewAsync(batch, 1, 25);
var knowledgeReview = await knowledgeImport.BuildReviewAsync(batch); var knowledgeReview = await reviewCandidates.GetKnowledgeReviewAsync(batch, 1, 25);
var narrative = await narrativeReview.GetReviewAsync(batch); var narrative = await narrativeReview.GetReviewAsync(batch);
var plotSynthesisRun = await wholeBookPlotIntelligence.GetCurrentAsync(batch.BookID); var plotSynthesisRun = await wholeBookPlotIntelligence.GetCurrentAsync(batch.BookID);
var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID); var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID);
var characterComplete = batch.CharacterStageComplete || pipeline?.CharacterReviewCompletedUtc is not null || characterReview.IsComplete; var characterComplete = pipeline?.CharacterReviewCompletedUtc is not null || characterReview.IsComplete;
var locationComplete = batch.LocationStageComplete || pipeline?.LocationReviewCompletedUtc is not null || locationReview.IsComplete; var locationComplete = pipeline?.LocationReviewCompletedUtc is not null || locationReview.IsComplete;
var assetComplete = batch.AssetStageComplete || pipeline?.AssetReviewCompletedUtc is not null || assetReview.IsComplete; var assetComplete = pipeline?.AssetReviewCompletedUtc is not null || assetReview.IsComplete;
var relationshipComplete = batch.RelationshipStageComplete || pipeline?.RelationshipReviewCompletedUtc is not null || relationshipReview.IsComplete; var relationshipComplete = pipeline?.RelationshipReviewCompletedUtc is not null || relationshipReview.IsComplete;
var knowledgeComplete = batch.KnowledgeStageComplete || pipeline?.KnowledgeReviewCompletedUtc is not null || knowledgeReview.IsComplete; var knowledgeComplete = pipeline?.KnowledgeReviewCompletedUtc is not null || knowledgeReview.IsComplete;
var narrativeComplete = batch.NarrativeStageComplete || pipeline?.NarrativeReviewCompletedUtc is not null || narrative.IsComplete; var narrativeComplete = batch.NarrativeStageComplete || pipeline?.NarrativeReviewCompletedUtc is not null || narrative.IsComplete;
var narrativePendingCount = narrativeComplete ? 0 : narrative.Candidates.Count; var narrativePendingCount = narrativeComplete ? 0 : narrative.Candidates.Count;
var narrativeThreadCount = narrative.Candidates.Count(candidate => var narrativeThreadCount = narrative.Candidates.Count(candidate =>
@ -750,20 +751,20 @@ public sealed class OnboardingStoryIntelligenceService(
return frame.Model; return frame.Model;
} }
public Task<StoryIntelligenceProgressViewModel?> GetCharacterReviewAsync(Guid batchId) public Task<StoryIntelligenceProgressViewModel?> GetCharacterReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25)
=> GetSelectedReviewAsync(batchId, "Characters", async frame => frame.Model.CharacterReview = await characterImport.BuildReviewAsync(frame.Batch)); => GetSelectedReviewAsync(batchId, "Characters", async frame => frame.Model.CharacterReview = await reviewCandidates.GetCharacterReviewAsync(frame.Batch, pageNumber, pageSize));
public Task<StoryIntelligenceProgressViewModel?> GetLocationReviewAsync(Guid batchId) public Task<StoryIntelligenceProgressViewModel?> GetLocationReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25)
=> GetSelectedReviewAsync(batchId, "Locations", async frame => frame.Model.LocationReview = await locationImport.BuildReviewAsync(frame.Batch)); => GetSelectedReviewAsync(batchId, "Locations", async frame => frame.Model.LocationReview = await reviewCandidates.GetLocationReviewAsync(frame.Batch, pageNumber, pageSize));
public Task<StoryIntelligenceProgressViewModel?> GetAssetReviewAsync(Guid batchId) public Task<StoryIntelligenceProgressViewModel?> GetAssetReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25)
=> GetSelectedReviewAsync(batchId, "Assets", async frame => frame.Model.AssetReview = await assetImport.BuildReviewAsync(frame.Batch)); => GetSelectedReviewAsync(batchId, "Assets", async frame => frame.Model.AssetReview = await reviewCandidates.GetAssetReviewAsync(frame.Batch, pageNumber, pageSize));
public Task<StoryIntelligenceProgressViewModel?> GetRelationshipReviewAsync(Guid batchId) public Task<StoryIntelligenceProgressViewModel?> GetRelationshipReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25)
=> GetSelectedReviewAsync(batchId, "Relationships", async frame => frame.Model.RelationshipReview = await relationshipImport.BuildReviewAsync(frame.Batch)); => GetSelectedReviewAsync(batchId, "Relationships", async frame => frame.Model.RelationshipReview = await reviewCandidates.GetRelationshipReviewAsync(frame.Batch, pageNumber, pageSize));
public Task<StoryIntelligenceProgressViewModel?> GetKnowledgeReviewAsync(Guid batchId) public Task<StoryIntelligenceProgressViewModel?> GetKnowledgeReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25)
=> GetSelectedReviewAsync(batchId, "Knowledge", async frame => frame.Model.KnowledgeReview = await knowledgeImport.BuildReviewAsync(frame.Batch)); => GetSelectedReviewAsync(batchId, "Knowledge", async frame => frame.Model.KnowledgeReview = await reviewCandidates.GetKnowledgeReviewAsync(frame.Batch, pageNumber, pageSize));
public Task<StoryIntelligenceProgressViewModel?> GetNarrativeReviewAsync(Guid batchId) public Task<StoryIntelligenceProgressViewModel?> GetNarrativeReviewAsync(Guid batchId)
=> GetSelectedReviewAsync(batchId, "PlotLinesThreads", async frame => => GetSelectedReviewAsync(batchId, "PlotLinesThreads", async frame =>
@ -956,6 +957,12 @@ public sealed class OnboardingStoryIntelligenceService(
return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." }); return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." });
} }
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
if (batch is null)
{
return (progress, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." });
}
var userId = RequireUserId(); var userId = RequireUserId();
var created = 0; var created = 0;
foreach (var chapter in progress.Chapters.OrderBy(chapter => chapter.ChapterNumber).Where(chapter => chapter.CanCommit)) foreach (var chapter in progress.Chapters.OrderBy(chapter => chapter.ChapterNumber).Where(chapter => chapter.CanCommit))
@ -975,6 +982,7 @@ public sealed class OnboardingStoryIntelligenceService(
} }
await onboarding.MarkCompletedAsync(); await onboarding.MarkCompletedAsync();
await reviewCandidates.PrepareCharacterCandidatesAsync(batch);
return (await GetProgressAsync(batchId), new StoryIntelligenceImportCommitResult return (await GetProgressAsync(batchId), new StoryIntelligenceImportCommitResult
{ {
Success = true, Success = true,
@ -991,7 +999,19 @@ public sealed class OnboardingStoryIntelligenceService(
return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." }); return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." });
} }
var result = await characterImport.ImportAsync(batch, form); var pendingCount = await reviewCandidates.MarkCharacterDecisionsAsync(batch.BookID, form.Characters);
var result = await characterImport.ImportAsync(batch, form, pendingCount == 0);
if (result.Success)
{
if (pendingCount == 0)
{
await reviewCandidates.PreparePeerCandidatesAsync(batch);
}
else
{
result = WithRemainingMessage(result, pendingCount, "character");
}
}
return (await GetReviewCentreAsync(batchId), result); return (await GetReviewCentreAsync(batchId), result);
} }
@ -1025,7 +1045,12 @@ public sealed class OnboardingStoryIntelligenceService(
return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." }); return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." });
} }
var result = await locationImport.ImportAsync(batch, form); var pendingCount = await reviewCandidates.MarkLocationDecisionsAsync(batch.BookID, form.Locations);
var result = await locationImport.ImportAsync(batch, form, pendingCount == 0);
if (result.Success)
{
result = pendingCount == 0 ? result : WithRemainingMessage(result, pendingCount, "location");
}
return (await GetReviewCentreAsync(batchId), result); return (await GetReviewCentreAsync(batchId), result);
} }
@ -1058,7 +1083,12 @@ public sealed class OnboardingStoryIntelligenceService(
return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." }); return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." });
} }
var result = await assetImport.ImportAsync(batch, form); var pendingCount = await reviewCandidates.MarkAssetDecisionsAsync(batch.BookID, form.Assets);
var result = await assetImport.ImportAsync(batch, form, pendingCount == 0);
if (result.Success)
{
result = pendingCount == 0 ? result : WithRemainingMessage(result, pendingCount, "asset");
}
return (await GetReviewCentreAsync(batchId), result); return (await GetReviewCentreAsync(batchId), result);
} }
@ -1092,7 +1122,12 @@ public sealed class OnboardingStoryIntelligenceService(
return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." }); return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." });
} }
var result = await relationshipImport.ImportAsync(batch, form); var pendingCount = await reviewCandidates.MarkRelationshipDecisionsAsync(batch.BookID, form.Relationships);
var result = await relationshipImport.ImportAsync(batch, form, pendingCount == 0);
if (result.Success)
{
result = pendingCount == 0 ? result : WithRemainingMessage(result, pendingCount, "relationship");
}
return (await GetReviewCentreAsync(batchId), result); return (await GetReviewCentreAsync(batchId), result);
} }
@ -1125,7 +1160,12 @@ public sealed class OnboardingStoryIntelligenceService(
return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." }); return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." });
} }
var result = await knowledgeImport.ImportAsync(batch, form); var pendingCount = await reviewCandidates.MarkKnowledgeDecisionsAsync(batch.BookID, form.Knowledge);
var result = await knowledgeImport.ImportAsync(batch, form, pendingCount == 0);
if (result.Success)
{
result = pendingCount == 0 ? result : WithRemainingMessage(result, pendingCount, "knowledge");
}
return (await GetReviewCentreAsync(batchId), result); return (await GetReviewCentreAsync(batchId), result);
} }
@ -1149,6 +1189,19 @@ public sealed class OnboardingStoryIntelligenceService(
}; };
} }
private static StoryIntelligenceImportCommitResult WithRemainingMessage(
StoryIntelligenceImportCommitResult result,
int pendingCount,
string candidateLabel)
=> new()
{
Success = result.Success,
CommitID = result.CommitID,
ScenesCreated = result.ScenesCreated,
MetricsCreated = result.MetricsCreated,
Message = $"{result.Message} {pendingCount:N0} {candidateLabel} candidate(s) remain."
};
public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportNarrativeAsync(Guid batchId, StoryIntelligenceNarrativeImportForm form) public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportNarrativeAsync(Guid batchId, StoryIntelligenceNarrativeImportForm form)
{ {
var batch = await batchStore.GetAsync(RequireUserId(), batchId); var batch = await batchStore.GetAsync(RequireUserId(), batchId);
@ -1170,7 +1223,7 @@ public sealed class OnboardingStoryIntelligenceService(
public async Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId) public async Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId)
{ {
var progress = await GetProgressAsync(batchId); var progress = await GetReviewCentreAsync(batchId);
if (progress is null) if (progress is null)
{ {
return null; return null;

View File

@ -9,7 +9,7 @@ namespace PlotLine.Services;
public interface IStoryIntelligenceAssetImportService public interface IStoryIntelligenceAssetImportService
{ {
Task<StoryIntelligenceAssetReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch); Task<StoryIntelligenceAssetReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch);
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceAssetImportForm form); Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceAssetImportForm form, bool completeReview);
} }
public sealed class StoryIntelligenceAssetImportService( public sealed class StoryIntelligenceAssetImportService(
@ -94,7 +94,7 @@ public sealed class StoryIntelligenceAssetImportService(
}; };
} }
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceAssetImportForm form) public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceAssetImportForm form, bool completeReview)
{ {
var data = await BuildCandidateDataAsync(batch); var data = await BuildCandidateDataAsync(batch);
if (!data.HasCommittedScenes) if (!data.HasCommittedScenes)
@ -109,9 +109,12 @@ public sealed class StoryIntelligenceAssetImportService(
{ {
if (data.Candidates.Count == 0) if (data.Candidates.Count == 0)
{ {
batch.AssetStageComplete = true; batch.AssetStageComplete = completeReview;
batch.LastAssetImportResult = new StoryIntelligenceAssetImportBatchResult(); batch.LastAssetImportResult = new StoryIntelligenceAssetImportBatchResult();
await pipelineState.RecordAssetImportAsync(batch.ProjectID, batch.BookID); if (completeReview)
{
await pipelineState.RecordAssetImportAsync(batch.ProjectID, batch.BookID);
}
return new StoryIntelligenceImportCommitResult return new StoryIntelligenceImportCommitResult
{ {
Success = true, Success = true,
@ -289,7 +292,7 @@ public sealed class StoryIntelligenceAssetImportService(
} }
} }
batch.AssetStageComplete = true; batch.AssetStageComplete = completeReview;
batch.LastAssetImportResult = new StoryIntelligenceAssetImportBatchResult batch.LastAssetImportResult = new StoryIntelligenceAssetImportBatchResult
{ {
AssetsCreated = created, AssetsCreated = created,
@ -299,7 +302,10 @@ public sealed class StoryIntelligenceAssetImportService(
SceneAssetEventsCreated = sceneEventsCreated, SceneAssetEventsCreated = sceneEventsCreated,
OwnershipLinksCreated = ownershipLinksCreated OwnershipLinksCreated = ownershipLinksCreated
}; };
await pipelineState.RecordAssetImportAsync(batch.ProjectID, batch.BookID); if (completeReview)
{
await pipelineState.RecordAssetImportAsync(batch.ProjectID, batch.BookID);
}
logger.LogInformation( logger.LogInformation(
"Imported Story Intelligence assets for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliases={Aliases} Ignored={Ignored} SceneEvents={SceneEvents} Ownership={Ownership}", "Imported Story Intelligence assets for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliases={Aliases} Ignored={Ignored} SceneEvents={SceneEvents} Ownership={Ownership}",

View File

@ -9,7 +9,7 @@ namespace PlotLine.Services;
public interface IStoryIntelligenceCharacterImportService public interface IStoryIntelligenceCharacterImportService
{ {
Task<StoryIntelligenceCharacterReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch); Task<StoryIntelligenceCharacterReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch);
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceCharacterImportForm form); Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceCharacterImportForm form, bool completeReview);
} }
public sealed class StoryIntelligenceCharacterImportService( public sealed class StoryIntelligenceCharacterImportService(
@ -192,7 +192,7 @@ public sealed class StoryIntelligenceCharacterImportService(
return warnings; return warnings;
} }
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceCharacterImportForm form) public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceCharacterImportForm form, bool completeReview)
{ {
var data = await BuildCandidateDataAsync(batch); var data = await BuildCandidateDataAsync(batch);
if (!data.HasCommittedScenes) if (!data.HasCommittedScenes)
@ -373,7 +373,7 @@ public sealed class StoryIntelligenceCharacterImportService(
povLinks++; povLinks++;
} }
batch.CharacterStageComplete = true; batch.CharacterStageComplete = completeReview;
batch.LastCharacterImportResult = new StoryIntelligenceCharacterImportBatchResult batch.LastCharacterImportResult = new StoryIntelligenceCharacterImportBatchResult
{ {
CharactersCreated = created, CharactersCreated = created,
@ -384,8 +384,11 @@ public sealed class StoryIntelligenceCharacterImportService(
ScenePeoplePanelsUpdated = linkedAppearances ScenePeoplePanelsUpdated = linkedAppearances
}; };
var remap = await coreImportCharacterMapping.MapBookAsync(batch.ProjectID, batch.BookID); var remap = await coreImportCharacterMapping.MapBookAsync(batch.ProjectID, batch.BookID);
await pipelineState.RecordCharacterImportAsync(batch.ProjectID, batch.BookID); if (completeReview)
await wholeBookPlotIntelligence.QueueAfterCharacterResolutionAsync(batch.ProjectID, batch.BookID, batch.UserID); {
await pipelineState.RecordCharacterImportAsync(batch.ProjectID, batch.BookID);
await wholeBookPlotIntelligence.QueueAfterCharacterResolutionAsync(batch.ProjectID, batch.BookID, batch.UserID);
}
logger.LogInformation( logger.LogInformation(
"Imported Story Intelligence characters for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliased={Aliased} Ignored={Ignored} LinkedAppearances={LinkedAppearances} PovLinks={PovLinks} RemappedScenes={RemappedScenes} RemappedLinks={RemappedLinks}", "Imported Story Intelligence characters for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliased={Aliased} Ignored={Ignored} LinkedAppearances={LinkedAppearances} PovLinks={PovLinks} RemappedScenes={RemappedScenes} RemappedLinks={RemappedLinks}",

View File

@ -10,7 +10,7 @@ namespace PlotLine.Services;
public interface IStoryIntelligenceKnowledgeImportService public interface IStoryIntelligenceKnowledgeImportService
{ {
Task<StoryIntelligenceKnowledgeReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch); Task<StoryIntelligenceKnowledgeReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch);
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceKnowledgeImportForm form); Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceKnowledgeImportForm form, bool completeReview);
} }
public sealed class StoryIntelligenceKnowledgeImportService( public sealed class StoryIntelligenceKnowledgeImportService(
@ -77,7 +77,7 @@ public sealed class StoryIntelligenceKnowledgeImportService(
}; };
} }
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceKnowledgeImportForm form) public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceKnowledgeImportForm form, bool completeReview)
{ {
var lookupData = await characters.GetLookupsAsync(batch.ProjectID); var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
var data = await BuildCandidateDataAsync(batch, lookupData.KnowledgeStates); var data = await BuildCandidateDataAsync(batch, lookupData.KnowledgeStates);
@ -96,7 +96,7 @@ public sealed class StoryIntelligenceKnowledgeImportService(
var hasUndecidedCandidates = data.Candidates.Any(candidate => !decidedKeys.Contains(candidate.Key)); var hasUndecidedCandidates = data.Candidates.Any(candidate => !decidedKeys.Contains(candidate.Key));
if (!hasUndecidedCandidates) if (!hasUndecidedCandidates)
{ {
batch.KnowledgeStageComplete = true; batch.KnowledgeStageComplete = completeReview;
batch.LastKnowledgeImportResult ??= new StoryIntelligenceKnowledgeImportBatchResult batch.LastKnowledgeImportResult ??= new StoryIntelligenceKnowledgeImportBatchResult
{ {
KnowledgeCreated = batch.KnowledgeDecisions.Count(decision => string.Equals(decision.Action, StoryIntelligenceKnowledgeImportActions.CreateNew, StringComparison.OrdinalIgnoreCase)), KnowledgeCreated = batch.KnowledgeDecisions.Count(decision => string.Equals(decision.Action, StoryIntelligenceKnowledgeImportActions.CreateNew, StringComparison.OrdinalIgnoreCase)),
@ -104,7 +104,10 @@ public sealed class StoryIntelligenceKnowledgeImportService(
KnowledgeMerged = batch.KnowledgeDecisions.Count(decision => string.Equals(decision.Action, StoryIntelligenceKnowledgeImportActions.Alias, StringComparison.OrdinalIgnoreCase)), KnowledgeMerged = batch.KnowledgeDecisions.Count(decision => string.Equals(decision.Action, StoryIntelligenceKnowledgeImportActions.Alias, StringComparison.OrdinalIgnoreCase)),
KnowledgeIgnored = batch.KnowledgeDecisions.Count(decision => string.Equals(decision.Action, StoryIntelligenceKnowledgeImportActions.Ignore, StringComparison.OrdinalIgnoreCase)) KnowledgeIgnored = batch.KnowledgeDecisions.Count(decision => string.Equals(decision.Action, StoryIntelligenceKnowledgeImportActions.Ignore, StringComparison.OrdinalIgnoreCase))
}; };
await pipelineState.RecordKnowledgeImportAsync(batch.ProjectID, batch.BookID); if (completeReview)
{
await pipelineState.RecordKnowledgeImportAsync(batch.ProjectID, batch.BookID);
}
return new StoryIntelligenceImportCommitResult return new StoryIntelligenceImportCommitResult
{ {
Success = true, Success = true,
@ -204,7 +207,7 @@ public sealed class StoryIntelligenceKnowledgeImportService(
AddDecision(batch, candidate.Key, StoryIntelligenceKnowledgeImportActions.Alias, targetKnowledgeId, KnowledgeLabel(candidate), true); AddDecision(batch, candidate.Key, StoryIntelligenceKnowledgeImportActions.Alias, targetKnowledgeId, KnowledgeLabel(candidate), true);
} }
batch.KnowledgeStageComplete = true; batch.KnowledgeStageComplete = completeReview;
batch.LastKnowledgeImportResult = new StoryIntelligenceKnowledgeImportBatchResult batch.LastKnowledgeImportResult = new StoryIntelligenceKnowledgeImportBatchResult
{ {
KnowledgeCreated = created, KnowledgeCreated = created,
@ -212,7 +215,10 @@ public sealed class StoryIntelligenceKnowledgeImportService(
KnowledgeMerged = merged, KnowledgeMerged = merged,
KnowledgeIgnored = ignored KnowledgeIgnored = ignored
}; };
await pipelineState.RecordKnowledgeImportAsync(batch.ProjectID, batch.BookID); if (completeReview)
{
await pipelineState.RecordKnowledgeImportAsync(batch.ProjectID, batch.BookID);
}
logger.LogInformation( logger.LogInformation(
"Imported Story Intelligence knowledge for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Merged={Merged} Ignored={Ignored}", "Imported Story Intelligence knowledge for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Merged={Merged} Ignored={Ignored}",

View File

@ -9,7 +9,7 @@ namespace PlotLine.Services;
public interface IStoryIntelligenceLocationImportService public interface IStoryIntelligenceLocationImportService
{ {
Task<StoryIntelligenceLocationReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch); Task<StoryIntelligenceLocationReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch);
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceLocationImportForm form); Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceLocationImportForm form, bool completeReview);
} }
public sealed class StoryIntelligenceLocationImportService( public sealed class StoryIntelligenceLocationImportService(
@ -124,7 +124,7 @@ public sealed class StoryIntelligenceLocationImportService(
}; };
} }
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceLocationImportForm form) public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceLocationImportForm form, bool completeReview)
{ {
var data = await BuildCandidateDataAsync(batch); var data = await BuildCandidateDataAsync(batch);
if (!data.HasCommittedScenes) if (!data.HasCommittedScenes)
@ -269,7 +269,7 @@ public sealed class StoryIntelligenceLocationImportService(
} }
} }
batch.LocationStageComplete = true; batch.LocationStageComplete = completeReview;
batch.LastLocationImportResult = new StoryIntelligenceLocationImportBatchResult batch.LastLocationImportResult = new StoryIntelligenceLocationImportBatchResult
{ {
LocationsCreated = created, LocationsCreated = created,
@ -278,7 +278,10 @@ public sealed class StoryIntelligenceLocationImportService(
LocationsIgnored = ignored, LocationsIgnored = ignored,
SceneLocationsUpdated = sceneLocationsUpdated SceneLocationsUpdated = sceneLocationsUpdated
}; };
await pipelineState.RecordLocationImportAsync(batch.ProjectID, batch.BookID); if (completeReview)
{
await pipelineState.RecordLocationImportAsync(batch.ProjectID, batch.BookID);
}
logger.LogInformation( logger.LogInformation(
"Imported Story Intelligence locations for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliases={Aliases} Ignored={Ignored} SceneLocationsUpdated={SceneLocationsUpdated}", "Imported Story Intelligence locations for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliases={Aliases} Ignored={Ignored} SceneLocationsUpdated={SceneLocationsUpdated}",

View File

@ -16,6 +16,7 @@ public sealed class StoryIntelligenceMaterialisationService(
ISceneRepository scenes, ISceneRepository scenes,
IChapterRepository chapters, IChapterRepository chapters,
IStoryIntelligenceNarrativeReviewService narrative, IStoryIntelligenceNarrativeReviewService narrative,
IStoryIntelligenceReviewCandidateService reviewCandidates,
ILogger<StoryIntelligenceMaterialisationService> logger) : IStoryIntelligenceMaterialisationService ILogger<StoryIntelligenceMaterialisationService> logger) : IStoryIntelligenceMaterialisationService
{ {
private static readonly JsonSerializerOptions JsonOptions = new() private static readonly JsonSerializerOptions JsonOptions = new()
@ -148,6 +149,8 @@ public sealed class StoryIntelligenceMaterialisationService(
BookTitle = pipeline.BookDisplayTitle, BookTitle = pipeline.BookDisplayTitle,
Items = batchItems Items = batchItems
}; };
await reviewCandidates.PrepareCharacterCandidatesAsync(batch);
await reviewCandidates.PreparePeerCandidatesAsync(batch);
await narrative.EnsureCandidatesAsync(batch); await narrative.EnsureCandidatesAsync(batch);
narrativeCandidatesAfter = (await narrative.ListCandidatesByBookAsync(bookId)).Count; narrativeCandidatesAfter = (await narrative.ListCandidatesByBookAsync(bookId)).Count;
} }

View File

@ -10,7 +10,7 @@ namespace PlotLine.Services;
public interface IStoryIntelligenceRelationshipImportService public interface IStoryIntelligenceRelationshipImportService
{ {
Task<StoryIntelligenceRelationshipReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch); Task<StoryIntelligenceRelationshipReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch);
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceRelationshipImportForm form); Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceRelationshipImportForm form, bool completeReview);
} }
public sealed class StoryIntelligenceRelationshipImportService( public sealed class StoryIntelligenceRelationshipImportService(
@ -74,7 +74,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
}; };
} }
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceRelationshipImportForm form) public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceRelationshipImportForm form, bool completeReview)
{ {
var data = await BuildCandidateDataAsync(batch); var data = await BuildCandidateDataAsync(batch);
if (!data.HasCommittedScenes) if (!data.HasCommittedScenes)
@ -90,9 +90,12 @@ public sealed class StoryIntelligenceRelationshipImportService(
{ {
if (data.Candidates.Count == 0) if (data.Candidates.Count == 0)
{ {
batch.RelationshipStageComplete = true; batch.RelationshipStageComplete = completeReview;
batch.LastRelationshipImportResult = new StoryIntelligenceRelationshipImportBatchResult(); batch.LastRelationshipImportResult = new StoryIntelligenceRelationshipImportBatchResult();
await pipelineState.RecordRelationshipImportAsync(batch.ProjectID, batch.BookID); if (completeReview)
{
await pipelineState.RecordRelationshipImportAsync(batch.ProjectID, batch.BookID);
}
return new StoryIntelligenceImportCommitResult return new StoryIntelligenceImportCommitResult
{ {
Success = true, Success = true,
@ -234,7 +237,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
} }
} }
batch.RelationshipStageComplete = true; batch.RelationshipStageComplete = completeReview;
batch.LastRelationshipImportResult = new StoryIntelligenceRelationshipImportBatchResult batch.LastRelationshipImportResult = new StoryIntelligenceRelationshipImportBatchResult
{ {
RelationshipsCreated = created, RelationshipsCreated = created,
@ -243,7 +246,10 @@ public sealed class StoryIntelligenceRelationshipImportService(
RelationshipsIgnored = ignored, RelationshipsIgnored = ignored,
RelationshipEventsCreated = eventsCreated RelationshipEventsCreated = eventsCreated
}; };
await pipelineState.RecordRelationshipImportAsync(batch.ProjectID, batch.BookID); if (completeReview)
{
await pipelineState.RecordRelationshipImportAsync(batch.ProjectID, batch.BookID);
}
logger.LogInformation( logger.LogInformation(
"Imported Story Intelligence relationships for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Merged={Merged} Ignored={Ignored} Events={Events}", "Imported Story Intelligence relationships for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Merged={Merged} Ignored={Ignored} Events={Events}",

View File

@ -0,0 +1,402 @@
using System.Text.Json;
using Microsoft.AspNetCore.Mvc.Rendering;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface IStoryIntelligenceReviewCandidateService
{
Task PrepareCharacterCandidatesAsync(OnboardingStoryIntelligenceBatch batch);
Task PreparePeerCandidatesAsync(OnboardingStoryIntelligenceBatch batch);
Task<StoryIntelligenceCharacterReviewViewModel> GetCharacterReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize);
Task<StoryIntelligenceLocationReviewViewModel> GetLocationReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize);
Task<StoryIntelligenceAssetReviewViewModel> GetAssetReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize);
Task<StoryIntelligenceRelationshipReviewViewModel> GetRelationshipReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize);
Task<StoryIntelligenceKnowledgeReviewViewModel> GetKnowledgeReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize);
Task<int> MarkCharacterDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceCharacterImportChoiceForm> choices);
Task<int> MarkLocationDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceLocationImportChoiceForm> choices);
Task<int> MarkAssetDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceAssetImportChoiceForm> choices);
Task<int> MarkRelationshipDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceRelationshipImportChoiceForm> choices);
Task<int> MarkKnowledgeDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceKnowledgeImportChoiceForm> choices);
}
public sealed class StoryIntelligenceReviewCandidateService(
IStoryIntelligenceReviewCandidateRepository candidates,
IStoryIntelligenceCharacterImportService characterImport,
IStoryIntelligenceLocationImportService locationImport,
IStoryIntelligenceAssetImportService assetImport,
IStoryIntelligenceRelationshipImportService relationshipImport,
IStoryIntelligenceKnowledgeImportService knowledgeImport,
ICharacterRepository characters,
ILogger<StoryIntelligenceReviewCandidateService> logger) : IStoryIntelligenceReviewCandidateService
{
private const int MaxPageSize = 100;
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public async Task PrepareCharacterCandidatesAsync(OnboardingStoryIntelligenceBatch batch)
{
var review = await characterImport.BuildReviewAsync(batch);
await PersistAsync(
batch,
StoryIntelligenceReviewModules.Characters,
review.Candidates,
candidate => candidate.Key,
candidate => candidate.CharacterName,
candidate => candidate.Classification,
candidate => candidate.ExistingCharacterID,
candidate => candidate.PossibleAliases.Take(5));
}
public async Task PreparePeerCandidatesAsync(OnboardingStoryIntelligenceBatch batch)
{
await PersistAsync(
batch,
StoryIntelligenceReviewModules.Locations,
(await locationImport.BuildReviewAsync(batch)).Candidates,
candidate => candidate.Key,
candidate => candidate.LocationName,
candidate => candidate.Category,
candidate => candidate.ExistingLocationID,
candidate => candidate.EvidenceSummaries.Take(5));
await PersistAsync(
batch,
StoryIntelligenceReviewModules.Assets,
(await assetImport.BuildReviewAsync(batch)).Candidates,
candidate => candidate.Key,
candidate => candidate.AssetName,
candidate => candidate.Category,
candidate => candidate.ExistingAssetID,
candidate => candidate.PossibleAliases.Take(5));
await PersistAsync(
batch,
StoryIntelligenceReviewModules.Relationships,
(await relationshipImport.BuildReviewAsync(batch)).Candidates,
candidate => candidate.Key,
candidate => $"{candidate.CharacterAName} / {candidate.CharacterBName}",
candidate => candidate.RelationshipType,
candidate => candidate.ExistingRelationshipID,
candidate => new[] { candidate.ExampleScene, candidate.ExampleContext }.Where(value => !string.IsNullOrWhiteSpace(value))!);
await PersistAsync(
batch,
StoryIntelligenceReviewModules.Knowledge,
(await knowledgeImport.BuildReviewAsync(batch)).Candidates,
candidate => candidate.Key,
candidate => candidate.KnowledgeStatement,
candidate => candidate.KnowledgeStateName,
candidate => candidate.ExistingKnowledgeID,
candidate => new[] { candidate.FirstScene, candidate.ExampleScene, candidate.Evidence }.Where(value => !string.IsNullOrWhiteSpace(value))!);
}
public async Task<StoryIntelligenceCharacterReviewViewModel> GetCharacterReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize)
{
var page = await LoadPageAsync<StoryIntelligenceCharacterReviewCandidateViewModel>(batch.BookID, StoryIntelligenceReviewModules.Characters, pageNumber, pageSize);
var existingOptions = (await characters.ListCharactersAsync(batch.ProjectID))
.Select(character => new StoryIntelligenceCharacterExistingOptionViewModel
{
CharacterID = character.CharacterID,
CharacterName = character.CharacterName
})
.OrderBy(character => character.CharacterName, StringComparer.OrdinalIgnoreCase)
.ToList();
var candidatesOnPage = page.Items
.Select(candidate => new StoryIntelligenceCharacterReviewCandidateViewModel
{
Key = candidate.Key,
CharacterName = candidate.CharacterName,
ImportName = candidate.ImportName,
DefaultAction = candidate.DefaultAction,
Classification = candidate.Classification,
AppearsInScenes = candidate.AppearsInScenes,
Confidence = candidate.Confidence,
PossibleAliases = candidate.PossibleAliases,
ExampleFirstAppearance = candidate.ExampleFirstAppearance,
NameIntelligenceWarning = candidate.NameIntelligenceWarning,
ExistingCharacterID = candidate.ExistingCharacterID,
ExistingCharacterName = candidate.ExistingCharacterName,
SelectedExistingCharacterID = candidate.SelectedExistingCharacterID,
ExistingCharacterOptions = existingOptions
})
.ToList();
return new StoryIntelligenceCharacterReviewViewModel
{
HasCommittedScenes = true,
CanImport = candidatesOnPage.Count > 0,
IsComplete = page.GenerationReady && page.PendingCount == 0,
SummaryNeedsAttentionCount = page.PendingCount,
PageNumber = page.PageNumber,
PageSize = page.PageSize,
TotalCandidateCount = page.TotalCount,
PendingCandidateCount = page.PendingCount,
GenericReferenceCount = candidatesOnPage.Count(candidate => candidate.Classification == StoryIntelligenceCharacterClassifications.GenericReference),
ExistingMatchCount = candidatesOnPage.Count(candidate => candidate.Classification == StoryIntelligenceCharacterClassifications.ExistingCharacter),
NewCandidateCount = candidatesOnPage.Count(candidate => candidate.Classification is StoryIntelligenceCharacterClassifications.NewCharacter or StoryIntelligenceCharacterClassifications.NewAlias),
AmbiguousCount = candidatesOnPage.Count(candidate => candidate.Classification == StoryIntelligenceCharacterClassifications.Ambiguous),
AliasTargetOptions = existingOptions,
Candidates = candidatesOnPage
};
}
public async Task<StoryIntelligenceLocationReviewViewModel> GetLocationReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize)
{
var page = await LoadPageAsync<StoryIntelligenceLocationReviewCandidateViewModel>(batch.BookID, StoryIntelligenceReviewModules.Locations, pageNumber, pageSize);
return new StoryIntelligenceLocationReviewViewModel
{
HasCommittedScenes = true,
CanImport = page.Items.Count > 0,
IsComplete = page.GenerationReady && page.PendingCount == 0,
SummaryNeedsAttentionCount = page.PendingCount,
PageNumber = page.PageNumber,
PageSize = page.PageSize,
TotalCandidateCount = page.TotalCount,
PendingCandidateCount = page.PendingCount,
Candidates = page.Items
};
}
public async Task<StoryIntelligenceAssetReviewViewModel> GetAssetReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize)
{
var page = await LoadPageAsync<StoryIntelligenceAssetReviewCandidateViewModel>(batch.BookID, StoryIntelligenceReviewModules.Assets, pageNumber, pageSize);
return new StoryIntelligenceAssetReviewViewModel
{
HasCommittedScenes = true,
CanImport = page.Items.Count > 0,
IsComplete = page.GenerationReady && page.PendingCount == 0,
SummaryNeedsAttentionCount = page.PendingCount,
PageNumber = page.PageNumber,
PageSize = page.PageSize,
TotalCandidateCount = page.TotalCount,
PendingCandidateCount = page.PendingCount,
Candidates = page.Items
};
}
public async Task<StoryIntelligenceRelationshipReviewViewModel> GetRelationshipReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize)
{
var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
var page = await LoadPageAsync<StoryIntelligenceRelationshipReviewCandidateViewModel>(batch.BookID, StoryIntelligenceReviewModules.Relationships, pageNumber, pageSize);
return new StoryIntelligenceRelationshipReviewViewModel
{
HasCommittedScenes = true,
CanImport = page.Items.Count > 0,
IsComplete = page.GenerationReady && page.PendingCount == 0,
SummaryNeedsAttentionCount = page.PendingCount,
PageNumber = page.PageNumber,
PageSize = page.PageSize,
TotalCandidateCount = page.TotalCount,
PendingCandidateCount = page.PendingCount,
RelationshipTypeOptions = lookupData.RelationshipTypes
.OrderBy(type => type.SortOrder)
.ThenBy(type => type.TypeName)
.Select(type => new SelectListItem(type.TypeName, type.RelationshipTypeID.ToString()))
.ToList(),
Candidates = page.Items
};
}
public async Task<StoryIntelligenceKnowledgeReviewViewModel> GetKnowledgeReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize)
{
var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
var page = await LoadPageAsync<StoryIntelligenceKnowledgeReviewCandidateViewModel>(batch.BookID, StoryIntelligenceReviewModules.Knowledge, pageNumber, pageSize);
return new StoryIntelligenceKnowledgeReviewViewModel
{
HasCommittedScenes = true,
CanImport = page.Items.Count > 0,
IsComplete = page.GenerationReady && page.PendingCount == 0,
SummaryNeedsAttentionCount = page.PendingCount,
PageNumber = page.PageNumber,
PageSize = page.PageSize,
TotalCandidateCount = page.TotalCount,
PendingCandidateCount = page.PendingCount,
KnowledgeStateOptions = lookupData.KnowledgeStates
.OrderBy(state => state.SortOrder)
.ThenBy(state => state.StateName)
.Select(state => new SelectListItem(state.StateName, state.KnowledgeStateID.ToString()))
.ToList(),
Candidates = page.Items
};
}
public async Task<int> MarkCharacterDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceCharacterImportChoiceForm> choices)
{
StoryIntelligenceReviewCandidateGeneration? generation = null;
foreach (var choice in choices.Where(choice => !string.IsNullOrWhiteSpace(choice.Key)))
{
generation = await candidates.MarkReviewedAsync(new StoryIntelligenceReviewCandidateDecisionRequest
{
BookID = bookId,
Module = StoryIntelligenceReviewModules.Characters,
StableKey = choice.Key,
DecisionAction = choice.Action,
DecisionTargetKey = choice.AliasTargetKey,
DecisionCanonicalID = choice.ExistingCharacterID ?? choice.AliasTargetCharacterID
});
}
return generation?.PendingCount ?? (await candidates.GetCurrentGenerationAsync(bookId, StoryIntelligenceReviewModules.Characters))?.PendingCount ?? 0;
}
public async Task<int> MarkLocationDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceLocationImportChoiceForm> choices)
{
StoryIntelligenceReviewCandidateGeneration? generation = null;
foreach (var choice in choices.Where(choice => !string.IsNullOrWhiteSpace(choice.Key)))
{
generation = await candidates.MarkReviewedAsync(new StoryIntelligenceReviewCandidateDecisionRequest
{
BookID = bookId,
Module = StoryIntelligenceReviewModules.Locations,
StableKey = choice.Key,
DecisionAction = choice.Action,
DecisionTargetKey = choice.AliasTargetKey
});
}
return generation?.PendingCount ?? (await candidates.GetCurrentGenerationAsync(bookId, StoryIntelligenceReviewModules.Locations))?.PendingCount ?? 0;
}
public async Task<int> MarkAssetDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceAssetImportChoiceForm> choices)
{
StoryIntelligenceReviewCandidateGeneration? generation = null;
foreach (var choice in choices.Where(choice => !string.IsNullOrWhiteSpace(choice.Key)))
{
generation = await candidates.MarkReviewedAsync(new StoryIntelligenceReviewCandidateDecisionRequest
{
BookID = bookId,
Module = StoryIntelligenceReviewModules.Assets,
StableKey = choice.Key,
DecisionAction = choice.Action,
DecisionTargetKey = choice.AliasTargetKey
});
}
return generation?.PendingCount ?? (await candidates.GetCurrentGenerationAsync(bookId, StoryIntelligenceReviewModules.Assets))?.PendingCount ?? 0;
}
public async Task<int> MarkRelationshipDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceRelationshipImportChoiceForm> choices)
{
StoryIntelligenceReviewCandidateGeneration? generation = null;
foreach (var choice in choices.Where(choice => !string.IsNullOrWhiteSpace(choice.Key)))
{
generation = await candidates.MarkReviewedAsync(new StoryIntelligenceReviewCandidateDecisionRequest
{
BookID = bookId,
Module = StoryIntelligenceReviewModules.Relationships,
StableKey = choice.Key,
DecisionAction = choice.Action,
DecisionTargetKey = choice.AliasTargetKey,
DecisionCanonicalID = choice.RelationshipTypeID
});
}
return generation?.PendingCount ?? (await candidates.GetCurrentGenerationAsync(bookId, StoryIntelligenceReviewModules.Relationships))?.PendingCount ?? 0;
}
public async Task<int> MarkKnowledgeDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceKnowledgeImportChoiceForm> choices)
{
StoryIntelligenceReviewCandidateGeneration? generation = null;
foreach (var choice in choices.Where(choice => !string.IsNullOrWhiteSpace(choice.Key)))
{
generation = await candidates.MarkReviewedAsync(new StoryIntelligenceReviewCandidateDecisionRequest
{
BookID = bookId,
Module = StoryIntelligenceReviewModules.Knowledge,
StableKey = choice.Key,
DecisionAction = choice.Action,
DecisionTargetKey = choice.AliasTargetKey,
DecisionCanonicalID = choice.KnowledgeStateID
});
}
return generation?.PendingCount ?? (await candidates.GetCurrentGenerationAsync(bookId, StoryIntelligenceReviewModules.Knowledge))?.PendingCount ?? 0;
}
private async Task PersistAsync<TCandidate>(
OnboardingStoryIntelligenceBatch batch,
string module,
IReadOnlyList<TCandidate> sourceCandidates,
Func<TCandidate, string> stableKey,
Func<TCandidate, string> displayName,
Func<TCandidate, string?> candidateType,
Func<TCandidate, int?> existingCanonicalId,
Func<TCandidate, IEnumerable<string?>> evidence)
{
var generation = await candidates.BeginGenerationAsync(batch.ProjectID, batch.BookID, module, "SceneIntelligencePreparation");
try
{
var sortOrder = 0;
foreach (var candidate in sourceCandidates)
{
sortOrder++;
var row = await candidates.SaveCandidateAsync(new StoryIntelligenceReviewCandidateSaveRequest
{
ReviewCandidateGenerationID = generation.ReviewCandidateGenerationID,
ProjectID = batch.ProjectID,
BookID = batch.BookID,
Module = module,
StableKey = stableKey(candidate),
CandidateType = candidateType(candidate),
DisplayName = displayName(candidate),
SortOrder = sortOrder,
ExistingCanonicalID = existingCanonicalId(candidate),
CandidatePayloadJson = JsonSerializer.Serialize(candidate, JsonOptions)
});
var evidenceOrder = 0;
foreach (var item in evidence(candidate).Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.OrdinalIgnoreCase).Take(5))
{
evidenceOrder++;
await candidates.SaveEvidenceAsync(row.ReviewCandidateID, null, item!, null, evidenceOrder);
}
}
await candidates.CompleteGenerationAsync(generation.ReviewCandidateGenerationID);
logger.LogInformation(
"Prepared persisted Story Intelligence review candidates. BatchID={BatchID} ProjectID={ProjectID} BookID={BookID} Module={Module} Count={Count}",
batch.BatchID,
batch.ProjectID,
batch.BookID,
module,
sourceCandidates.Count);
}
catch (Exception ex)
{
await candidates.FailGenerationAsync(generation.ReviewCandidateGenerationID, ex.Message);
logger.LogError(ex, "Persisted Story Intelligence review candidate preparation failed. BatchID={BatchID} BookID={BookID} Module={Module}", batch.BatchID, batch.BookID, module);
throw;
}
}
private async Task<CandidatePage<TCandidate>> LoadPageAsync<TCandidate>(int bookId, string module, int pageNumber, int pageSize)
{
pageNumber = Math.Max(1, pageNumber);
pageSize = Math.Clamp(pageSize <= 0 ? 25 : pageSize, 1, MaxPageSize);
var generation = await candidates.GetCurrentGenerationAsync(bookId, module);
if (generation is null || !string.Equals(generation.Status, StoryIntelligenceReviewCandidateGenerationStatuses.Ready, StringComparison.OrdinalIgnoreCase))
{
return new CandidatePage<TCandidate>([], pageNumber, pageSize, 0, 0, false);
}
var rows = await candidates.ListPageAsync(bookId, module, pageNumber, pageSize);
var items = rows.Select(row => JsonSerializer.Deserialize<TCandidate>(row.CandidatePayloadJson, JsonOptions))
.Where(item => item is not null)
.Select(item => item!)
.ToList();
var totalCount = rows.FirstOrDefault()?.TotalCount ?? generation.CandidateCount;
var pendingCount = rows.FirstOrDefault()?.PendingCount ?? generation.PendingCount;
return new CandidatePage<TCandidate>(items, pageNumber, pageSize, totalCount, pendingCount, true);
}
private sealed record CandidatePage<TCandidate>(
IReadOnlyList<TCandidate> Items,
int PageNumber,
int PageSize,
int TotalCount,
int PendingCount,
bool GenerationReady);
}

View File

@ -0,0 +1,330 @@
IF OBJECT_ID(N'dbo.StoryIntelligenceReviewCandidateGenerations', N'U') IS NULL
BEGIN
CREATE TABLE dbo.StoryIntelligenceReviewCandidateGenerations
(
ReviewCandidateGenerationID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryIntelligenceReviewCandidateGenerations PRIMARY KEY,
ProjectID int NOT NULL,
BookID int NOT NULL,
Module nvarchar(40) NOT NULL,
Status nvarchar(30) NOT NULL CONSTRAINT DF_StoryIntelligenceReviewCandidateGenerations_Status DEFAULT N'Preparing',
Source nvarchar(80) NOT NULL CONSTRAINT DF_StoryIntelligenceReviewCandidateGenerations_Source DEFAULT N'SceneIntelligencePreparation',
CandidateCount int NOT NULL CONSTRAINT DF_StoryIntelligenceReviewCandidateGenerations_CandidateCount DEFAULT 0,
PendingCount int NOT NULL CONSTRAINT DF_StoryIntelligenceReviewCandidateGenerations_PendingCount DEFAULT 0,
ErrorMessage nvarchar(1000) NULL,
IsCurrent bit NOT NULL CONSTRAINT DF_StoryIntelligenceReviewCandidateGenerations_IsCurrent DEFAULT 1,
CreatedUtc datetime2 NOT NULL CONSTRAINT DF_StoryIntelligenceReviewCandidateGenerations_CreatedUtc DEFAULT SYSUTCDATETIME(),
PreparedUtc datetime2 NULL,
FailedUtc datetime2 NULL,
UpdatedUtc datetime2 NOT NULL CONSTRAINT DF_StoryIntelligenceReviewCandidateGenerations_UpdatedUtc DEFAULT SYSUTCDATETIME(),
CONSTRAINT FK_StoryIntelligenceReviewCandidateGenerations_Projects FOREIGN KEY (ProjectID) REFERENCES dbo.Projects(ProjectID),
CONSTRAINT FK_StoryIntelligenceReviewCandidateGenerations_Books FOREIGN KEY (BookID) REFERENCES dbo.Books(BookID) ON DELETE CASCADE
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE object_id = OBJECT_ID(N'dbo.StoryIntelligenceReviewCandidateGenerations') AND name = N'UX_StoryIntelligenceReviewCandidateGenerations_Current')
CREATE UNIQUE INDEX UX_StoryIntelligenceReviewCandidateGenerations_Current
ON dbo.StoryIntelligenceReviewCandidateGenerations(BookID, Module)
WHERE IsCurrent = 1;
GO
IF OBJECT_ID(N'dbo.StoryIntelligenceReviewCandidates', N'U') IS NULL
BEGIN
CREATE TABLE dbo.StoryIntelligenceReviewCandidates
(
ReviewCandidateID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryIntelligenceReviewCandidates PRIMARY KEY,
ReviewCandidateGenerationID int NOT NULL,
ProjectID int NOT NULL,
BookID int NOT NULL,
Module nvarchar(40) NOT NULL,
StableKey nvarchar(450) NOT NULL,
CandidateType nvarchar(80) NULL,
DisplayName nvarchar(450) NOT NULL,
SortOrder int NOT NULL CONSTRAINT DF_StoryIntelligenceReviewCandidates_SortOrder DEFAULT 0,
Confidence decimal(9,4) NULL,
ExistingCanonicalID int NULL,
DecisionStatus nvarchar(30) NOT NULL CONSTRAINT DF_StoryIntelligenceReviewCandidates_DecisionStatus DEFAULT N'Pending',
DecisionAction nvarchar(40) NULL,
DecisionTargetKey nvarchar(450) NULL,
DecisionCanonicalID int NULL,
CandidatePayloadJson nvarchar(max) NOT NULL,
CreatedUtc datetime2 NOT NULL CONSTRAINT DF_StoryIntelligenceReviewCandidates_CreatedUtc DEFAULT SYSUTCDATETIME(),
ReviewedUtc datetime2 NULL,
UpdatedUtc datetime2 NOT NULL CONSTRAINT DF_StoryIntelligenceReviewCandidates_UpdatedUtc DEFAULT SYSUTCDATETIME(),
CONSTRAINT FK_StoryIntelligenceReviewCandidates_Generation FOREIGN KEY (ReviewCandidateGenerationID) REFERENCES dbo.StoryIntelligenceReviewCandidateGenerations(ReviewCandidateGenerationID) ON DELETE CASCADE
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE object_id = OBJECT_ID(N'dbo.StoryIntelligenceReviewCandidates') AND name = N'UX_StoryIntelligenceReviewCandidates_Generation_Key')
CREATE UNIQUE INDEX UX_StoryIntelligenceReviewCandidates_Generation_Key
ON dbo.StoryIntelligenceReviewCandidates(ReviewCandidateGenerationID, StableKey);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE object_id = OBJECT_ID(N'dbo.StoryIntelligenceReviewCandidates') AND name = N'IX_StoryIntelligenceReviewCandidates_Page')
CREATE INDEX IX_StoryIntelligenceReviewCandidates_Page
ON dbo.StoryIntelligenceReviewCandidates(BookID, Module, DecisionStatus, SortOrder, ReviewCandidateID)
INCLUDE (ReviewCandidateGenerationID, DisplayName, ExistingCanonicalID);
GO
IF OBJECT_ID(N'dbo.StoryIntelligenceReviewCandidateEvidence', N'U') IS NULL
BEGIN
CREATE TABLE dbo.StoryIntelligenceReviewCandidateEvidence
(
ReviewCandidateEvidenceID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryIntelligenceReviewCandidateEvidence PRIMARY KEY,
ReviewCandidateID int NOT NULL,
SceneID int NULL,
EvidenceText nvarchar(1000) NOT NULL,
Confidence decimal(9,4) NULL,
SortOrder int NOT NULL CONSTRAINT DF_StoryIntelligenceReviewCandidateEvidence_SortOrder DEFAULT 0,
CreatedUtc datetime2 NOT NULL CONSTRAINT DF_StoryIntelligenceReviewCandidateEvidence_CreatedUtc DEFAULT SYSUTCDATETIME(),
CONSTRAINT FK_StoryIntelligenceReviewCandidateEvidence_Candidate FOREIGN KEY (ReviewCandidateID) REFERENCES dbo.StoryIntelligenceReviewCandidates(ReviewCandidateID) ON DELETE CASCADE,
CONSTRAINT FK_StoryIntelligenceReviewCandidateEvidence_Scene FOREIGN KEY (SceneID) REFERENCES dbo.Scenes(SceneID) ON DELETE SET NULL
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE object_id = OBJECT_ID(N'dbo.StoryIntelligenceReviewCandidateEvidence') AND name = N'IX_StoryIntelligenceReviewCandidateEvidence_Candidate')
CREATE INDEX IX_StoryIntelligenceReviewCandidateEvidence_Candidate
ON dbo.StoryIntelligenceReviewCandidateEvidence(ReviewCandidateID, SortOrder);
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceReviewCandidateGeneration_Begin
@ProjectID int,
@BookID int,
@Module nvarchar(40),
@Source nvarchar(80) = N'SceneIntelligencePreparation'
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.StoryIntelligenceReviewCandidateGenerations
SET IsCurrent = 0,
UpdatedUtc = SYSUTCDATETIME()
WHERE BookID = @BookID
AND Module = @Module
AND IsCurrent = 1;
INSERT dbo.StoryIntelligenceReviewCandidateGenerations (ProjectID, BookID, Module, Status, Source)
VALUES (@ProjectID, @BookID, @Module, N'Preparing', @Source);
SELECT *
FROM dbo.StoryIntelligenceReviewCandidateGenerations
WHERE ReviewCandidateGenerationID = CAST(SCOPE_IDENTITY() AS int);
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceReviewCandidate_Save
@ReviewCandidateGenerationID int,
@ProjectID int,
@BookID int,
@Module nvarchar(40),
@StableKey nvarchar(450),
@CandidateType nvarchar(80) = NULL,
@DisplayName nvarchar(450),
@SortOrder int,
@Confidence decimal(9,4) = NULL,
@ExistingCanonicalID int = NULL,
@CandidatePayloadJson nvarchar(max)
AS
BEGIN
SET NOCOUNT ON;
MERGE dbo.StoryIntelligenceReviewCandidates AS target
USING (SELECT @ReviewCandidateGenerationID AS ReviewCandidateGenerationID, @StableKey AS StableKey) AS source
ON target.ReviewCandidateGenerationID = source.ReviewCandidateGenerationID
AND target.StableKey = source.StableKey
WHEN MATCHED THEN
UPDATE SET CandidateType = @CandidateType,
DisplayName = @DisplayName,
SortOrder = @SortOrder,
Confidence = @Confidence,
ExistingCanonicalID = @ExistingCanonicalID,
CandidatePayloadJson = @CandidatePayloadJson,
UpdatedUtc = SYSUTCDATETIME()
WHEN NOT MATCHED THEN
INSERT (ReviewCandidateGenerationID, ProjectID, BookID, Module, StableKey, CandidateType, DisplayName, SortOrder, Confidence, ExistingCanonicalID, CandidatePayloadJson)
VALUES (@ReviewCandidateGenerationID, @ProjectID, @BookID, @Module, @StableKey, @CandidateType, @DisplayName, @SortOrder, @Confidence, @ExistingCanonicalID, @CandidatePayloadJson);
SELECT *
FROM dbo.StoryIntelligenceReviewCandidates
WHERE ReviewCandidateGenerationID = @ReviewCandidateGenerationID
AND StableKey = @StableKey;
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceReviewCandidateEvidence_Save
@ReviewCandidateID int,
@SceneID int = NULL,
@EvidenceText nvarchar(1000),
@Confidence decimal(9,4) = NULL,
@SortOrder int
AS
BEGIN
SET NOCOUNT ON;
INSERT dbo.StoryIntelligenceReviewCandidateEvidence (ReviewCandidateID, SceneID, EvidenceText, Confidence, SortOrder)
VALUES (@ReviewCandidateID, @SceneID, @EvidenceText, @Confidence, @SortOrder);
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceReviewCandidateGeneration_Complete
@ReviewCandidateGenerationID int
AS
BEGIN
SET NOCOUNT ON;
UPDATE generation
SET Status = N'Ready',
CandidateCount = counts.CandidateCount,
PendingCount = counts.PendingCount,
PreparedUtc = SYSUTCDATETIME(),
FailedUtc = NULL,
ErrorMessage = NULL,
UpdatedUtc = SYSUTCDATETIME()
FROM dbo.StoryIntelligenceReviewCandidateGenerations generation
CROSS APPLY
(
SELECT COUNT(*) AS CandidateCount,
ISNULL(SUM(CASE WHEN candidate.DecisionStatus = N'Pending' THEN 1 ELSE 0 END), 0) AS PendingCount
FROM dbo.StoryIntelligenceReviewCandidates candidate
WHERE candidate.ReviewCandidateGenerationID = generation.ReviewCandidateGenerationID
) counts
WHERE generation.ReviewCandidateGenerationID = @ReviewCandidateGenerationID;
SELECT *
FROM dbo.StoryIntelligenceReviewCandidateGenerations
WHERE ReviewCandidateGenerationID = @ReviewCandidateGenerationID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceReviewCandidateGeneration_Fail
@ReviewCandidateGenerationID int,
@ErrorMessage nvarchar(1000)
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.StoryIntelligenceReviewCandidateGenerations
SET Status = N'Failed',
ErrorMessage = @ErrorMessage,
FailedUtc = SYSUTCDATETIME(),
UpdatedUtc = SYSUTCDATETIME()
WHERE ReviewCandidateGenerationID = @ReviewCandidateGenerationID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceReviewCandidateGeneration_GetCurrent
@BookID int,
@Module nvarchar(40)
AS
BEGIN
SET NOCOUNT ON;
SELECT TOP (1) *
FROM dbo.StoryIntelligenceReviewCandidateGenerations
WHERE BookID = @BookID
AND Module = @Module
AND IsCurrent = 1
ORDER BY ReviewCandidateGenerationID DESC;
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceReviewCandidate_ListPage
@BookID int,
@Module nvarchar(40),
@PageNumber int = 1,
@PageSize int = 25
AS
BEGIN
SET NOCOUNT ON;
IF @PageNumber < 1 SET @PageNumber = 1;
IF @PageSize < 1 SET @PageSize = 25;
IF @PageSize > 100 SET @PageSize = 100;
DECLARE @GenerationID int;
SELECT TOP (1) @GenerationID = ReviewCandidateGenerationID
FROM dbo.StoryIntelligenceReviewCandidateGenerations
WHERE BookID = @BookID
AND Module = @Module
AND IsCurrent = 1
AND Status = N'Ready'
ORDER BY ReviewCandidateGenerationID DESC;
;WITH CurrentCandidates AS
(
SELECT candidate.*,
COUNT(*) OVER () AS TotalCount,
SUM(CASE WHEN candidate.DecisionStatus = N'Pending' THEN 1 ELSE 0 END) OVER () AS PendingCount,
ROW_NUMBER() OVER (ORDER BY candidate.SortOrder, candidate.ReviewCandidateID) AS RowNumber
FROM dbo.StoryIntelligenceReviewCandidates candidate
WHERE candidate.ReviewCandidateGenerationID = @GenerationID
AND candidate.DecisionStatus = N'Pending'
)
SELECT *
FROM CurrentCandidates
WHERE RowNumber BETWEEN ((@PageNumber - 1) * @PageSize) + 1 AND @PageNumber * @PageSize
ORDER BY RowNumber;
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceReviewCandidate_MarkReviewed
@BookID int,
@Module nvarchar(40),
@StableKey nvarchar(450),
@DecisionAction nvarchar(40),
@DecisionTargetKey nvarchar(450) = NULL,
@DecisionCanonicalID int = NULL
AS
BEGIN
SET NOCOUNT ON;
DECLARE @GenerationID int;
SELECT TOP (1) @GenerationID = ReviewCandidateGenerationID
FROM dbo.StoryIntelligenceReviewCandidateGenerations
WHERE BookID = @BookID
AND Module = @Module
AND IsCurrent = 1
ORDER BY ReviewCandidateGenerationID DESC;
UPDATE dbo.StoryIntelligenceReviewCandidates
SET DecisionStatus = N'Reviewed',
DecisionAction = @DecisionAction,
DecisionTargetKey = @DecisionTargetKey,
DecisionCanonicalID = @DecisionCanonicalID,
ReviewedUtc = SYSUTCDATETIME(),
UpdatedUtc = SYSUTCDATETIME()
WHERE ReviewCandidateGenerationID = @GenerationID
AND StableKey = @StableKey;
UPDATE generation
SET PendingCount = counts.PendingCount,
UpdatedUtc = SYSUTCDATETIME()
FROM dbo.StoryIntelligenceReviewCandidateGenerations generation
CROSS APPLY
(
SELECT ISNULL(SUM(CASE WHEN candidate.DecisionStatus = N'Pending' THEN 1 ELSE 0 END), 0) AS PendingCount
FROM dbo.StoryIntelligenceReviewCandidates candidate
WHERE candidate.ReviewCandidateGenerationID = generation.ReviewCandidateGenerationID
) counts
WHERE generation.ReviewCandidateGenerationID = @GenerationID;
SELECT *
FROM dbo.StoryIntelligenceReviewCandidateGenerations
WHERE ReviewCandidateGenerationID = @GenerationID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceReviewCandidate_ResetBook
@BookID int
AS
BEGIN
SET NOCOUNT ON;
DELETE dbo.StoryIntelligenceReviewCandidateGenerations
WHERE BookID = @BookID;
END;
GO

View File

@ -368,6 +368,12 @@ public sealed class StoryIntelligenceCharacterReviewViewModel
public int ExistingMatchCount { get; init; } public int ExistingMatchCount { get; init; }
public int NewCandidateCount { get; init; } public int NewCandidateCount { get; init; }
public int AmbiguousCount { get; init; } public int AmbiguousCount { get; init; }
public int PageNumber { get; init; } = 1;
public int PageSize { get; init; } = 25;
public int TotalCandidateCount { get; init; }
public int PendingCandidateCount { get; init; }
public bool HasPreviousPage => PageNumber > 1;
public bool HasNextPage => PageNumber * PageSize < PendingCandidateCount;
public IReadOnlyList<StoryIntelligenceCharacterReviewCandidateViewModel> Candidates { get; init; } = []; public IReadOnlyList<StoryIntelligenceCharacterReviewCandidateViewModel> Candidates { get; init; } = [];
public IReadOnlyList<StoryIntelligenceCharacterExistingOptionViewModel> AliasTargetOptions { get; init; } = []; public IReadOnlyList<StoryIntelligenceCharacterExistingOptionViewModel> AliasTargetOptions { get; init; } = [];
} }
@ -440,6 +446,12 @@ public sealed class StoryIntelligenceLocationReviewViewModel
public int AlreadyLinkedCount { get; init; } public int AlreadyLinkedCount { get; init; }
public bool IsComplete { get; init; } public bool IsComplete { get; init; }
public int? SummaryNeedsAttentionCount { get; init; } public int? SummaryNeedsAttentionCount { get; init; }
public int PageNumber { get; init; } = 1;
public int PageSize { get; init; } = 25;
public int TotalCandidateCount { get; init; }
public int PendingCandidateCount { get; init; }
public bool HasPreviousPage => PageNumber > 1;
public bool HasNextPage => PageNumber * PageSize < PendingCandidateCount;
public IReadOnlyList<StoryIntelligenceLocationReviewCandidateViewModel> Candidates { get; init; } = []; public IReadOnlyList<StoryIntelligenceLocationReviewCandidateViewModel> Candidates { get; init; } = [];
} }
@ -490,6 +502,12 @@ public sealed class StoryIntelligenceAssetReviewViewModel
public int AlreadyLinkedCount { get; init; } public int AlreadyLinkedCount { get; init; }
public bool IsComplete { get; init; } public bool IsComplete { get; init; }
public int? SummaryNeedsAttentionCount { get; init; } public int? SummaryNeedsAttentionCount { get; init; }
public int PageNumber { get; init; } = 1;
public int PageSize { get; init; } = 25;
public int TotalCandidateCount { get; init; }
public int PendingCandidateCount { get; init; }
public bool HasPreviousPage => PageNumber > 1;
public bool HasNextPage => PageNumber * PageSize < PendingCandidateCount;
public IReadOnlyList<StoryIntelligenceAssetReviewCandidateViewModel> Candidates { get; init; } = []; public IReadOnlyList<StoryIntelligenceAssetReviewCandidateViewModel> Candidates { get; init; } = [];
} }
@ -540,6 +558,12 @@ public sealed class StoryIntelligenceRelationshipReviewViewModel
public int AlreadyLinkedCount { get; init; } public int AlreadyLinkedCount { get; init; }
public bool IsComplete { get; init; } public bool IsComplete { get; init; }
public int? SummaryNeedsAttentionCount { get; init; } public int? SummaryNeedsAttentionCount { get; init; }
public int PageNumber { get; init; } = 1;
public int PageSize { get; init; } = 25;
public int TotalCandidateCount { get; init; }
public int PendingCandidateCount { get; init; }
public bool HasPreviousPage => PageNumber > 1;
public bool HasNextPage => PageNumber * PageSize < PendingCandidateCount;
public IReadOnlyList<SelectListItem> RelationshipTypeOptions { get; init; } = []; public IReadOnlyList<SelectListItem> RelationshipTypeOptions { get; init; } = [];
public IReadOnlyList<StoryIntelligenceRelationshipReviewCandidateViewModel> Candidates { get; init; } = []; public IReadOnlyList<StoryIntelligenceRelationshipReviewCandidateViewModel> Candidates { get; init; } = [];
} }
@ -592,6 +616,12 @@ public sealed class StoryIntelligenceKnowledgeReviewViewModel
public int AlreadyLinkedCount { get; init; } public int AlreadyLinkedCount { get; init; }
public bool IsComplete { get; init; } public bool IsComplete { get; init; }
public int? SummaryNeedsAttentionCount { get; init; } public int? SummaryNeedsAttentionCount { get; init; }
public int PageNumber { get; init; } = 1;
public int PageSize { get; init; } = 25;
public int TotalCandidateCount { get; init; }
public int PendingCandidateCount { get; init; }
public bool HasPreviousPage => PageNumber > 1;
public bool HasNextPage => PageNumber * PageSize < PendingCandidateCount;
public IReadOnlyList<SelectListItem> KnowledgeStateOptions { get; init; } = []; public IReadOnlyList<SelectListItem> KnowledgeStateOptions { get; init; } = [];
public IReadOnlyList<StoryIntelligenceKnowledgeReviewCandidateViewModel> Candidates { get; init; } = []; public IReadOnlyList<StoryIntelligenceKnowledgeReviewCandidateViewModel> Candidates { get; init; } = [];
} }

View File

@ -3,7 +3,7 @@
ViewData["Title"] = "Review assets"; ViewData["Title"] = "Review assets";
const int previewLimit = 25; const int previewLimit = 25;
var assetCandidates = Model.AssetReview.Candidates.Take(previewLimit).ToList(); var assetCandidates = Model.AssetReview.Candidates.Take(previewLimit).ToList();
var hiddenCandidateCount = Math.Max(0, Model.AssetReview.Candidates.Count - assetCandidates.Count); var hiddenCandidateCount = Math.Max(0, Model.AssetReview.PendingCandidateCount - assetCandidates.Count);
} }
<section class="onboarding-shell" aria-labelledby="story-asset-title"> <section class="onboarding-shell" aria-labelledby="story-asset-title">
@ -43,7 +43,7 @@
<div class="story-review-card-actions"> <div class="story-review-card-actions">
@if (hiddenCandidateCount > 0) @if (hiddenCandidateCount > 0)
{ {
<span class="story-review-page-status">Showing @assetCandidates.Count.ToString("N0") of @Model.AssetReview.Candidates.Count.ToString("N0")</span> <span class="story-review-page-status">Showing @assetCandidates.Count.ToString("N0") of @Model.AssetReview.PendingCandidateCount.ToString("N0") pending</span>
} }
<button class="btn btn-outline-primary btn-sm" type="button" data-asset-bulk="create-all">Create all</button> <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> <button class="btn btn-outline-secondary btn-sm" type="button" data-asset-bulk="link-existing">Link existing matches</button>
@ -146,6 +146,14 @@
<span class="text-muted">Save this batch to continue with @hiddenCandidateCount.ToString("N0") more asset decision@(hiddenCandidateCount == 1 ? string.Empty : "s").</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> <a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
@if (Model.AssetReview.HasPreviousPage)
{
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceAssets" asp-route-batchId="@Model.BatchID" asp-route-page="@(Model.AssetReview.PageNumber - 1)">Previous</a>
}
@if (Model.AssetReview.HasNextPage)
{
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceAssets" asp-route-batchId="@Model.BatchID" asp-route-page="@(Model.AssetReview.PageNumber + 1)">Next</a>
}
<button class="btn btn-primary" type="submit" data-busy-label="Saving assets...">Save asset decisions</button> <button class="btn btn-primary" type="submit" data-busy-label="Saving assets...">Save asset decisions</button>
</div> </div>
</form> </form>

View File

@ -3,7 +3,7 @@
ViewData["Title"] = "Review characters"; ViewData["Title"] = "Review characters";
const int previewLimit = 25; const int previewLimit = 25;
var characterCandidates = Model.CharacterReview.Candidates.Take(previewLimit).ToList(); var characterCandidates = Model.CharacterReview.Candidates.Take(previewLimit).ToList();
var hiddenCandidateCount = Math.Max(0, Model.CharacterReview.Candidates.Count - characterCandidates.Count); var hiddenCandidateCount = Math.Max(0, Model.CharacterReview.PendingCandidateCount - characterCandidates.Count);
} }
<section class="onboarding-shell" aria-labelledby="story-character-title"> <section class="onboarding-shell" aria-labelledby="story-character-title">
@ -52,7 +52,7 @@
<button class="btn btn-outline-secondary btn-sm" type="button" data-character-bulk="collapse">Collapse all</button> <button class="btn btn-outline-secondary btn-sm" type="button" data-character-bulk="collapse">Collapse all</button>
@if (hiddenCandidateCount > 0) @if (hiddenCandidateCount > 0)
{ {
<span class="story-review-page-status">Showing @characterCandidates.Count.ToString("N0") of @Model.CharacterReview.Candidates.Count.ToString("N0")</span> <span class="story-review-page-status">Showing @characterCandidates.Count.ToString("N0") of @Model.CharacterReview.PendingCandidateCount.ToString("N0") pending</span>
} }
</div> </div>
@ -157,6 +157,14 @@
<span class="text-muted">Save this batch to continue with @hiddenCandidateCount.ToString("N0") more character decision@(hiddenCandidateCount == 1 ? string.Empty : "s").</span> <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> <a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
@if (Model.CharacterReview.HasPreviousPage)
{
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceCharacters" asp-route-batchId="@Model.BatchID" asp-route-page="@(Model.CharacterReview.PageNumber - 1)">Previous</a>
}
@if (Model.CharacterReview.HasNextPage)
{
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceCharacters" asp-route-batchId="@Model.BatchID" asp-route-page="@(Model.CharacterReview.PageNumber + 1)">Next</a>
}
<button class="btn btn-primary" type="submit" data-busy-label="Saving characters...">Save character decisions</button> <button class="btn btn-primary" type="submit" data-busy-label="Saving characters...">Save character decisions</button>
</div> </div>
</form> </form>

View File

@ -3,7 +3,7 @@
ViewData["Title"] = "Review knowledge"; ViewData["Title"] = "Review knowledge";
const int previewLimit = 25; const int previewLimit = 25;
var knowledgeCandidates = Model.KnowledgeReview.Candidates.Take(previewLimit).ToList(); var knowledgeCandidates = Model.KnowledgeReview.Candidates.Take(previewLimit).ToList();
var hiddenCandidateCount = Math.Max(0, Model.KnowledgeReview.Candidates.Count - knowledgeCandidates.Count); var hiddenCandidateCount = Math.Max(0, Model.KnowledgeReview.PendingCandidateCount - knowledgeCandidates.Count);
} }
<section class="onboarding-shell" aria-labelledby="story-knowledge-title"> <section class="onboarding-shell" aria-labelledby="story-knowledge-title">
@ -43,7 +43,7 @@
<div class="story-review-card-actions"> <div class="story-review-card-actions">
@if (hiddenCandidateCount > 0) @if (hiddenCandidateCount > 0)
{ {
<span class="story-review-page-status">Showing @knowledgeCandidates.Count.ToString("N0") of @Model.KnowledgeReview.Candidates.Count.ToString("N0")</span> <span class="story-review-page-status">Showing @knowledgeCandidates.Count.ToString("N0") of @Model.KnowledgeReview.PendingCandidateCount.ToString("N0") pending</span>
} }
<button class="btn btn-outline-primary btn-sm" type="button" data-knowledge-bulk="create-all">Create all</button> <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> <button class="btn btn-outline-secondary btn-sm" type="button" data-knowledge-bulk="link-existing">Link existing matches</button>
@ -152,6 +152,14 @@
<span class="text-muted">Save this batch to continue with @hiddenCandidateCount.ToString("N0") more knowledge decision@(hiddenCandidateCount == 1 ? string.Empty : "s").</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> <a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
@if (Model.KnowledgeReview.HasPreviousPage)
{
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceKnowledge" asp-route-batchId="@Model.BatchID" asp-route-page="@(Model.KnowledgeReview.PageNumber - 1)">Previous</a>
}
@if (Model.KnowledgeReview.HasNextPage)
{
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceKnowledge" asp-route-batchId="@Model.BatchID" asp-route-page="@(Model.KnowledgeReview.PageNumber + 1)">Next</a>
}
<button class="btn btn-primary" type="submit" data-busy-label="Saving knowledge...">Save knowledge decisions</button> <button class="btn btn-primary" type="submit" data-busy-label="Saving knowledge...">Save knowledge decisions</button>
</div> </div>
</form> </form>

View File

@ -3,7 +3,7 @@
ViewData["Title"] = "Review locations"; ViewData["Title"] = "Review locations";
const int previewLimit = 25; const int previewLimit = 25;
var locationCandidates = Model.LocationReview.Candidates.Take(previewLimit).ToList(); var locationCandidates = Model.LocationReview.Candidates.Take(previewLimit).ToList();
var hiddenCandidateCount = Math.Max(0, Model.LocationReview.Candidates.Count - locationCandidates.Count); var hiddenCandidateCount = Math.Max(0, Model.LocationReview.PendingCandidateCount - locationCandidates.Count);
} }
<section class="onboarding-shell" aria-labelledby="story-location-title"> <section class="onboarding-shell" aria-labelledby="story-location-title">
@ -42,7 +42,7 @@
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="collapse">Collapse all</button> <button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="collapse">Collapse all</button>
@if (hiddenCandidateCount > 0) @if (hiddenCandidateCount > 0)
{ {
<span class="story-review-page-status">Showing @locationCandidates.Count.ToString("N0") of @Model.LocationReview.Candidates.Count.ToString("N0")</span> <span class="story-review-page-status">Showing @locationCandidates.Count.ToString("N0") of @Model.LocationReview.PendingCandidateCount.ToString("N0") pending</span>
} }
</div> </div>
@ -147,6 +147,14 @@
<span class="text-muted">Save this batch to continue with @hiddenCandidateCount.ToString("N0") more location decision@(hiddenCandidateCount == 1 ? string.Empty : "s").</span> <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> <a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
@if (Model.LocationReview.HasPreviousPage)
{
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID" asp-route-page="@(Model.LocationReview.PageNumber - 1)">Previous</a>
}
@if (Model.LocationReview.HasNextPage)
{
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID" asp-route-page="@(Model.LocationReview.PageNumber + 1)">Next</a>
}
<button class="btn btn-primary" type="submit" data-busy-label="Saving locations...">Save location decisions</button> <button class="btn btn-primary" type="submit" data-busy-label="Saving locations...">Save location decisions</button>
</div> </div>
</form> </form>

View File

@ -3,7 +3,7 @@
ViewData["Title"] = "Review relationships"; ViewData["Title"] = "Review relationships";
const int previewLimit = 25; const int previewLimit = 25;
var relationshipCandidates = Model.RelationshipReview.Candidates.Take(previewLimit).ToList(); var relationshipCandidates = Model.RelationshipReview.Candidates.Take(previewLimit).ToList();
var hiddenCandidateCount = Math.Max(0, Model.RelationshipReview.Candidates.Count - relationshipCandidates.Count); var hiddenCandidateCount = Math.Max(0, Model.RelationshipReview.PendingCandidateCount - relationshipCandidates.Count);
} }
<section class="onboarding-shell" aria-labelledby="story-relationship-title"> <section class="onboarding-shell" aria-labelledby="story-relationship-title">
@ -43,7 +43,7 @@
<div class="story-review-card-actions"> <div class="story-review-card-actions">
@if (hiddenCandidateCount > 0) @if (hiddenCandidateCount > 0)
{ {
<span class="story-review-page-status">Showing @relationshipCandidates.Count.ToString("N0") of @Model.RelationshipReview.Candidates.Count.ToString("N0")</span> <span class="story-review-page-status">Showing @relationshipCandidates.Count.ToString("N0") of @Model.RelationshipReview.PendingCandidateCount.ToString("N0") pending</span>
} }
<button class="btn btn-outline-primary btn-sm" type="button" data-relationship-bulk="create-all">Create all</button> <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> <button class="btn btn-outline-secondary btn-sm" type="button" data-relationship-bulk="link-existing">Link existing matches</button>
@ -162,6 +162,14 @@
<span class="text-muted">Save this batch to continue with @hiddenCandidateCount.ToString("N0") more relationship decision@(hiddenCandidateCount == 1 ? string.Empty : "s").</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> <a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
@if (Model.RelationshipReview.HasPreviousPage)
{
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceRelationships" asp-route-batchId="@Model.BatchID" asp-route-page="@(Model.RelationshipReview.PageNumber - 1)">Previous</a>
}
@if (Model.RelationshipReview.HasNextPage)
{
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceRelationships" asp-route-batchId="@Model.BatchID" asp-route-page="@(Model.RelationshipReview.PageNumber + 1)">Next</a>
}
<button class="btn btn-primary" type="submit" data-busy-label="Saving relationships...">Save relationship decisions</button> <button class="btn btn-primary" type="submit" data-busy-label="Saving relationships...">Save relationship decisions</button>
</div> </div>
</form> </form>

View File

@ -208,7 +208,7 @@
.forEach((root) => updateLegacyRoot(root, state)); .forEach((root) => updateLegacyRoot(root, state));
if (progressRoots.length > 0 && field(state, "isCompleted", "IsCompleted")) { if (progressRoots.length > 0 && field(state, "isCompleted", "IsCompleted")) {
window.location.href = `/onboarding/story-intelligence/complete?jobId=${encodeURIComponent(jobId)}`; window.location.reload();
} }
}; };

View File

@ -0,0 +1,44 @@
# Story Intelligence Single-System Runtime
Story Intelligence uses one production architecture:
Core Import -> canonical Project/Book/Chapter records -> canonical Scenes -> SceneManuscriptSources -> Scene Intelligence analysis -> retained raw Scene Intelligence results -> deterministic candidate preparation -> persisted review candidates/evidence -> Review Centre -> persisted candidate page GETs -> persisted decisions -> canonical materialisation.
Raw Scene Intelligence JSON is source and audit data. Normal review GET requests must not parse it. Scene JSON may be read only by the deliberate preparation/materialisation path or by explicit reanalysis.
## Sources Of Truth
| Concern | Authoritative Persisted Source |
|---|---|
| Scene text | `SceneManuscriptSources` |
| Scene analysis audit | `StoryIntelligenceSceneResults` |
| Entity review preparation | `StoryIntelligenceReviewCandidateGenerations` |
| Entity review candidates | `StoryIntelligenceReviewCandidates` |
| Entity review evidence | `StoryIntelligenceReviewCandidateEvidence` |
| Entity review completion | `StoryIntelligenceBookPipelines.*ReviewCompletedUtc` |
| Plot/Thread review candidates | `StoryIntelligenceNarrativeCandidates` |
| Plot/Thread evidence/events | `StoryIntelligenceNarrativeCandidateEvidence`, `StoryIntelligenceNarrativeCandidateEvents` |
| Metrics | `SceneMetricValues` |
| Review Centre tiles | `StoryIntelligenceReviewCentre_GetSummary` |
## Candidate Preparation
Candidate preparation is deliberate server work. It reads retained Scene Intelligence results, applies the module generator, and stores the resulting candidate page payloads and compact evidence. Preparation creates a current generation per Book and module.
Characters are prepared after scene import/materialisation. After Character Review completes, peer candidates are prepared for Locations, Assets, Relationships, and Knowledge. Whole-book Plot synthesis remains the only final Plot/Thread candidate generator.
## Review GETs
Normal detailed review GETs perform:
authorization -> compact review frame -> `StoryIntelligenceReviewCandidate_ListPage` -> ViewModel -> Razor.
They do not call candidate generators, do not call `GetProgressAsync`, do not call `ResumeBookAsync`, do not call `EnsureCandidatesAsync`, and do not read `StoryIntelligenceSceneResults`.
## Failure
If a current generation is missing, preparing, or failed, review pages show no generated candidate rows from a fallback path. The fix is deliberate preparation or retry, not silent Scene JSON reconstruction during page render.
## Removed In Phase 27D
Normal review GETs no longer use the transient Scene-JSON-backed entity candidate rebuild path. Those builders remain reachable only as candidate preparation/materialisation generators.