diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index d8cb8db..141bebd 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -275,12 +275,13 @@ var tests = new (string Name, Action Test)[] ("Story Intelligence review module pages omit wizard chrome", StoryIntelligenceReviewModulePagesOmitWizardChrome), ("Story Intelligence downstream review uses completed scene analysis", StoryIntelligenceDownstreamReviewUsesCompletedSceneAnalysis), ("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 heavy review modules offer bounded rendering", StoryIntelligenceHeavyReviewModulesOfferBoundedRendering), ("Story Intelligence Review Centre adds no AI calls", StoryIntelligenceReviewCentreAddsNoAiCalls), ("Story Intelligence Review Centre uses bounded summary query", StoryIntelligenceReviewCentreUsesBoundedSummaryQuery), ("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 global progress uses whole run scene totals", StoryIntelligenceGlobalProgressUsesWholeRunSceneTotals), ("Story Intelligence recovered provider failures stay operational", StoryIntelligenceRecoveredProviderFailuresStayOperational), @@ -946,12 +947,12 @@ static void StoryIntelligenceReviewPostsDoNotUseSuccessInterstitials() { var root = PlotLineRoot(); var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs")); - var postBlock = ExtractBetween(controller, "[HttpPost(\"story-intelligence/characters\")]", "[HttpGet(\"story-intelligence/characters/complete\")]") - + ExtractBetween(controller, "[HttpPost(\"story-intelligence/locations\")]", "[HttpGet(\"story-intelligence/locations/complete\")]") - + ExtractBetween(controller, "[HttpPost(\"story-intelligence/assets\")]", "[HttpGet(\"story-intelligence/assets/complete\")]") - + ExtractBetween(controller, "[HttpPost(\"story-intelligence/relationships\")]", "[HttpGet(\"story-intelligence/relationships/complete\")]") - + ExtractBetween(controller, "[HttpPost(\"story-intelligence/knowledge\")]", "[HttpGet(\"story-intelligence/knowledge/complete\")]") - + ExtractBetween(controller, "[HttpPost(\"story-intelligence/narrative\")]", "[HttpGet(\"story-intelligence/narrative/complete\")]"); + var postBlock = ExtractBetween(controller, "[HttpPost(\"story-intelligence/characters\")]", "[HttpPost(\"story-intelligence/locations\")]") + + ExtractBetween(controller, "[HttpPost(\"story-intelligence/locations\")]", "[HttpPost(\"story-intelligence/assets\")]") + + ExtractBetween(controller, "[HttpPost(\"story-intelligence/assets\")]", "[HttpPost(\"story-intelligence/relationships\")]") + + ExtractBetween(controller, "[HttpPost(\"story-intelligence/relationships\")]", "[HttpGet(\"story-intelligence/knowledge\")]") + + ExtractBetween(controller, "[HttpPost(\"story-intelligence/knowledge\")]", "[HttpGet(\"story-intelligence/narrative\")]") + + 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("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."); } -static void StoryIntelligenceLegacyReviewCompletionRoutesRedirectToCentre() +static void StoryIntelligenceLegacyReviewCompletionRoutesAreDeleted() { var root = PlotLineRoot(); var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs")); - var completionActions = new[] + var deletedRoutes = new[] { - ("StoryIntelligenceCharacterComplete", "characters"), - ("StoryIntelligenceLocationComplete", "locations"), - ("StoryIntelligenceAssetComplete", "assets"), - ("StoryIntelligenceRelationshipComplete", "relationships"), - ("StoryIntelligenceKnowledgeComplete", "knowledge"), - ("StoryIntelligenceNarrativeComplete", "narrative") + "story-intelligence/characters/complete", + "story-intelligence/locations/complete", + "story-intelligence/assets/complete", + "story-intelligence/relationships/complete", + "story-intelligence/knowledge/complete", + "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(\"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($"[HttpGet(\"{route}\")]", StringComparison.Ordinal), $"{route} should be deleted, not redirected."); } + + 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() @@ -1088,11 +1094,11 @@ static void StoryIntelligenceDetailedReviewGetsUseSelectedModulePaths() var expectedActions = new Dictionary(StringComparer.Ordinal) { - ["StoryIntelligenceCharacters"] = "GetCharacterReviewAsync(batchId)", - ["StoryIntelligenceLocations"] = "GetLocationReviewAsync(batchId)", - ["StoryIntelligenceAssets"] = "GetAssetReviewAsync(batchId)", - ["StoryIntelligenceRelationships"] = "GetRelationshipReviewAsync(batchId)", - ["StoryIntelligenceKnowledge"] = "GetKnowledgeReviewAsync(batchId)", + ["StoryIntelligenceCharacters"] = "GetCharacterReviewAsync(batchId, page)", + ["StoryIntelligenceLocations"] = "GetLocationReviewAsync(batchId, page)", + ["StoryIntelligenceAssets"] = "GetAssetReviewAsync(batchId, page)", + ["StoryIntelligenceRelationships"] = "GetRelationshipReviewAsync(batchId, page)", + ["StoryIntelligenceKnowledge"] = "GetKnowledgeReviewAsync(batchId, page)", ["StoryIntelligenceNarrative"] = "GetNarrativeReviewAsync(batchId)" }; @@ -1120,6 +1126,47 @@ static void StoryIntelligenceDetailedReviewGetsUseSelectedModulePaths() var narrativeMethod = ExtractBetween(service, "public Task GetNarrativeReviewAsync", "private async Task GetSelectedReviewAsync"); 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."); + + 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 GetLocationReviewAsync", "public async Task GetAssetReviewAsync").Contains("BuildReviewAsync", StringComparison.Ordinal), "Location GET must not call the old builder."); + Assert(!ExtractBetween(service, "public async Task GetRelationshipReviewAsync", "public async Task 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) diff --git a/PlotLine/Controllers/OnboardingController.cs b/PlotLine/Controllers/OnboardingController.cs index 493f43e..86049ff 100644 --- a/PlotLine/Controllers/OnboardingController.cs +++ b/PlotLine/Controllers/OnboardingController.cs @@ -321,9 +321,9 @@ public sealed class OnboardingController( } [HttpGet("story-intelligence/characters")] - public async Task StoryIntelligenceCharacters(Guid batchId) + public async Task StoryIntelligenceCharacters(Guid batchId, int page = 1) { - var model = await storyIntelligence.GetCharacterReviewAsync(batchId); + var model = await storyIntelligence.GetCharacterReviewAsync(batchId, page); if (model is null) { return NotFound(); @@ -345,9 +345,9 @@ public sealed class OnboardingController( } [HttpGet("story-intelligence/locations")] - public async Task StoryIntelligenceLocations(Guid batchId) + public async Task StoryIntelligenceLocations(Guid batchId, int page = 1) { - var model = await storyIntelligence.GetLocationReviewAsync(batchId); + var model = await storyIntelligence.GetLocationReviewAsync(batchId, page); if (model is null) { return NotFound(); @@ -369,9 +369,9 @@ public sealed class OnboardingController( } [HttpGet("story-intelligence/assets")] - public async Task StoryIntelligenceAssets(Guid batchId) + public async Task StoryIntelligenceAssets(Guid batchId, int page = 1) { - var model = await storyIntelligence.GetAssetReviewAsync(batchId); + var model = await storyIntelligence.GetAssetReviewAsync(batchId, page); if (model is null) { return NotFound(); @@ -396,9 +396,9 @@ public sealed class OnboardingController( } [HttpGet("story-intelligence/relationships")] - public async Task StoryIntelligenceRelationships(Guid batchId) + public async Task StoryIntelligenceRelationships(Guid batchId, int page = 1) { - var model = await storyIntelligence.GetRelationshipReviewAsync(batchId); + var model = await storyIntelligence.GetRelationshipReviewAsync(batchId, page); if (model is null) { return NotFound(); @@ -480,12 +480,6 @@ public sealed class OnboardingController( : 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")] [ValidateAntiForgeryToken] public async Task ImportStoryIntelligenceLocations(StoryIntelligenceLocationImportForm form) @@ -502,12 +496,6 @@ public sealed class OnboardingController( : 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")] [ValidateAntiForgeryToken] public async Task ImportStoryIntelligenceAssets(StoryIntelligenceAssetImportForm form) @@ -524,12 +512,6 @@ public sealed class OnboardingController( : 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")] [ValidateAntiForgeryToken] public async Task ImportStoryIntelligenceRelationships(StoryIntelligenceRelationshipImportForm form) @@ -546,16 +528,10 @@ public sealed class OnboardingController( : 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")] - public async Task StoryIntelligenceKnowledge(Guid batchId) + public async Task StoryIntelligenceKnowledge(Guid batchId, int page = 1) { - var model = await storyIntelligence.GetKnowledgeReviewAsync(batchId); + var model = await storyIntelligence.GetKnowledgeReviewAsync(batchId, page); if (model is null) { return NotFound(); @@ -595,12 +571,6 @@ public sealed class OnboardingController( : 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")] public async Task StoryIntelligenceNarrative(Guid batchId) { @@ -647,7 +617,7 @@ public sealed class OnboardingController( [HttpPost("story-intelligence/plot-synthesis/retry")] public async Task RetryStoryIntelligencePlotSynthesis(Guid batchId) { - var model = await storyIntelligence.GetProgressAsync(batchId); + var model = await storyIntelligence.GetReviewCentreAsync(batchId); if (model is null) { return NotFound(); @@ -663,30 +633,6 @@ public sealed class OnboardingController( 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 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) => RedirectToAction("Details", "Books", new { id = model.BookID }); diff --git a/PlotLine/Data/StoryIntelligenceReviewCandidateRepository.cs b/PlotLine/Data/StoryIntelligenceReviewCandidateRepository.cs new file mode 100644 index 0000000..c6762be --- /dev/null +++ b/PlotLine/Data/StoryIntelligenceReviewCandidateRepository.cs @@ -0,0 +1,131 @@ +using System.Data; +using Dapper; +using PlotLine.Models; + +namespace PlotLine.Data; + +public interface IStoryIntelligenceReviewCandidateRepository +{ + Task BeginGenerationAsync(int projectId, int bookId, string module, string source); + Task GetCurrentGenerationAsync(int bookId, string module); + Task SaveCandidateAsync(StoryIntelligenceReviewCandidateSaveRequest request); + Task SaveEvidenceAsync(int reviewCandidateId, int? sceneId, string evidenceText, decimal? confidence, int sortOrder); + Task CompleteGenerationAsync(int generationId); + Task FailGenerationAsync(int generationId, string errorMessage); + Task> ListPageAsync(int bookId, string module, int pageNumber, int pageSize); + Task MarkReviewedAsync(StoryIntelligenceReviewCandidateDecisionRequest request); + Task ResetBookAsync(int bookId); +} + +public sealed class StoryIntelligenceReviewCandidateRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligenceReviewCandidateRepository +{ + public async Task BeginGenerationAsync(int projectId, int bookId, string module, string source) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.StoryIntelligenceReviewCandidateGeneration_Begin", + new { ProjectID = projectId, BookID = bookId, Module = module, Source = source }, + commandType: CommandType.StoredProcedure); + } + + public async Task GetCurrentGenerationAsync(int bookId, string module) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.StoryIntelligenceReviewCandidateGeneration_GetCurrent", + new { BookID = bookId, Module = module }, + commandType: CommandType.StoredProcedure); + } + + public async Task SaveCandidateAsync(StoryIntelligenceReviewCandidateSaveRequest request) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "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 CompleteGenerationAsync(int generationId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "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> ListPageAsync(int bookId, string module, int pageNumber, int pageSize) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + "dbo.StoryIntelligenceReviewCandidate_ListPage", + new { BookID = bookId, Module = module, PageNumber = pageNumber, PageSize = pageSize }, + commandType: CommandType.StoredProcedure); + return rows.ToList(); + } + + public async Task MarkReviewedAsync(StoryIntelligenceReviewCandidateDecisionRequest request) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "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); + } +} diff --git a/PlotLine/Models/StoryIntelligenceReviewCandidateModels.cs b/PlotLine/Models/StoryIntelligenceReviewCandidateModels.cs new file mode 100644 index 0000000..7057c9d --- /dev/null +++ b/PlotLine/Models/StoryIntelligenceReviewCandidateModels.cs @@ -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; } +} diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index 4ae9170..16ac8d3 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -123,6 +123,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -231,6 +232,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/PlotLine/Services/OnboardingStoryIntelligenceService.cs b/PlotLine/Services/OnboardingStoryIntelligenceService.cs index 35178e2..92acd68 100644 --- a/PlotLine/Services/OnboardingStoryIntelligenceService.cs +++ b/PlotLine/Services/OnboardingStoryIntelligenceService.cs @@ -13,11 +13,11 @@ public interface IOnboardingStoryIntelligenceService Task StartAsync(string? appearancePreference = null, string? primaryAgeGroup = null, string? storyEra = null, string? storyLocation = null); Task GetProgressAsync(Guid batchId); Task GetReviewCentreAsync(Guid batchId); - Task GetCharacterReviewAsync(Guid batchId); - Task GetLocationReviewAsync(Guid batchId); - Task GetAssetReviewAsync(Guid batchId); - Task GetRelationshipReviewAsync(Guid batchId); - Task GetKnowledgeReviewAsync(Guid batchId); + Task GetCharacterReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25); + Task GetLocationReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25); + Task GetAssetReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25); + Task GetRelationshipReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25); + Task GetKnowledgeReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25); Task GetNarrativeReviewAsync(Guid batchId); Task CancelAsync(Guid batchId); Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAsync(Guid batchId, int runId); @@ -54,6 +54,7 @@ public sealed class OnboardingStoryIntelligenceService( IWholeBookPlotIntelligenceService wholeBookPlotIntelligence, IStoryIntelligenceSourceRepository sources, IStoryIntelligencePipelineStateService pipelineState, + IStoryIntelligenceReviewCandidateService reviewCandidates, IStoryMemoryRepository storyMemory, IBookRepository books, IChapterRepository chapters, @@ -647,19 +648,19 @@ public sealed class OnboardingStoryIntelligenceService( }); } - var characterReview = await characterImport.BuildReviewAsync(batch); - var locationReview = await locationImport.BuildReviewAsync(batch); - var assetReview = await assetImport.BuildReviewAsync(batch); - var relationshipReview = await relationshipImport.BuildReviewAsync(batch); - var knowledgeReview = await knowledgeImport.BuildReviewAsync(batch); + var characterReview = await reviewCandidates.GetCharacterReviewAsync(batch, 1, 25); + var locationReview = await reviewCandidates.GetLocationReviewAsync(batch, 1, 25); + var assetReview = await reviewCandidates.GetAssetReviewAsync(batch, 1, 25); + var relationshipReview = await reviewCandidates.GetRelationshipReviewAsync(batch, 1, 25); + var knowledgeReview = await reviewCandidates.GetKnowledgeReviewAsync(batch, 1, 25); var narrative = await narrativeReview.GetReviewAsync(batch); var plotSynthesisRun = await wholeBookPlotIntelligence.GetCurrentAsync(batch.BookID); var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID); - var characterComplete = batch.CharacterStageComplete || pipeline?.CharacterReviewCompletedUtc is not null || characterReview.IsComplete; - var locationComplete = batch.LocationStageComplete || pipeline?.LocationReviewCompletedUtc is not null || locationReview.IsComplete; - var assetComplete = batch.AssetStageComplete || pipeline?.AssetReviewCompletedUtc is not null || assetReview.IsComplete; - var relationshipComplete = batch.RelationshipStageComplete || pipeline?.RelationshipReviewCompletedUtc is not null || relationshipReview.IsComplete; - var knowledgeComplete = batch.KnowledgeStageComplete || pipeline?.KnowledgeReviewCompletedUtc is not null || knowledgeReview.IsComplete; + var characterComplete = pipeline?.CharacterReviewCompletedUtc is not null || characterReview.IsComplete; + var locationComplete = pipeline?.LocationReviewCompletedUtc is not null || locationReview.IsComplete; + var assetComplete = pipeline?.AssetReviewCompletedUtc is not null || assetReview.IsComplete; + var relationshipComplete = pipeline?.RelationshipReviewCompletedUtc is not null || relationshipReview.IsComplete; + var knowledgeComplete = pipeline?.KnowledgeReviewCompletedUtc is not null || knowledgeReview.IsComplete; var narrativeComplete = batch.NarrativeStageComplete || pipeline?.NarrativeReviewCompletedUtc is not null || narrative.IsComplete; var narrativePendingCount = narrativeComplete ? 0 : narrative.Candidates.Count; var narrativeThreadCount = narrative.Candidates.Count(candidate => @@ -750,20 +751,20 @@ public sealed class OnboardingStoryIntelligenceService( return frame.Model; } - public Task GetCharacterReviewAsync(Guid batchId) - => GetSelectedReviewAsync(batchId, "Characters", async frame => frame.Model.CharacterReview = await characterImport.BuildReviewAsync(frame.Batch)); + public Task GetCharacterReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25) + => GetSelectedReviewAsync(batchId, "Characters", async frame => frame.Model.CharacterReview = await reviewCandidates.GetCharacterReviewAsync(frame.Batch, pageNumber, pageSize)); - public Task GetLocationReviewAsync(Guid batchId) - => GetSelectedReviewAsync(batchId, "Locations", async frame => frame.Model.LocationReview = await locationImport.BuildReviewAsync(frame.Batch)); + public Task GetLocationReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25) + => GetSelectedReviewAsync(batchId, "Locations", async frame => frame.Model.LocationReview = await reviewCandidates.GetLocationReviewAsync(frame.Batch, pageNumber, pageSize)); - public Task GetAssetReviewAsync(Guid batchId) - => GetSelectedReviewAsync(batchId, "Assets", async frame => frame.Model.AssetReview = await assetImport.BuildReviewAsync(frame.Batch)); + public Task GetAssetReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25) + => GetSelectedReviewAsync(batchId, "Assets", async frame => frame.Model.AssetReview = await reviewCandidates.GetAssetReviewAsync(frame.Batch, pageNumber, pageSize)); - public Task GetRelationshipReviewAsync(Guid batchId) - => GetSelectedReviewAsync(batchId, "Relationships", async frame => frame.Model.RelationshipReview = await relationshipImport.BuildReviewAsync(frame.Batch)); + public Task GetRelationshipReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25) + => GetSelectedReviewAsync(batchId, "Relationships", async frame => frame.Model.RelationshipReview = await reviewCandidates.GetRelationshipReviewAsync(frame.Batch, pageNumber, pageSize)); - public Task GetKnowledgeReviewAsync(Guid batchId) - => GetSelectedReviewAsync(batchId, "Knowledge", async frame => frame.Model.KnowledgeReview = await knowledgeImport.BuildReviewAsync(frame.Batch)); + public Task GetKnowledgeReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25) + => GetSelectedReviewAsync(batchId, "Knowledge", async frame => frame.Model.KnowledgeReview = await reviewCandidates.GetKnowledgeReviewAsync(frame.Batch, pageNumber, pageSize)); public Task GetNarrativeReviewAsync(Guid batchId) => 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." }); } + 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 created = 0; 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 reviewCandidates.PrepareCharacterCandidatesAsync(batch); return (await GetProgressAsync(batchId), new StoryIntelligenceImportCommitResult { 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." }); } - 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); } @@ -1025,7 +1045,12 @@ public sealed class OnboardingStoryIntelligenceService( 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); } @@ -1058,7 +1083,12 @@ public sealed class OnboardingStoryIntelligenceService( 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); } @@ -1092,7 +1122,12 @@ public sealed class OnboardingStoryIntelligenceService( 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); } @@ -1125,7 +1160,12 @@ public sealed class OnboardingStoryIntelligenceService( 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); } @@ -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) { var batch = await batchStore.GetAsync(RequireUserId(), batchId); @@ -1170,7 +1223,7 @@ public sealed class OnboardingStoryIntelligenceService( public async Task GetCompletionAsync(Guid batchId) { - var progress = await GetProgressAsync(batchId); + var progress = await GetReviewCentreAsync(batchId); if (progress is null) { return null; diff --git a/PlotLine/Services/StoryIntelligenceAssetImportService.cs b/PlotLine/Services/StoryIntelligenceAssetImportService.cs index cbd9a44..5b725c4 100644 --- a/PlotLine/Services/StoryIntelligenceAssetImportService.cs +++ b/PlotLine/Services/StoryIntelligenceAssetImportService.cs @@ -9,7 +9,7 @@ namespace PlotLine.Services; public interface IStoryIntelligenceAssetImportService { Task BuildReviewAsync(OnboardingStoryIntelligenceBatch batch); - Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceAssetImportForm form); + Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceAssetImportForm form, bool completeReview); } public sealed class StoryIntelligenceAssetImportService( @@ -94,7 +94,7 @@ public sealed class StoryIntelligenceAssetImportService( }; } - public async Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceAssetImportForm form) + public async Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceAssetImportForm form, bool completeReview) { var data = await BuildCandidateDataAsync(batch); if (!data.HasCommittedScenes) @@ -109,9 +109,12 @@ public sealed class StoryIntelligenceAssetImportService( { if (data.Candidates.Count == 0) { - batch.AssetStageComplete = true; + batch.AssetStageComplete = completeReview; batch.LastAssetImportResult = new StoryIntelligenceAssetImportBatchResult(); - await pipelineState.RecordAssetImportAsync(batch.ProjectID, batch.BookID); + if (completeReview) + { + await pipelineState.RecordAssetImportAsync(batch.ProjectID, batch.BookID); + } return new StoryIntelligenceImportCommitResult { Success = true, @@ -289,7 +292,7 @@ public sealed class StoryIntelligenceAssetImportService( } } - batch.AssetStageComplete = true; + batch.AssetStageComplete = completeReview; batch.LastAssetImportResult = new StoryIntelligenceAssetImportBatchResult { AssetsCreated = created, @@ -299,7 +302,10 @@ public sealed class StoryIntelligenceAssetImportService( SceneAssetEventsCreated = sceneEventsCreated, OwnershipLinksCreated = ownershipLinksCreated }; - await pipelineState.RecordAssetImportAsync(batch.ProjectID, batch.BookID); + if (completeReview) + { + await pipelineState.RecordAssetImportAsync(batch.ProjectID, batch.BookID); + } logger.LogInformation( "Imported Story Intelligence assets for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliases={Aliases} Ignored={Ignored} SceneEvents={SceneEvents} Ownership={Ownership}", diff --git a/PlotLine/Services/StoryIntelligenceCharacterImportService.cs b/PlotLine/Services/StoryIntelligenceCharacterImportService.cs index a7f36db..947794e 100644 --- a/PlotLine/Services/StoryIntelligenceCharacterImportService.cs +++ b/PlotLine/Services/StoryIntelligenceCharacterImportService.cs @@ -9,7 +9,7 @@ namespace PlotLine.Services; public interface IStoryIntelligenceCharacterImportService { Task BuildReviewAsync(OnboardingStoryIntelligenceBatch batch); - Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceCharacterImportForm form); + Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceCharacterImportForm form, bool completeReview); } public sealed class StoryIntelligenceCharacterImportService( @@ -192,7 +192,7 @@ public sealed class StoryIntelligenceCharacterImportService( return warnings; } - public async Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceCharacterImportForm form) + public async Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceCharacterImportForm form, bool completeReview) { var data = await BuildCandidateDataAsync(batch); if (!data.HasCommittedScenes) @@ -373,7 +373,7 @@ public sealed class StoryIntelligenceCharacterImportService( povLinks++; } - batch.CharacterStageComplete = true; + batch.CharacterStageComplete = completeReview; batch.LastCharacterImportResult = new StoryIntelligenceCharacterImportBatchResult { CharactersCreated = created, @@ -384,8 +384,11 @@ public sealed class StoryIntelligenceCharacterImportService( ScenePeoplePanelsUpdated = linkedAppearances }; var remap = await coreImportCharacterMapping.MapBookAsync(batch.ProjectID, batch.BookID); - await pipelineState.RecordCharacterImportAsync(batch.ProjectID, batch.BookID); - await wholeBookPlotIntelligence.QueueAfterCharacterResolutionAsync(batch.ProjectID, batch.BookID, batch.UserID); + if (completeReview) + { + await pipelineState.RecordCharacterImportAsync(batch.ProjectID, batch.BookID); + await wholeBookPlotIntelligence.QueueAfterCharacterResolutionAsync(batch.ProjectID, batch.BookID, batch.UserID); + } 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}", diff --git a/PlotLine/Services/StoryIntelligenceKnowledgeImportService.cs b/PlotLine/Services/StoryIntelligenceKnowledgeImportService.cs index 4290647..83a8d4a 100644 --- a/PlotLine/Services/StoryIntelligenceKnowledgeImportService.cs +++ b/PlotLine/Services/StoryIntelligenceKnowledgeImportService.cs @@ -10,7 +10,7 @@ namespace PlotLine.Services; public interface IStoryIntelligenceKnowledgeImportService { Task BuildReviewAsync(OnboardingStoryIntelligenceBatch batch); - Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceKnowledgeImportForm form); + Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceKnowledgeImportForm form, bool completeReview); } public sealed class StoryIntelligenceKnowledgeImportService( @@ -77,7 +77,7 @@ public sealed class StoryIntelligenceKnowledgeImportService( }; } - public async Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceKnowledgeImportForm form) + public async Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceKnowledgeImportForm form, bool completeReview) { var lookupData = await characters.GetLookupsAsync(batch.ProjectID); 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)); if (!hasUndecidedCandidates) { - batch.KnowledgeStageComplete = true; + batch.KnowledgeStageComplete = completeReview; batch.LastKnowledgeImportResult ??= new StoryIntelligenceKnowledgeImportBatchResult { 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)), 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 { Success = true, @@ -204,7 +207,7 @@ public sealed class StoryIntelligenceKnowledgeImportService( AddDecision(batch, candidate.Key, StoryIntelligenceKnowledgeImportActions.Alias, targetKnowledgeId, KnowledgeLabel(candidate), true); } - batch.KnowledgeStageComplete = true; + batch.KnowledgeStageComplete = completeReview; batch.LastKnowledgeImportResult = new StoryIntelligenceKnowledgeImportBatchResult { KnowledgeCreated = created, @@ -212,7 +215,10 @@ public sealed class StoryIntelligenceKnowledgeImportService( KnowledgeMerged = merged, KnowledgeIgnored = ignored }; - await pipelineState.RecordKnowledgeImportAsync(batch.ProjectID, batch.BookID); + if (completeReview) + { + await pipelineState.RecordKnowledgeImportAsync(batch.ProjectID, batch.BookID); + } logger.LogInformation( "Imported Story Intelligence knowledge for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Merged={Merged} Ignored={Ignored}", diff --git a/PlotLine/Services/StoryIntelligenceLocationImportService.cs b/PlotLine/Services/StoryIntelligenceLocationImportService.cs index 12a16e5..4a2bbb0 100644 --- a/PlotLine/Services/StoryIntelligenceLocationImportService.cs +++ b/PlotLine/Services/StoryIntelligenceLocationImportService.cs @@ -9,7 +9,7 @@ namespace PlotLine.Services; public interface IStoryIntelligenceLocationImportService { Task BuildReviewAsync(OnboardingStoryIntelligenceBatch batch); - Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceLocationImportForm form); + Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceLocationImportForm form, bool completeReview); } public sealed class StoryIntelligenceLocationImportService( @@ -124,7 +124,7 @@ public sealed class StoryIntelligenceLocationImportService( }; } - public async Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceLocationImportForm form) + public async Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceLocationImportForm form, bool completeReview) { var data = await BuildCandidateDataAsync(batch); if (!data.HasCommittedScenes) @@ -269,7 +269,7 @@ public sealed class StoryIntelligenceLocationImportService( } } - batch.LocationStageComplete = true; + batch.LocationStageComplete = completeReview; batch.LastLocationImportResult = new StoryIntelligenceLocationImportBatchResult { LocationsCreated = created, @@ -278,7 +278,10 @@ public sealed class StoryIntelligenceLocationImportService( LocationsIgnored = ignored, SceneLocationsUpdated = sceneLocationsUpdated }; - await pipelineState.RecordLocationImportAsync(batch.ProjectID, batch.BookID); + if (completeReview) + { + await pipelineState.RecordLocationImportAsync(batch.ProjectID, batch.BookID); + } logger.LogInformation( "Imported Story Intelligence locations for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliases={Aliases} Ignored={Ignored} SceneLocationsUpdated={SceneLocationsUpdated}", diff --git a/PlotLine/Services/StoryIntelligenceMaterialisationService.cs b/PlotLine/Services/StoryIntelligenceMaterialisationService.cs index f7479c0..876fe17 100644 --- a/PlotLine/Services/StoryIntelligenceMaterialisationService.cs +++ b/PlotLine/Services/StoryIntelligenceMaterialisationService.cs @@ -16,6 +16,7 @@ public sealed class StoryIntelligenceMaterialisationService( ISceneRepository scenes, IChapterRepository chapters, IStoryIntelligenceNarrativeReviewService narrative, + IStoryIntelligenceReviewCandidateService reviewCandidates, ILogger logger) : IStoryIntelligenceMaterialisationService { private static readonly JsonSerializerOptions JsonOptions = new() @@ -148,6 +149,8 @@ public sealed class StoryIntelligenceMaterialisationService( BookTitle = pipeline.BookDisplayTitle, Items = batchItems }; + await reviewCandidates.PrepareCharacterCandidatesAsync(batch); + await reviewCandidates.PreparePeerCandidatesAsync(batch); await narrative.EnsureCandidatesAsync(batch); narrativeCandidatesAfter = (await narrative.ListCandidatesByBookAsync(bookId)).Count; } diff --git a/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs b/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs index 02c134a..202fc37 100644 --- a/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs +++ b/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs @@ -10,7 +10,7 @@ namespace PlotLine.Services; public interface IStoryIntelligenceRelationshipImportService { Task BuildReviewAsync(OnboardingStoryIntelligenceBatch batch); - Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceRelationshipImportForm form); + Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceRelationshipImportForm form, bool completeReview); } public sealed class StoryIntelligenceRelationshipImportService( @@ -74,7 +74,7 @@ public sealed class StoryIntelligenceRelationshipImportService( }; } - public async Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceRelationshipImportForm form) + public async Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceRelationshipImportForm form, bool completeReview) { var data = await BuildCandidateDataAsync(batch); if (!data.HasCommittedScenes) @@ -90,9 +90,12 @@ public sealed class StoryIntelligenceRelationshipImportService( { if (data.Candidates.Count == 0) { - batch.RelationshipStageComplete = true; + batch.RelationshipStageComplete = completeReview; batch.LastRelationshipImportResult = new StoryIntelligenceRelationshipImportBatchResult(); - await pipelineState.RecordRelationshipImportAsync(batch.ProjectID, batch.BookID); + if (completeReview) + { + await pipelineState.RecordRelationshipImportAsync(batch.ProjectID, batch.BookID); + } return new StoryIntelligenceImportCommitResult { Success = true, @@ -234,7 +237,7 @@ public sealed class StoryIntelligenceRelationshipImportService( } } - batch.RelationshipStageComplete = true; + batch.RelationshipStageComplete = completeReview; batch.LastRelationshipImportResult = new StoryIntelligenceRelationshipImportBatchResult { RelationshipsCreated = created, @@ -243,7 +246,10 @@ public sealed class StoryIntelligenceRelationshipImportService( RelationshipsIgnored = ignored, RelationshipEventsCreated = eventsCreated }; - await pipelineState.RecordRelationshipImportAsync(batch.ProjectID, batch.BookID); + if (completeReview) + { + await pipelineState.RecordRelationshipImportAsync(batch.ProjectID, batch.BookID); + } logger.LogInformation( "Imported Story Intelligence relationships for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Merged={Merged} Ignored={Ignored} Events={Events}", diff --git a/PlotLine/Services/StoryIntelligenceReviewCandidateService.cs b/PlotLine/Services/StoryIntelligenceReviewCandidateService.cs new file mode 100644 index 0000000..02a9b80 --- /dev/null +++ b/PlotLine/Services/StoryIntelligenceReviewCandidateService.cs @@ -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 GetCharacterReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize); + Task GetLocationReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize); + Task GetAssetReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize); + Task GetRelationshipReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize); + Task GetKnowledgeReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize); + Task MarkCharacterDecisionsAsync(int bookId, IEnumerable choices); + Task MarkLocationDecisionsAsync(int bookId, IEnumerable choices); + Task MarkAssetDecisionsAsync(int bookId, IEnumerable choices); + Task MarkRelationshipDecisionsAsync(int bookId, IEnumerable choices); + Task MarkKnowledgeDecisionsAsync(int bookId, IEnumerable choices); +} + +public sealed class StoryIntelligenceReviewCandidateService( + IStoryIntelligenceReviewCandidateRepository candidates, + IStoryIntelligenceCharacterImportService characterImport, + IStoryIntelligenceLocationImportService locationImport, + IStoryIntelligenceAssetImportService assetImport, + IStoryIntelligenceRelationshipImportService relationshipImport, + IStoryIntelligenceKnowledgeImportService knowledgeImport, + ICharacterRepository characters, + ILogger 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 GetCharacterReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize) + { + var page = await LoadPageAsync(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 GetLocationReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize) + { + var page = await LoadPageAsync(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 GetAssetReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize) + { + var page = await LoadPageAsync(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 GetRelationshipReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize) + { + var lookupData = await characters.GetLookupsAsync(batch.ProjectID); + var page = await LoadPageAsync(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 GetKnowledgeReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize) + { + var lookupData = await characters.GetLookupsAsync(batch.ProjectID); + var page = await LoadPageAsync(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 MarkCharacterDecisionsAsync(int bookId, IEnumerable 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 MarkLocationDecisionsAsync(int bookId, IEnumerable 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 MarkAssetDecisionsAsync(int bookId, IEnumerable 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 MarkRelationshipDecisionsAsync(int bookId, IEnumerable 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 MarkKnowledgeDecisionsAsync(int bookId, IEnumerable 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( + OnboardingStoryIntelligenceBatch batch, + string module, + IReadOnlyList sourceCandidates, + Func stableKey, + Func displayName, + Func candidateType, + Func existingCanonicalId, + Func> 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> LoadPageAsync(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([], pageNumber, pageSize, 0, 0, false); + } + + var rows = await candidates.ListPageAsync(bookId, module, pageNumber, pageSize); + var items = rows.Select(row => JsonSerializer.Deserialize(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(items, pageNumber, pageSize, totalCount, pendingCount, true); + } + + private sealed record CandidatePage( + IReadOnlyList Items, + int PageNumber, + int PageSize, + int TotalCount, + int PendingCount, + bool GenerationReady); +} diff --git a/PlotLine/Sql/177_Phase27D_StoryIntelligencePersistedReviewCandidates.sql b/PlotLine/Sql/177_Phase27D_StoryIntelligencePersistedReviewCandidates.sql new file mode 100644 index 0000000..0f53805 --- /dev/null +++ b/PlotLine/Sql/177_Phase27D_StoryIntelligencePersistedReviewCandidates.sql @@ -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 diff --git a/PlotLine/ViewModels/OnboardingViewModels.cs b/PlotLine/ViewModels/OnboardingViewModels.cs index 528a095..24eb02b 100644 --- a/PlotLine/ViewModels/OnboardingViewModels.cs +++ b/PlotLine/ViewModels/OnboardingViewModels.cs @@ -368,6 +368,12 @@ public sealed class StoryIntelligenceCharacterReviewViewModel public int ExistingMatchCount { get; init; } public int NewCandidateCount { 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 Candidates { get; init; } = []; public IReadOnlyList AliasTargetOptions { get; init; } = []; } @@ -440,6 +446,12 @@ public sealed class StoryIntelligenceLocationReviewViewModel public int AlreadyLinkedCount { get; init; } public bool IsComplete { 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 Candidates { get; init; } = []; } @@ -490,6 +502,12 @@ public sealed class StoryIntelligenceAssetReviewViewModel public int AlreadyLinkedCount { get; init; } public bool IsComplete { 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 Candidates { get; init; } = []; } @@ -540,6 +558,12 @@ public sealed class StoryIntelligenceRelationshipReviewViewModel public int AlreadyLinkedCount { get; init; } public bool IsComplete { 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 RelationshipTypeOptions { get; init; } = []; public IReadOnlyList Candidates { get; init; } = []; } @@ -592,6 +616,12 @@ public sealed class StoryIntelligenceKnowledgeReviewViewModel public int AlreadyLinkedCount { get; init; } public bool IsComplete { 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 KnowledgeStateOptions { get; init; } = []; public IReadOnlyList Candidates { get; init; } = []; } diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceAssets.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceAssets.cshtml index 9671015..2a3b037 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceAssets.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceAssets.cshtml @@ -3,7 +3,7 @@ ViewData["Title"] = "Review assets"; const int previewLimit = 25; 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); }
@@ -43,7 +43,7 @@
@if (hiddenCandidateCount > 0) { - Showing @assetCandidates.Count.ToString("N0") of @Model.AssetReview.Candidates.Count.ToString("N0") + Showing @assetCandidates.Count.ToString("N0") of @Model.AssetReview.PendingCandidateCount.ToString("N0") pending } @@ -146,6 +146,14 @@ Save this batch to continue with @hiddenCandidateCount.ToString("N0") more asset decision@(hiddenCandidateCount == 1 ? string.Empty : "s"). } Back to Review Centre + @if (Model.AssetReview.HasPreviousPage) + { + Previous + } + @if (Model.AssetReview.HasNextPage) + { + Next + }
diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceCharacters.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceCharacters.cshtml index a4cfb24..e827c7b 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceCharacters.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceCharacters.cshtml @@ -3,7 +3,7 @@ ViewData["Title"] = "Review characters"; const int previewLimit = 25; var characterCandidates = Model.CharacterReview.Candidates.Take(previewLimit).ToList(); - var hiddenCandidateCount = Math.Max(0, Model.CharacterReview.Candidates.Count - characterCandidates.Count); + var hiddenCandidateCount = Math.Max(0, Model.CharacterReview.PendingCandidateCount - characterCandidates.Count); }
@@ -52,7 +52,7 @@ @if (hiddenCandidateCount > 0) { - Showing @characterCandidates.Count.ToString("N0") of @Model.CharacterReview.Candidates.Count.ToString("N0") + Showing @characterCandidates.Count.ToString("N0") of @Model.CharacterReview.PendingCandidateCount.ToString("N0") pending } @@ -157,6 +157,14 @@ Save this batch to continue with @hiddenCandidateCount.ToString("N0") more character decision@(hiddenCandidateCount == 1 ? string.Empty : "s"). } Back to Review Centre + @if (Model.CharacterReview.HasPreviousPage) + { + Previous + } + @if (Model.CharacterReview.HasNextPage) + { + Next + } diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceKnowledge.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceKnowledge.cshtml index 986799d..a0bb915 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceKnowledge.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceKnowledge.cshtml @@ -3,7 +3,7 @@ ViewData["Title"] = "Review knowledge"; const int previewLimit = 25; 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); }
@@ -43,7 +43,7 @@
@if (hiddenCandidateCount > 0) { - Showing @knowledgeCandidates.Count.ToString("N0") of @Model.KnowledgeReview.Candidates.Count.ToString("N0") + Showing @knowledgeCandidates.Count.ToString("N0") of @Model.KnowledgeReview.PendingCandidateCount.ToString("N0") pending } @@ -152,6 +152,14 @@ Save this batch to continue with @hiddenCandidateCount.ToString("N0") more knowledge decision@(hiddenCandidateCount == 1 ? string.Empty : "s"). } Back to Review Centre + @if (Model.KnowledgeReview.HasPreviousPage) + { + Previous + } + @if (Model.KnowledgeReview.HasNextPage) + { + Next + }
diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceLocations.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceLocations.cshtml index f963963..80ba592 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceLocations.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceLocations.cshtml @@ -3,7 +3,7 @@ ViewData["Title"] = "Review locations"; const int previewLimit = 25; 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); }
@@ -42,7 +42,7 @@ @if (hiddenCandidateCount > 0) { - Showing @locationCandidates.Count.ToString("N0") of @Model.LocationReview.Candidates.Count.ToString("N0") + Showing @locationCandidates.Count.ToString("N0") of @Model.LocationReview.PendingCandidateCount.ToString("N0") pending } @@ -147,6 +147,14 @@ Save this batch to continue with @hiddenCandidateCount.ToString("N0") more location decision@(hiddenCandidateCount == 1 ? string.Empty : "s"). } Back to Review Centre + @if (Model.LocationReview.HasPreviousPage) + { + Previous + } + @if (Model.LocationReview.HasNextPage) + { + Next + } diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceRelationships.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceRelationships.cshtml index dd42d9b..5610d1a 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceRelationships.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceRelationships.cshtml @@ -3,7 +3,7 @@ ViewData["Title"] = "Review relationships"; const int previewLimit = 25; 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); }
@@ -43,7 +43,7 @@
@if (hiddenCandidateCount > 0) { - Showing @relationshipCandidates.Count.ToString("N0") of @Model.RelationshipReview.Candidates.Count.ToString("N0") + Showing @relationshipCandidates.Count.ToString("N0") of @Model.RelationshipReview.PendingCandidateCount.ToString("N0") pending } @@ -162,6 +162,14 @@ Save this batch to continue with @hiddenCandidateCount.ToString("N0") more relationship decision@(hiddenCandidateCount == 1 ? string.Empty : "s"). } Back to Review Centre + @if (Model.RelationshipReview.HasPreviousPage) + { + Previous + } + @if (Model.RelationshipReview.HasNextPage) + { + Next + }
diff --git a/PlotLine/wwwroot/js/story-intelligence-progress.js b/PlotLine/wwwroot/js/story-intelligence-progress.js index 2858f84..169fe9f 100644 --- a/PlotLine/wwwroot/js/story-intelligence-progress.js +++ b/PlotLine/wwwroot/js/story-intelligence-progress.js @@ -208,7 +208,7 @@ .forEach((root) => updateLegacyRoot(root, state)); if (progressRoots.length > 0 && field(state, "isCompleted", "IsCompleted")) { - window.location.href = `/onboarding/story-intelligence/complete?jobId=${encodeURIComponent(jobId)}`; + window.location.reload(); } }; diff --git a/docs/story-intelligence/single-system-runtime.md b/docs/story-intelligence/single-system-runtime.md new file mode 100644 index 0000000..71dbc14 --- /dev/null +++ b/docs/story-intelligence/single-system-runtime.md @@ -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.