Consolidate Story Intelligence review candidates
This commit is contained in:
parent
1fab92f0e5
commit
9c1f9c9182
@ -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<string, string>(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<StoryIntelligenceProgressViewModel?> GetNarrativeReviewAsync", "private async Task<StoryIntelligenceProgressViewModel?> GetSelectedReviewAsync");
|
||||
Assert(narrativeMethod.Contains("narrativeReview.GetReviewAsync(frame.Batch)", StringComparison.Ordinal), "Plot Lines & Threads should use persisted narrative candidates.");
|
||||
Assert(!narrativeMethod.Contains("EnsureCandidatesAsync", StringComparison.Ordinal), "Plot Lines & Threads GET should not generate narrative candidates.");
|
||||
|
||||
var persistedReviewService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceReviewCandidateService.cs"));
|
||||
Assert(service.Contains("reviewCandidates.GetCharacterReviewAsync(frame.Batch, pageNumber, pageSize)", StringComparison.Ordinal), "Character GET should read persisted review candidates.");
|
||||
Assert(service.Contains("reviewCandidates.GetLocationReviewAsync(frame.Batch, pageNumber, pageSize)", StringComparison.Ordinal), "Location GET should read persisted review candidates.");
|
||||
Assert(service.Contains("reviewCandidates.GetAssetReviewAsync(frame.Batch, pageNumber, pageSize)", StringComparison.Ordinal), "Asset GET should read persisted review candidates.");
|
||||
Assert(service.Contains("reviewCandidates.GetRelationshipReviewAsync(frame.Batch, pageNumber, pageSize)", StringComparison.Ordinal), "Relationship GET should read persisted review candidates.");
|
||||
Assert(service.Contains("reviewCandidates.GetKnowledgeReviewAsync(frame.Batch, pageNumber, pageSize)", StringComparison.Ordinal), "Knowledge GET should read persisted review candidates.");
|
||||
Assert(persistedReviewService.Contains("ListPageAsync", StringComparison.Ordinal), "Persisted review service should page through candidate rows.");
|
||||
Assert(!persistedReviewService.Contains("ListSceneResultsAsync", StringComparison.Ordinal), "Persisted review GET service must not parse retained Scene Intelligence JSON.");
|
||||
Assert(!persistedReviewService.Contains("GetRunAsync", StringComparison.Ordinal), "Persisted review GET service must not traverse old run batches.");
|
||||
}
|
||||
|
||||
static void StoryIntelligenceDetailedReviewsReadPersistedCandidatePages()
|
||||
{
|
||||
var root = PlotLineRoot();
|
||||
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceReviewCandidateService.cs"));
|
||||
var repository = File.ReadAllText(Path.Combine(root, "Data/StoryIntelligenceReviewCandidateRepository.cs"));
|
||||
var migration = File.ReadAllText(Path.Combine(root, "Sql/177_Phase27D_StoryIntelligencePersistedReviewCandidates.sql"));
|
||||
var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
|
||||
var program = File.ReadAllText(Path.Combine(root, "Program.cs"));
|
||||
|
||||
foreach (var module in new[] { "Characters", "Locations", "Assets", "Relationships", "Knowledge" })
|
||||
{
|
||||
Assert(migration.Contains($"Module = @Module", StringComparison.Ordinal), $"{module} persisted reads should be module scoped.");
|
||||
}
|
||||
|
||||
Assert(migration.Contains("CREATE TABLE dbo.StoryIntelligenceReviewCandidateGenerations", StringComparison.Ordinal), "Candidate preparation generations should be durable.");
|
||||
Assert(migration.Contains("CREATE TABLE dbo.StoryIntelligenceReviewCandidates", StringComparison.Ordinal), "Review candidates should be durable.");
|
||||
Assert(migration.Contains("CREATE TABLE dbo.StoryIntelligenceReviewCandidateEvidence", StringComparison.Ordinal), "Review evidence should be durable.");
|
||||
Assert(migration.Contains("StoryIntelligenceReviewCandidate_ListPage", StringComparison.Ordinal), "Review GET should use a bounded page procedure.");
|
||||
Assert(migration.Contains("IF @PageSize > 100 SET @PageSize = 100", StringComparison.Ordinal), "Page size should be bounded server-side.");
|
||||
Assert(repository.Contains("CommandType.StoredProcedure", StringComparison.Ordinal), "Candidate repository should use stored procedures.");
|
||||
Assert(!repository.Contains("SELECT ", StringComparison.Ordinal), "Candidate repository should not use inline SELECT SQL.");
|
||||
Assert(onboarding.Contains("reviewCandidates.GetLocationReviewAsync(frame.Batch, pageNumber, pageSize)", StringComparison.Ordinal), "Normal Location GET should use persisted candidate pages.");
|
||||
Assert(onboarding.Contains("reviewCandidates.GetRelationshipReviewAsync(frame.Batch, pageNumber, pageSize)", StringComparison.Ordinal), "Normal Relationship GET should use persisted candidate pages.");
|
||||
Assert(service.Contains("PreparePeerCandidatesAsync", StringComparison.Ordinal), "Scene JSON candidate builders should be isolated to deliberate preparation.");
|
||||
Assert(service.Contains("LoadPageAsync", StringComparison.Ordinal), "Normal review reads should use page loading.");
|
||||
Assert(!ExtractBetween(service, "public async Task<StoryIntelligenceLocationReviewViewModel> GetLocationReviewAsync", "public async Task<StoryIntelligenceAssetReviewViewModel> GetAssetReviewAsync").Contains("BuildReviewAsync", StringComparison.Ordinal), "Location GET must not call the old builder.");
|
||||
Assert(!ExtractBetween(service, "public async Task<StoryIntelligenceRelationshipReviewViewModel> GetRelationshipReviewAsync", "public async Task<StoryIntelligenceKnowledgeReviewViewModel> GetKnowledgeReviewAsync").Contains("BuildReviewAsync", StringComparison.Ordinal), "Relationship GET must not call the old builder.");
|
||||
Assert(program.Contains("IStoryIntelligenceReviewCandidateRepository, StoryIntelligenceReviewCandidateRepository", StringComparison.Ordinal), "Persisted candidate repository should be registered.");
|
||||
Assert(program.Contains("IStoryIntelligenceReviewCandidateService, StoryIntelligenceReviewCandidateService", StringComparison.Ordinal), "Persisted candidate service should be registered.");
|
||||
}
|
||||
|
||||
static string ExtractAction(string value, string signature)
|
||||
|
||||
@ -321,9 +321,9 @@ public sealed class OnboardingController(
|
||||
}
|
||||
|
||||
[HttpGet("story-intelligence/characters")]
|
||||
public async Task<IActionResult> StoryIntelligenceCharacters(Guid batchId)
|
||||
public async Task<IActionResult> StoryIntelligenceCharacters(Guid batchId, int page = 1)
|
||||
{
|
||||
var model = await storyIntelligence.GetCharacterReviewAsync(batchId);
|
||||
var model = await storyIntelligence.GetCharacterReviewAsync(batchId, page);
|
||||
if (model is null)
|
||||
{
|
||||
return NotFound();
|
||||
@ -345,9 +345,9 @@ public sealed class OnboardingController(
|
||||
}
|
||||
|
||||
[HttpGet("story-intelligence/locations")]
|
||||
public async Task<IActionResult> StoryIntelligenceLocations(Guid batchId)
|
||||
public async Task<IActionResult> StoryIntelligenceLocations(Guid batchId, int page = 1)
|
||||
{
|
||||
var model = await storyIntelligence.GetLocationReviewAsync(batchId);
|
||||
var model = await storyIntelligence.GetLocationReviewAsync(batchId, page);
|
||||
if (model is null)
|
||||
{
|
||||
return NotFound();
|
||||
@ -369,9 +369,9 @@ public sealed class OnboardingController(
|
||||
}
|
||||
|
||||
[HttpGet("story-intelligence/assets")]
|
||||
public async Task<IActionResult> StoryIntelligenceAssets(Guid batchId)
|
||||
public async Task<IActionResult> StoryIntelligenceAssets(Guid batchId, int page = 1)
|
||||
{
|
||||
var model = await storyIntelligence.GetAssetReviewAsync(batchId);
|
||||
var model = await storyIntelligence.GetAssetReviewAsync(batchId, page);
|
||||
if (model is null)
|
||||
{
|
||||
return NotFound();
|
||||
@ -396,9 +396,9 @@ public sealed class OnboardingController(
|
||||
}
|
||||
|
||||
[HttpGet("story-intelligence/relationships")]
|
||||
public async Task<IActionResult> StoryIntelligenceRelationships(Guid batchId)
|
||||
public async Task<IActionResult> StoryIntelligenceRelationships(Guid batchId, int page = 1)
|
||||
{
|
||||
var model = await storyIntelligence.GetRelationshipReviewAsync(batchId);
|
||||
var model = await storyIntelligence.GetRelationshipReviewAsync(batchId, page);
|
||||
if (model is null)
|
||||
{
|
||||
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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> StoryIntelligenceKnowledge(Guid batchId)
|
||||
public async Task<IActionResult> StoryIntelligenceKnowledge(Guid batchId, int page = 1)
|
||||
{
|
||||
var model = await storyIntelligence.GetKnowledgeReviewAsync(batchId);
|
||||
var model = await storyIntelligence.GetKnowledgeReviewAsync(batchId, page);
|
||||
if (model is null)
|
||||
{
|
||||
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<IActionResult> StoryIntelligenceNarrative(Guid batchId)
|
||||
{
|
||||
@ -647,7 +617,7 @@ public sealed class OnboardingController(
|
||||
[HttpPost("story-intelligence/plot-synthesis/retry")]
|
||||
public async Task<IActionResult> 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<IActionResult> StoryIntelligenceComplete(Guid batchId)
|
||||
{
|
||||
var progress = await storyIntelligence.GetProgressAsync(batchId);
|
||||
if (progress is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (!progress.CharacterGateSatisfied)
|
||||
{
|
||||
return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
|
||||
}
|
||||
|
||||
var model = await storyIntelligence.GetCompletionAsync(batchId);
|
||||
return model is null ? NotFound() : View(model);
|
||||
}
|
||||
|
||||
private IActionResult RedirectToBook(StoryIntelligenceProgressViewModel model)
|
||||
=> RedirectToAction("Details", "Books", new { id = model.BookID });
|
||||
|
||||
|
||||
131
PlotLine/Data/StoryIntelligenceReviewCandidateRepository.cs
Normal file
131
PlotLine/Data/StoryIntelligenceReviewCandidateRepository.cs
Normal file
@ -0,0 +1,131 @@
|
||||
using System.Data;
|
||||
using Dapper;
|
||||
using PlotLine.Models;
|
||||
|
||||
namespace PlotLine.Data;
|
||||
|
||||
public interface IStoryIntelligenceReviewCandidateRepository
|
||||
{
|
||||
Task<StoryIntelligenceReviewCandidateGeneration> BeginGenerationAsync(int projectId, int bookId, string module, string source);
|
||||
Task<StoryIntelligenceReviewCandidateGeneration?> GetCurrentGenerationAsync(int bookId, string module);
|
||||
Task<StoryIntelligenceReviewCandidateRow> SaveCandidateAsync(StoryIntelligenceReviewCandidateSaveRequest request);
|
||||
Task SaveEvidenceAsync(int reviewCandidateId, int? sceneId, string evidenceText, decimal? confidence, int sortOrder);
|
||||
Task<StoryIntelligenceReviewCandidateGeneration?> CompleteGenerationAsync(int generationId);
|
||||
Task FailGenerationAsync(int generationId, string errorMessage);
|
||||
Task<IReadOnlyList<StoryIntelligenceReviewCandidateRow>> ListPageAsync(int bookId, string module, int pageNumber, int pageSize);
|
||||
Task<StoryIntelligenceReviewCandidateGeneration?> MarkReviewedAsync(StoryIntelligenceReviewCandidateDecisionRequest request);
|
||||
Task ResetBookAsync(int bookId);
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceReviewCandidateRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligenceReviewCandidateRepository
|
||||
{
|
||||
public async Task<StoryIntelligenceReviewCandidateGeneration> BeginGenerationAsync(int projectId, int bookId, string module, string source)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleAsync<StoryIntelligenceReviewCandidateGeneration>(
|
||||
"dbo.StoryIntelligenceReviewCandidateGeneration_Begin",
|
||||
new { ProjectID = projectId, BookID = bookId, Module = module, Source = source },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<StoryIntelligenceReviewCandidateGeneration?> GetCurrentGenerationAsync(int bookId, string module)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceReviewCandidateGeneration>(
|
||||
"dbo.StoryIntelligenceReviewCandidateGeneration_GetCurrent",
|
||||
new { BookID = bookId, Module = module },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<StoryIntelligenceReviewCandidateRow> SaveCandidateAsync(StoryIntelligenceReviewCandidateSaveRequest request)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleAsync<StoryIntelligenceReviewCandidateRow>(
|
||||
"dbo.StoryIntelligenceReviewCandidate_Save",
|
||||
new
|
||||
{
|
||||
request.ReviewCandidateGenerationID,
|
||||
request.ProjectID,
|
||||
request.BookID,
|
||||
request.Module,
|
||||
request.StableKey,
|
||||
request.CandidateType,
|
||||
request.DisplayName,
|
||||
request.SortOrder,
|
||||
request.Confidence,
|
||||
request.ExistingCanonicalID,
|
||||
request.CandidatePayloadJson
|
||||
},
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task SaveEvidenceAsync(int reviewCandidateId, int? sceneId, string evidenceText, decimal? confidence, int sortOrder)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
await connection.ExecuteAsync(
|
||||
"dbo.StoryIntelligenceReviewCandidateEvidence_Save",
|
||||
new
|
||||
{
|
||||
ReviewCandidateID = reviewCandidateId,
|
||||
SceneID = sceneId,
|
||||
EvidenceText = evidenceText,
|
||||
Confidence = confidence,
|
||||
SortOrder = sortOrder
|
||||
},
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<StoryIntelligenceReviewCandidateGeneration?> CompleteGenerationAsync(int generationId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceReviewCandidateGeneration>(
|
||||
"dbo.StoryIntelligenceReviewCandidateGeneration_Complete",
|
||||
new { ReviewCandidateGenerationID = generationId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task FailGenerationAsync(int generationId, string errorMessage)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
await connection.ExecuteAsync(
|
||||
"dbo.StoryIntelligenceReviewCandidateGeneration_Fail",
|
||||
new { ReviewCandidateGenerationID = generationId, ErrorMessage = errorMessage },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<StoryIntelligenceReviewCandidateRow>> ListPageAsync(int bookId, string module, int pageNumber, int pageSize)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
var rows = await connection.QueryAsync<StoryIntelligenceReviewCandidateRow>(
|
||||
"dbo.StoryIntelligenceReviewCandidate_ListPage",
|
||||
new { BookID = bookId, Module = module, PageNumber = pageNumber, PageSize = pageSize },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
return rows.ToList();
|
||||
}
|
||||
|
||||
public async Task<StoryIntelligenceReviewCandidateGeneration?> MarkReviewedAsync(StoryIntelligenceReviewCandidateDecisionRequest request)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceReviewCandidateGeneration>(
|
||||
"dbo.StoryIntelligenceReviewCandidate_MarkReviewed",
|
||||
new
|
||||
{
|
||||
request.BookID,
|
||||
request.Module,
|
||||
request.StableKey,
|
||||
request.DecisionAction,
|
||||
request.DecisionTargetKey,
|
||||
request.DecisionCanonicalID
|
||||
},
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task ResetBookAsync(int bookId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
await connection.ExecuteAsync(
|
||||
"dbo.StoryIntelligenceReviewCandidate_ResetBook",
|
||||
new { BookID = bookId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
}
|
||||
85
PlotLine/Models/StoryIntelligenceReviewCandidateModels.cs
Normal file
85
PlotLine/Models/StoryIntelligenceReviewCandidateModels.cs
Normal file
@ -0,0 +1,85 @@
|
||||
namespace PlotLine.Models;
|
||||
|
||||
public static class StoryIntelligenceReviewModules
|
||||
{
|
||||
public const string Characters = "Characters";
|
||||
public const string Locations = "Locations";
|
||||
public const string Assets = "Assets";
|
||||
public const string Relationships = "Relationships";
|
||||
public const string Knowledge = "Knowledge";
|
||||
}
|
||||
|
||||
public static class StoryIntelligenceReviewCandidateGenerationStatuses
|
||||
{
|
||||
public const string Preparing = "Preparing";
|
||||
public const string Ready = "Ready";
|
||||
public const string Failed = "Failed";
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceReviewCandidateGeneration
|
||||
{
|
||||
public int ReviewCandidateGenerationID { get; init; }
|
||||
public int ProjectID { get; init; }
|
||||
public int BookID { get; init; }
|
||||
public string Module { get; init; } = string.Empty;
|
||||
public string Status { get; init; } = string.Empty;
|
||||
public string Source { get; init; } = string.Empty;
|
||||
public int CandidateCount { get; init; }
|
||||
public int PendingCount { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
public bool IsCurrent { get; init; }
|
||||
public DateTime CreatedUtc { get; init; }
|
||||
public DateTime? PreparedUtc { get; init; }
|
||||
public DateTime? FailedUtc { get; init; }
|
||||
public DateTime UpdatedUtc { get; init; }
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceReviewCandidateRow
|
||||
{
|
||||
public int ReviewCandidateID { get; init; }
|
||||
public int ReviewCandidateGenerationID { get; init; }
|
||||
public int ProjectID { get; init; }
|
||||
public int BookID { get; init; }
|
||||
public string Module { get; init; } = string.Empty;
|
||||
public string StableKey { get; init; } = string.Empty;
|
||||
public string? CandidateType { get; init; }
|
||||
public string DisplayName { get; init; } = string.Empty;
|
||||
public int SortOrder { get; init; }
|
||||
public decimal? Confidence { get; init; }
|
||||
public int? ExistingCanonicalID { get; init; }
|
||||
public string DecisionStatus { get; init; } = string.Empty;
|
||||
public string? DecisionAction { get; init; }
|
||||
public string? DecisionTargetKey { get; init; }
|
||||
public int? DecisionCanonicalID { get; init; }
|
||||
public string CandidatePayloadJson { get; init; } = string.Empty;
|
||||
public DateTime CreatedUtc { get; init; }
|
||||
public DateTime? ReviewedUtc { get; init; }
|
||||
public DateTime UpdatedUtc { get; init; }
|
||||
public int TotalCount { get; init; }
|
||||
public int PendingCount { get; init; }
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceReviewCandidateSaveRequest
|
||||
{
|
||||
public int ReviewCandidateGenerationID { get; init; }
|
||||
public int ProjectID { get; init; }
|
||||
public int BookID { get; init; }
|
||||
public string Module { get; init; } = string.Empty;
|
||||
public string StableKey { get; init; } = string.Empty;
|
||||
public string? CandidateType { get; init; }
|
||||
public string DisplayName { get; init; } = string.Empty;
|
||||
public int SortOrder { get; init; }
|
||||
public decimal? Confidence { get; init; }
|
||||
public int? ExistingCanonicalID { get; init; }
|
||||
public string CandidatePayloadJson { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceReviewCandidateDecisionRequest
|
||||
{
|
||||
public int BookID { get; init; }
|
||||
public string Module { get; init; } = string.Empty;
|
||||
public string StableKey { get; init; } = string.Empty;
|
||||
public string DecisionAction { get; init; } = string.Empty;
|
||||
public string? DecisionTargetKey { get; init; }
|
||||
public int? DecisionCanonicalID { get; init; }
|
||||
}
|
||||
@ -123,6 +123,7 @@ public class Program
|
||||
builder.Services.AddScoped<ISceneMetricTypeRepository, SceneMetricTypeRepository>();
|
||||
builder.Services.AddScoped<IPlotRepository, PlotRepository>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceNarrativeCandidateRepository, StoryIntelligenceNarrativeCandidateRepository>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceReviewCandidateRepository, StoryIntelligenceReviewCandidateRepository>();
|
||||
builder.Services.AddScoped<IAssetRepository, AssetRepository>();
|
||||
builder.Services.AddScoped<ICharacterRepository, CharacterRepository>();
|
||||
builder.Services.AddScoped<ILocationRepository, LocationRepository>();
|
||||
@ -231,6 +232,7 @@ public class Program
|
||||
builder.Services.AddScoped<IStoryIntelligenceRelationshipImportService, StoryIntelligenceRelationshipImportService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceKnowledgeImportService, StoryIntelligenceKnowledgeImportService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceNarrativeReviewService, StoryIntelligenceNarrativeReviewService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceReviewCandidateService, StoryIntelligenceReviewCandidateService>();
|
||||
builder.Services.AddScoped<IWholeBookPlotIntelligenceService, WholeBookPlotIntelligenceService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceSceneContextService, StoryIntelligenceSceneContextService>();
|
||||
builder.Services.AddScoped<IStoryMemoryService, StoryMemoryService>();
|
||||
|
||||
@ -13,11 +13,11 @@ public interface IOnboardingStoryIntelligenceService
|
||||
Task<OnboardingStoryIntelligenceBatch?> StartAsync(string? appearancePreference = null, string? primaryAgeGroup = null, string? storyEra = null, string? storyLocation = null);
|
||||
Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid batchId);
|
||||
Task<StoryIntelligenceProgressViewModel?> GetReviewCentreAsync(Guid batchId);
|
||||
Task<StoryIntelligenceProgressViewModel?> GetCharacterReviewAsync(Guid batchId);
|
||||
Task<StoryIntelligenceProgressViewModel?> GetLocationReviewAsync(Guid batchId);
|
||||
Task<StoryIntelligenceProgressViewModel?> GetAssetReviewAsync(Guid batchId);
|
||||
Task<StoryIntelligenceProgressViewModel?> GetRelationshipReviewAsync(Guid batchId);
|
||||
Task<StoryIntelligenceProgressViewModel?> GetKnowledgeReviewAsync(Guid batchId);
|
||||
Task<StoryIntelligenceProgressViewModel?> GetCharacterReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25);
|
||||
Task<StoryIntelligenceProgressViewModel?> GetLocationReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25);
|
||||
Task<StoryIntelligenceProgressViewModel?> GetAssetReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25);
|
||||
Task<StoryIntelligenceProgressViewModel?> GetRelationshipReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25);
|
||||
Task<StoryIntelligenceProgressViewModel?> GetKnowledgeReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25);
|
||||
Task<StoryIntelligenceProgressViewModel?> GetNarrativeReviewAsync(Guid batchId);
|
||||
Task<StoryIntelligenceProgressViewModel?> 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<StoryIntelligenceProgressViewModel?> GetCharacterReviewAsync(Guid batchId)
|
||||
=> GetSelectedReviewAsync(batchId, "Characters", async frame => frame.Model.CharacterReview = await characterImport.BuildReviewAsync(frame.Batch));
|
||||
public Task<StoryIntelligenceProgressViewModel?> 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<StoryIntelligenceProgressViewModel?> GetLocationReviewAsync(Guid batchId)
|
||||
=> GetSelectedReviewAsync(batchId, "Locations", async frame => frame.Model.LocationReview = await locationImport.BuildReviewAsync(frame.Batch));
|
||||
public Task<StoryIntelligenceProgressViewModel?> 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<StoryIntelligenceProgressViewModel?> GetAssetReviewAsync(Guid batchId)
|
||||
=> GetSelectedReviewAsync(batchId, "Assets", async frame => frame.Model.AssetReview = await assetImport.BuildReviewAsync(frame.Batch));
|
||||
public Task<StoryIntelligenceProgressViewModel?> 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<StoryIntelligenceProgressViewModel?> GetRelationshipReviewAsync(Guid batchId)
|
||||
=> GetSelectedReviewAsync(batchId, "Relationships", async frame => frame.Model.RelationshipReview = await relationshipImport.BuildReviewAsync(frame.Batch));
|
||||
public Task<StoryIntelligenceProgressViewModel?> 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<StoryIntelligenceProgressViewModel?> GetKnowledgeReviewAsync(Guid batchId)
|
||||
=> GetSelectedReviewAsync(batchId, "Knowledge", async frame => frame.Model.KnowledgeReview = await knowledgeImport.BuildReviewAsync(frame.Batch));
|
||||
public Task<StoryIntelligenceProgressViewModel?> 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<StoryIntelligenceProgressViewModel?> 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<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId)
|
||||
{
|
||||
var progress = await GetProgressAsync(batchId);
|
||||
var progress = await GetReviewCentreAsync(batchId);
|
||||
if (progress is null)
|
||||
{
|
||||
return null;
|
||||
|
||||
@ -9,7 +9,7 @@ namespace PlotLine.Services;
|
||||
public interface IStoryIntelligenceAssetImportService
|
||||
{
|
||||
Task<StoryIntelligenceAssetReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch);
|
||||
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceAssetImportForm form);
|
||||
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceAssetImportForm form, bool completeReview);
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceAssetImportService(
|
||||
@ -94,7 +94,7 @@ public sealed class StoryIntelligenceAssetImportService(
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceAssetImportForm form)
|
||||
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceAssetImportForm form, bool completeReview)
|
||||
{
|
||||
var data = await BuildCandidateDataAsync(batch);
|
||||
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}",
|
||||
|
||||
@ -9,7 +9,7 @@ namespace PlotLine.Services;
|
||||
public interface IStoryIntelligenceCharacterImportService
|
||||
{
|
||||
Task<StoryIntelligenceCharacterReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch);
|
||||
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceCharacterImportForm form);
|
||||
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceCharacterImportForm form, bool completeReview);
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceCharacterImportService(
|
||||
@ -192,7 +192,7 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
return warnings;
|
||||
}
|
||||
|
||||
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceCharacterImportForm form)
|
||||
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceCharacterImportForm form, bool completeReview)
|
||||
{
|
||||
var data = await BuildCandidateDataAsync(batch);
|
||||
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}",
|
||||
|
||||
@ -10,7 +10,7 @@ namespace PlotLine.Services;
|
||||
public interface IStoryIntelligenceKnowledgeImportService
|
||||
{
|
||||
Task<StoryIntelligenceKnowledgeReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch);
|
||||
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceKnowledgeImportForm form);
|
||||
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceKnowledgeImportForm form, bool completeReview);
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceKnowledgeImportService(
|
||||
@ -77,7 +77,7 @@ public sealed class StoryIntelligenceKnowledgeImportService(
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceKnowledgeImportForm form)
|
||||
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceKnowledgeImportForm form, bool completeReview)
|
||||
{
|
||||
var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
|
||||
var 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}",
|
||||
|
||||
@ -9,7 +9,7 @@ namespace PlotLine.Services;
|
||||
public interface IStoryIntelligenceLocationImportService
|
||||
{
|
||||
Task<StoryIntelligenceLocationReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch);
|
||||
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceLocationImportForm form);
|
||||
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceLocationImportForm form, bool completeReview);
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceLocationImportService(
|
||||
@ -124,7 +124,7 @@ public sealed class StoryIntelligenceLocationImportService(
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceLocationImportForm form)
|
||||
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceLocationImportForm form, bool completeReview)
|
||||
{
|
||||
var data = await BuildCandidateDataAsync(batch);
|
||||
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}",
|
||||
|
||||
@ -16,6 +16,7 @@ public sealed class StoryIntelligenceMaterialisationService(
|
||||
ISceneRepository scenes,
|
||||
IChapterRepository chapters,
|
||||
IStoryIntelligenceNarrativeReviewService narrative,
|
||||
IStoryIntelligenceReviewCandidateService reviewCandidates,
|
||||
ILogger<StoryIntelligenceMaterialisationService> 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;
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@ namespace PlotLine.Services;
|
||||
public interface IStoryIntelligenceRelationshipImportService
|
||||
{
|
||||
Task<StoryIntelligenceRelationshipReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch);
|
||||
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceRelationshipImportForm form);
|
||||
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceRelationshipImportForm form, bool completeReview);
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceRelationshipImportService(
|
||||
@ -74,7 +74,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceRelationshipImportForm form)
|
||||
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceRelationshipImportForm form, bool completeReview)
|
||||
{
|
||||
var data = await BuildCandidateDataAsync(batch);
|
||||
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}",
|
||||
|
||||
402
PlotLine/Services/StoryIntelligenceReviewCandidateService.cs
Normal file
402
PlotLine/Services/StoryIntelligenceReviewCandidateService.cs
Normal file
@ -0,0 +1,402 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using PlotLine.Data;
|
||||
using PlotLine.Models;
|
||||
using PlotLine.ViewModels;
|
||||
|
||||
namespace PlotLine.Services;
|
||||
|
||||
public interface IStoryIntelligenceReviewCandidateService
|
||||
{
|
||||
Task PrepareCharacterCandidatesAsync(OnboardingStoryIntelligenceBatch batch);
|
||||
Task PreparePeerCandidatesAsync(OnboardingStoryIntelligenceBatch batch);
|
||||
Task<StoryIntelligenceCharacterReviewViewModel> GetCharacterReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize);
|
||||
Task<StoryIntelligenceLocationReviewViewModel> GetLocationReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize);
|
||||
Task<StoryIntelligenceAssetReviewViewModel> GetAssetReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize);
|
||||
Task<StoryIntelligenceRelationshipReviewViewModel> GetRelationshipReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize);
|
||||
Task<StoryIntelligenceKnowledgeReviewViewModel> GetKnowledgeReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize);
|
||||
Task<int> MarkCharacterDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceCharacterImportChoiceForm> choices);
|
||||
Task<int> MarkLocationDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceLocationImportChoiceForm> choices);
|
||||
Task<int> MarkAssetDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceAssetImportChoiceForm> choices);
|
||||
Task<int> MarkRelationshipDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceRelationshipImportChoiceForm> choices);
|
||||
Task<int> MarkKnowledgeDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceKnowledgeImportChoiceForm> choices);
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceReviewCandidateService(
|
||||
IStoryIntelligenceReviewCandidateRepository candidates,
|
||||
IStoryIntelligenceCharacterImportService characterImport,
|
||||
IStoryIntelligenceLocationImportService locationImport,
|
||||
IStoryIntelligenceAssetImportService assetImport,
|
||||
IStoryIntelligenceRelationshipImportService relationshipImport,
|
||||
IStoryIntelligenceKnowledgeImportService knowledgeImport,
|
||||
ICharacterRepository characters,
|
||||
ILogger<StoryIntelligenceReviewCandidateService> logger) : IStoryIntelligenceReviewCandidateService
|
||||
{
|
||||
private const int MaxPageSize = 100;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public async Task PrepareCharacterCandidatesAsync(OnboardingStoryIntelligenceBatch batch)
|
||||
{
|
||||
var review = await characterImport.BuildReviewAsync(batch);
|
||||
await PersistAsync(
|
||||
batch,
|
||||
StoryIntelligenceReviewModules.Characters,
|
||||
review.Candidates,
|
||||
candidate => candidate.Key,
|
||||
candidate => candidate.CharacterName,
|
||||
candidate => candidate.Classification,
|
||||
candidate => candidate.ExistingCharacterID,
|
||||
candidate => candidate.PossibleAliases.Take(5));
|
||||
}
|
||||
|
||||
public async Task PreparePeerCandidatesAsync(OnboardingStoryIntelligenceBatch batch)
|
||||
{
|
||||
await PersistAsync(
|
||||
batch,
|
||||
StoryIntelligenceReviewModules.Locations,
|
||||
(await locationImport.BuildReviewAsync(batch)).Candidates,
|
||||
candidate => candidate.Key,
|
||||
candidate => candidate.LocationName,
|
||||
candidate => candidate.Category,
|
||||
candidate => candidate.ExistingLocationID,
|
||||
candidate => candidate.EvidenceSummaries.Take(5));
|
||||
|
||||
await PersistAsync(
|
||||
batch,
|
||||
StoryIntelligenceReviewModules.Assets,
|
||||
(await assetImport.BuildReviewAsync(batch)).Candidates,
|
||||
candidate => candidate.Key,
|
||||
candidate => candidate.AssetName,
|
||||
candidate => candidate.Category,
|
||||
candidate => candidate.ExistingAssetID,
|
||||
candidate => candidate.PossibleAliases.Take(5));
|
||||
|
||||
await PersistAsync(
|
||||
batch,
|
||||
StoryIntelligenceReviewModules.Relationships,
|
||||
(await relationshipImport.BuildReviewAsync(batch)).Candidates,
|
||||
candidate => candidate.Key,
|
||||
candidate => $"{candidate.CharacterAName} / {candidate.CharacterBName}",
|
||||
candidate => candidate.RelationshipType,
|
||||
candidate => candidate.ExistingRelationshipID,
|
||||
candidate => new[] { candidate.ExampleScene, candidate.ExampleContext }.Where(value => !string.IsNullOrWhiteSpace(value))!);
|
||||
|
||||
await PersistAsync(
|
||||
batch,
|
||||
StoryIntelligenceReviewModules.Knowledge,
|
||||
(await knowledgeImport.BuildReviewAsync(batch)).Candidates,
|
||||
candidate => candidate.Key,
|
||||
candidate => candidate.KnowledgeStatement,
|
||||
candidate => candidate.KnowledgeStateName,
|
||||
candidate => candidate.ExistingKnowledgeID,
|
||||
candidate => new[] { candidate.FirstScene, candidate.ExampleScene, candidate.Evidence }.Where(value => !string.IsNullOrWhiteSpace(value))!);
|
||||
}
|
||||
|
||||
public async Task<StoryIntelligenceCharacterReviewViewModel> GetCharacterReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize)
|
||||
{
|
||||
var page = await LoadPageAsync<StoryIntelligenceCharacterReviewCandidateViewModel>(batch.BookID, StoryIntelligenceReviewModules.Characters, pageNumber, pageSize);
|
||||
var existingOptions = (await characters.ListCharactersAsync(batch.ProjectID))
|
||||
.Select(character => new StoryIntelligenceCharacterExistingOptionViewModel
|
||||
{
|
||||
CharacterID = character.CharacterID,
|
||||
CharacterName = character.CharacterName
|
||||
})
|
||||
.OrderBy(character => character.CharacterName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
var candidatesOnPage = page.Items
|
||||
.Select(candidate => new StoryIntelligenceCharacterReviewCandidateViewModel
|
||||
{
|
||||
Key = candidate.Key,
|
||||
CharacterName = candidate.CharacterName,
|
||||
ImportName = candidate.ImportName,
|
||||
DefaultAction = candidate.DefaultAction,
|
||||
Classification = candidate.Classification,
|
||||
AppearsInScenes = candidate.AppearsInScenes,
|
||||
Confidence = candidate.Confidence,
|
||||
PossibleAliases = candidate.PossibleAliases,
|
||||
ExampleFirstAppearance = candidate.ExampleFirstAppearance,
|
||||
NameIntelligenceWarning = candidate.NameIntelligenceWarning,
|
||||
ExistingCharacterID = candidate.ExistingCharacterID,
|
||||
ExistingCharacterName = candidate.ExistingCharacterName,
|
||||
SelectedExistingCharacterID = candidate.SelectedExistingCharacterID,
|
||||
ExistingCharacterOptions = existingOptions
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new StoryIntelligenceCharacterReviewViewModel
|
||||
{
|
||||
HasCommittedScenes = true,
|
||||
CanImport = candidatesOnPage.Count > 0,
|
||||
IsComplete = page.GenerationReady && page.PendingCount == 0,
|
||||
SummaryNeedsAttentionCount = page.PendingCount,
|
||||
PageNumber = page.PageNumber,
|
||||
PageSize = page.PageSize,
|
||||
TotalCandidateCount = page.TotalCount,
|
||||
PendingCandidateCount = page.PendingCount,
|
||||
GenericReferenceCount = candidatesOnPage.Count(candidate => candidate.Classification == StoryIntelligenceCharacterClassifications.GenericReference),
|
||||
ExistingMatchCount = candidatesOnPage.Count(candidate => candidate.Classification == StoryIntelligenceCharacterClassifications.ExistingCharacter),
|
||||
NewCandidateCount = candidatesOnPage.Count(candidate => candidate.Classification is StoryIntelligenceCharacterClassifications.NewCharacter or StoryIntelligenceCharacterClassifications.NewAlias),
|
||||
AmbiguousCount = candidatesOnPage.Count(candidate => candidate.Classification == StoryIntelligenceCharacterClassifications.Ambiguous),
|
||||
AliasTargetOptions = existingOptions,
|
||||
Candidates = candidatesOnPage
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<StoryIntelligenceLocationReviewViewModel> GetLocationReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize)
|
||||
{
|
||||
var page = await LoadPageAsync<StoryIntelligenceLocationReviewCandidateViewModel>(batch.BookID, StoryIntelligenceReviewModules.Locations, pageNumber, pageSize);
|
||||
return new StoryIntelligenceLocationReviewViewModel
|
||||
{
|
||||
HasCommittedScenes = true,
|
||||
CanImport = page.Items.Count > 0,
|
||||
IsComplete = page.GenerationReady && page.PendingCount == 0,
|
||||
SummaryNeedsAttentionCount = page.PendingCount,
|
||||
PageNumber = page.PageNumber,
|
||||
PageSize = page.PageSize,
|
||||
TotalCandidateCount = page.TotalCount,
|
||||
PendingCandidateCount = page.PendingCount,
|
||||
Candidates = page.Items
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<StoryIntelligenceAssetReviewViewModel> GetAssetReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize)
|
||||
{
|
||||
var page = await LoadPageAsync<StoryIntelligenceAssetReviewCandidateViewModel>(batch.BookID, StoryIntelligenceReviewModules.Assets, pageNumber, pageSize);
|
||||
return new StoryIntelligenceAssetReviewViewModel
|
||||
{
|
||||
HasCommittedScenes = true,
|
||||
CanImport = page.Items.Count > 0,
|
||||
IsComplete = page.GenerationReady && page.PendingCount == 0,
|
||||
SummaryNeedsAttentionCount = page.PendingCount,
|
||||
PageNumber = page.PageNumber,
|
||||
PageSize = page.PageSize,
|
||||
TotalCandidateCount = page.TotalCount,
|
||||
PendingCandidateCount = page.PendingCount,
|
||||
Candidates = page.Items
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<StoryIntelligenceRelationshipReviewViewModel> GetRelationshipReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize)
|
||||
{
|
||||
var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
|
||||
var page = await LoadPageAsync<StoryIntelligenceRelationshipReviewCandidateViewModel>(batch.BookID, StoryIntelligenceReviewModules.Relationships, pageNumber, pageSize);
|
||||
return new StoryIntelligenceRelationshipReviewViewModel
|
||||
{
|
||||
HasCommittedScenes = true,
|
||||
CanImport = page.Items.Count > 0,
|
||||
IsComplete = page.GenerationReady && page.PendingCount == 0,
|
||||
SummaryNeedsAttentionCount = page.PendingCount,
|
||||
PageNumber = page.PageNumber,
|
||||
PageSize = page.PageSize,
|
||||
TotalCandidateCount = page.TotalCount,
|
||||
PendingCandidateCount = page.PendingCount,
|
||||
RelationshipTypeOptions = lookupData.RelationshipTypes
|
||||
.OrderBy(type => type.SortOrder)
|
||||
.ThenBy(type => type.TypeName)
|
||||
.Select(type => new SelectListItem(type.TypeName, type.RelationshipTypeID.ToString()))
|
||||
.ToList(),
|
||||
Candidates = page.Items
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<StoryIntelligenceKnowledgeReviewViewModel> GetKnowledgeReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize)
|
||||
{
|
||||
var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
|
||||
var page = await LoadPageAsync<StoryIntelligenceKnowledgeReviewCandidateViewModel>(batch.BookID, StoryIntelligenceReviewModules.Knowledge, pageNumber, pageSize);
|
||||
return new StoryIntelligenceKnowledgeReviewViewModel
|
||||
{
|
||||
HasCommittedScenes = true,
|
||||
CanImport = page.Items.Count > 0,
|
||||
IsComplete = page.GenerationReady && page.PendingCount == 0,
|
||||
SummaryNeedsAttentionCount = page.PendingCount,
|
||||
PageNumber = page.PageNumber,
|
||||
PageSize = page.PageSize,
|
||||
TotalCandidateCount = page.TotalCount,
|
||||
PendingCandidateCount = page.PendingCount,
|
||||
KnowledgeStateOptions = lookupData.KnowledgeStates
|
||||
.OrderBy(state => state.SortOrder)
|
||||
.ThenBy(state => state.StateName)
|
||||
.Select(state => new SelectListItem(state.StateName, state.KnowledgeStateID.ToString()))
|
||||
.ToList(),
|
||||
Candidates = page.Items
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<int> MarkCharacterDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceCharacterImportChoiceForm> choices)
|
||||
{
|
||||
StoryIntelligenceReviewCandidateGeneration? generation = null;
|
||||
foreach (var choice in choices.Where(choice => !string.IsNullOrWhiteSpace(choice.Key)))
|
||||
{
|
||||
generation = await candidates.MarkReviewedAsync(new StoryIntelligenceReviewCandidateDecisionRequest
|
||||
{
|
||||
BookID = bookId,
|
||||
Module = StoryIntelligenceReviewModules.Characters,
|
||||
StableKey = choice.Key,
|
||||
DecisionAction = choice.Action,
|
||||
DecisionTargetKey = choice.AliasTargetKey,
|
||||
DecisionCanonicalID = choice.ExistingCharacterID ?? choice.AliasTargetCharacterID
|
||||
});
|
||||
}
|
||||
|
||||
return generation?.PendingCount ?? (await candidates.GetCurrentGenerationAsync(bookId, StoryIntelligenceReviewModules.Characters))?.PendingCount ?? 0;
|
||||
}
|
||||
|
||||
public async Task<int> MarkLocationDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceLocationImportChoiceForm> choices)
|
||||
{
|
||||
StoryIntelligenceReviewCandidateGeneration? generation = null;
|
||||
foreach (var choice in choices.Where(choice => !string.IsNullOrWhiteSpace(choice.Key)))
|
||||
{
|
||||
generation = await candidates.MarkReviewedAsync(new StoryIntelligenceReviewCandidateDecisionRequest
|
||||
{
|
||||
BookID = bookId,
|
||||
Module = StoryIntelligenceReviewModules.Locations,
|
||||
StableKey = choice.Key,
|
||||
DecisionAction = choice.Action,
|
||||
DecisionTargetKey = choice.AliasTargetKey
|
||||
});
|
||||
}
|
||||
|
||||
return generation?.PendingCount ?? (await candidates.GetCurrentGenerationAsync(bookId, StoryIntelligenceReviewModules.Locations))?.PendingCount ?? 0;
|
||||
}
|
||||
|
||||
public async Task<int> MarkAssetDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceAssetImportChoiceForm> choices)
|
||||
{
|
||||
StoryIntelligenceReviewCandidateGeneration? generation = null;
|
||||
foreach (var choice in choices.Where(choice => !string.IsNullOrWhiteSpace(choice.Key)))
|
||||
{
|
||||
generation = await candidates.MarkReviewedAsync(new StoryIntelligenceReviewCandidateDecisionRequest
|
||||
{
|
||||
BookID = bookId,
|
||||
Module = StoryIntelligenceReviewModules.Assets,
|
||||
StableKey = choice.Key,
|
||||
DecisionAction = choice.Action,
|
||||
DecisionTargetKey = choice.AliasTargetKey
|
||||
});
|
||||
}
|
||||
|
||||
return generation?.PendingCount ?? (await candidates.GetCurrentGenerationAsync(bookId, StoryIntelligenceReviewModules.Assets))?.PendingCount ?? 0;
|
||||
}
|
||||
|
||||
public async Task<int> MarkRelationshipDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceRelationshipImportChoiceForm> choices)
|
||||
{
|
||||
StoryIntelligenceReviewCandidateGeneration? generation = null;
|
||||
foreach (var choice in choices.Where(choice => !string.IsNullOrWhiteSpace(choice.Key)))
|
||||
{
|
||||
generation = await candidates.MarkReviewedAsync(new StoryIntelligenceReviewCandidateDecisionRequest
|
||||
{
|
||||
BookID = bookId,
|
||||
Module = StoryIntelligenceReviewModules.Relationships,
|
||||
StableKey = choice.Key,
|
||||
DecisionAction = choice.Action,
|
||||
DecisionTargetKey = choice.AliasTargetKey,
|
||||
DecisionCanonicalID = choice.RelationshipTypeID
|
||||
});
|
||||
}
|
||||
|
||||
return generation?.PendingCount ?? (await candidates.GetCurrentGenerationAsync(bookId, StoryIntelligenceReviewModules.Relationships))?.PendingCount ?? 0;
|
||||
}
|
||||
|
||||
public async Task<int> MarkKnowledgeDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceKnowledgeImportChoiceForm> choices)
|
||||
{
|
||||
StoryIntelligenceReviewCandidateGeneration? generation = null;
|
||||
foreach (var choice in choices.Where(choice => !string.IsNullOrWhiteSpace(choice.Key)))
|
||||
{
|
||||
generation = await candidates.MarkReviewedAsync(new StoryIntelligenceReviewCandidateDecisionRequest
|
||||
{
|
||||
BookID = bookId,
|
||||
Module = StoryIntelligenceReviewModules.Knowledge,
|
||||
StableKey = choice.Key,
|
||||
DecisionAction = choice.Action,
|
||||
DecisionTargetKey = choice.AliasTargetKey,
|
||||
DecisionCanonicalID = choice.KnowledgeStateID
|
||||
});
|
||||
}
|
||||
|
||||
return generation?.PendingCount ?? (await candidates.GetCurrentGenerationAsync(bookId, StoryIntelligenceReviewModules.Knowledge))?.PendingCount ?? 0;
|
||||
}
|
||||
|
||||
private async Task PersistAsync<TCandidate>(
|
||||
OnboardingStoryIntelligenceBatch batch,
|
||||
string module,
|
||||
IReadOnlyList<TCandidate> sourceCandidates,
|
||||
Func<TCandidate, string> stableKey,
|
||||
Func<TCandidate, string> displayName,
|
||||
Func<TCandidate, string?> candidateType,
|
||||
Func<TCandidate, int?> existingCanonicalId,
|
||||
Func<TCandidate, IEnumerable<string?>> evidence)
|
||||
{
|
||||
var generation = await candidates.BeginGenerationAsync(batch.ProjectID, batch.BookID, module, "SceneIntelligencePreparation");
|
||||
try
|
||||
{
|
||||
var sortOrder = 0;
|
||||
foreach (var candidate in sourceCandidates)
|
||||
{
|
||||
sortOrder++;
|
||||
var row = await candidates.SaveCandidateAsync(new StoryIntelligenceReviewCandidateSaveRequest
|
||||
{
|
||||
ReviewCandidateGenerationID = generation.ReviewCandidateGenerationID,
|
||||
ProjectID = batch.ProjectID,
|
||||
BookID = batch.BookID,
|
||||
Module = module,
|
||||
StableKey = stableKey(candidate),
|
||||
CandidateType = candidateType(candidate),
|
||||
DisplayName = displayName(candidate),
|
||||
SortOrder = sortOrder,
|
||||
ExistingCanonicalID = existingCanonicalId(candidate),
|
||||
CandidatePayloadJson = JsonSerializer.Serialize(candidate, JsonOptions)
|
||||
});
|
||||
|
||||
var evidenceOrder = 0;
|
||||
foreach (var item in evidence(candidate).Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.OrdinalIgnoreCase).Take(5))
|
||||
{
|
||||
evidenceOrder++;
|
||||
await candidates.SaveEvidenceAsync(row.ReviewCandidateID, null, item!, null, evidenceOrder);
|
||||
}
|
||||
}
|
||||
|
||||
await candidates.CompleteGenerationAsync(generation.ReviewCandidateGenerationID);
|
||||
logger.LogInformation(
|
||||
"Prepared persisted Story Intelligence review candidates. BatchID={BatchID} ProjectID={ProjectID} BookID={BookID} Module={Module} Count={Count}",
|
||||
batch.BatchID,
|
||||
batch.ProjectID,
|
||||
batch.BookID,
|
||||
module,
|
||||
sourceCandidates.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await candidates.FailGenerationAsync(generation.ReviewCandidateGenerationID, ex.Message);
|
||||
logger.LogError(ex, "Persisted Story Intelligence review candidate preparation failed. BatchID={BatchID} BookID={BookID} Module={Module}", batch.BatchID, batch.BookID, module);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<CandidatePage<TCandidate>> LoadPageAsync<TCandidate>(int bookId, string module, int pageNumber, int pageSize)
|
||||
{
|
||||
pageNumber = Math.Max(1, pageNumber);
|
||||
pageSize = Math.Clamp(pageSize <= 0 ? 25 : pageSize, 1, MaxPageSize);
|
||||
|
||||
var generation = await candidates.GetCurrentGenerationAsync(bookId, module);
|
||||
if (generation is null || !string.Equals(generation.Status, StoryIntelligenceReviewCandidateGenerationStatuses.Ready, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new CandidatePage<TCandidate>([], pageNumber, pageSize, 0, 0, false);
|
||||
}
|
||||
|
||||
var rows = await candidates.ListPageAsync(bookId, module, pageNumber, pageSize);
|
||||
var items = rows.Select(row => JsonSerializer.Deserialize<TCandidate>(row.CandidatePayloadJson, JsonOptions))
|
||||
.Where(item => item is not null)
|
||||
.Select(item => item!)
|
||||
.ToList();
|
||||
var totalCount = rows.FirstOrDefault()?.TotalCount ?? generation.CandidateCount;
|
||||
var pendingCount = rows.FirstOrDefault()?.PendingCount ?? generation.PendingCount;
|
||||
return new CandidatePage<TCandidate>(items, pageNumber, pageSize, totalCount, pendingCount, true);
|
||||
}
|
||||
|
||||
private sealed record CandidatePage<TCandidate>(
|
||||
IReadOnlyList<TCandidate> Items,
|
||||
int PageNumber,
|
||||
int PageSize,
|
||||
int TotalCount,
|
||||
int PendingCount,
|
||||
bool GenerationReady);
|
||||
}
|
||||
@ -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
|
||||
@ -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<StoryIntelligenceCharacterReviewCandidateViewModel> Candidates { get; init; } = [];
|
||||
public IReadOnlyList<StoryIntelligenceCharacterExistingOptionViewModel> 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<StoryIntelligenceLocationReviewCandidateViewModel> 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<StoryIntelligenceAssetReviewCandidateViewModel> 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<SelectListItem> RelationshipTypeOptions { get; init; } = [];
|
||||
public IReadOnlyList<StoryIntelligenceRelationshipReviewCandidateViewModel> 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<SelectListItem> KnowledgeStateOptions { get; init; } = [];
|
||||
public IReadOnlyList<StoryIntelligenceKnowledgeReviewCandidateViewModel> Candidates { get; init; } = [];
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
<section class="onboarding-shell" aria-labelledby="story-asset-title">
|
||||
@ -43,7 +43,7 @@
|
||||
<div class="story-review-card-actions">
|
||||
@if (hiddenCandidateCount > 0)
|
||||
{
|
||||
<span class="story-review-page-status">Showing @assetCandidates.Count.ToString("N0") of @Model.AssetReview.Candidates.Count.ToString("N0")</span>
|
||||
<span class="story-review-page-status">Showing @assetCandidates.Count.ToString("N0") of @Model.AssetReview.PendingCandidateCount.ToString("N0") pending</span>
|
||||
}
|
||||
<button class="btn btn-outline-primary btn-sm" type="button" data-asset-bulk="create-all">Create all</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-asset-bulk="link-existing">Link existing matches</button>
|
||||
@ -146,6 +146,14 @@
|
||||
<span class="text-muted">Save this batch to continue with @hiddenCandidateCount.ToString("N0") more asset decision@(hiddenCandidateCount == 1 ? string.Empty : "s").</span>
|
||||
}
|
||||
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
|
||||
@if (Model.AssetReview.HasPreviousPage)
|
||||
{
|
||||
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceAssets" asp-route-batchId="@Model.BatchID" asp-route-page="@(Model.AssetReview.PageNumber - 1)">Previous</a>
|
||||
}
|
||||
@if (Model.AssetReview.HasNextPage)
|
||||
{
|
||||
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceAssets" asp-route-batchId="@Model.BatchID" asp-route-page="@(Model.AssetReview.PageNumber + 1)">Next</a>
|
||||
}
|
||||
<button class="btn btn-primary" type="submit" data-busy-label="Saving assets...">Save asset decisions</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
<section class="onboarding-shell" aria-labelledby="story-character-title">
|
||||
@ -52,7 +52,7 @@
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-character-bulk="collapse">Collapse all</button>
|
||||
@if (hiddenCandidateCount > 0)
|
||||
{
|
||||
<span class="story-review-page-status">Showing @characterCandidates.Count.ToString("N0") of @Model.CharacterReview.Candidates.Count.ToString("N0")</span>
|
||||
<span class="story-review-page-status">Showing @characterCandidates.Count.ToString("N0") of @Model.CharacterReview.PendingCandidateCount.ToString("N0") pending</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@ -157,6 +157,14 @@
|
||||
<span class="text-muted">Save this batch to continue with @hiddenCandidateCount.ToString("N0") more character decision@(hiddenCandidateCount == 1 ? string.Empty : "s").</span>
|
||||
}
|
||||
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
|
||||
@if (Model.CharacterReview.HasPreviousPage)
|
||||
{
|
||||
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceCharacters" asp-route-batchId="@Model.BatchID" asp-route-page="@(Model.CharacterReview.PageNumber - 1)">Previous</a>
|
||||
}
|
||||
@if (Model.CharacterReview.HasNextPage)
|
||||
{
|
||||
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceCharacters" asp-route-batchId="@Model.BatchID" asp-route-page="@(Model.CharacterReview.PageNumber + 1)">Next</a>
|
||||
}
|
||||
<button class="btn btn-primary" type="submit" data-busy-label="Saving characters...">Save character decisions</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
<section class="onboarding-shell" aria-labelledby="story-knowledge-title">
|
||||
@ -43,7 +43,7 @@
|
||||
<div class="story-review-card-actions">
|
||||
@if (hiddenCandidateCount > 0)
|
||||
{
|
||||
<span class="story-review-page-status">Showing @knowledgeCandidates.Count.ToString("N0") of @Model.KnowledgeReview.Candidates.Count.ToString("N0")</span>
|
||||
<span class="story-review-page-status">Showing @knowledgeCandidates.Count.ToString("N0") of @Model.KnowledgeReview.PendingCandidateCount.ToString("N0") pending</span>
|
||||
}
|
||||
<button class="btn btn-outline-primary btn-sm" type="button" data-knowledge-bulk="create-all">Create all</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-knowledge-bulk="link-existing">Link existing matches</button>
|
||||
@ -152,6 +152,14 @@
|
||||
<span class="text-muted">Save this batch to continue with @hiddenCandidateCount.ToString("N0") more knowledge decision@(hiddenCandidateCount == 1 ? string.Empty : "s").</span>
|
||||
}
|
||||
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
|
||||
@if (Model.KnowledgeReview.HasPreviousPage)
|
||||
{
|
||||
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceKnowledge" asp-route-batchId="@Model.BatchID" asp-route-page="@(Model.KnowledgeReview.PageNumber - 1)">Previous</a>
|
||||
}
|
||||
@if (Model.KnowledgeReview.HasNextPage)
|
||||
{
|
||||
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceKnowledge" asp-route-batchId="@Model.BatchID" asp-route-page="@(Model.KnowledgeReview.PageNumber + 1)">Next</a>
|
||||
}
|
||||
<button class="btn btn-primary" type="submit" data-busy-label="Saving knowledge...">Save knowledge decisions</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
<section class="onboarding-shell" aria-labelledby="story-location-title">
|
||||
@ -42,7 +42,7 @@
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="collapse">Collapse all</button>
|
||||
@if (hiddenCandidateCount > 0)
|
||||
{
|
||||
<span class="story-review-page-status">Showing @locationCandidates.Count.ToString("N0") of @Model.LocationReview.Candidates.Count.ToString("N0")</span>
|
||||
<span class="story-review-page-status">Showing @locationCandidates.Count.ToString("N0") of @Model.LocationReview.PendingCandidateCount.ToString("N0") pending</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@ -147,6 +147,14 @@
|
||||
<span class="text-muted">Save this batch to continue with @hiddenCandidateCount.ToString("N0") more location decision@(hiddenCandidateCount == 1 ? string.Empty : "s").</span>
|
||||
}
|
||||
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
|
||||
@if (Model.LocationReview.HasPreviousPage)
|
||||
{
|
||||
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID" asp-route-page="@(Model.LocationReview.PageNumber - 1)">Previous</a>
|
||||
}
|
||||
@if (Model.LocationReview.HasNextPage)
|
||||
{
|
||||
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID" asp-route-page="@(Model.LocationReview.PageNumber + 1)">Next</a>
|
||||
}
|
||||
<button class="btn btn-primary" type="submit" data-busy-label="Saving locations...">Save location decisions</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
<section class="onboarding-shell" aria-labelledby="story-relationship-title">
|
||||
@ -43,7 +43,7 @@
|
||||
<div class="story-review-card-actions">
|
||||
@if (hiddenCandidateCount > 0)
|
||||
{
|
||||
<span class="story-review-page-status">Showing @relationshipCandidates.Count.ToString("N0") of @Model.RelationshipReview.Candidates.Count.ToString("N0")</span>
|
||||
<span class="story-review-page-status">Showing @relationshipCandidates.Count.ToString("N0") of @Model.RelationshipReview.PendingCandidateCount.ToString("N0") pending</span>
|
||||
}
|
||||
<button class="btn btn-outline-primary btn-sm" type="button" data-relationship-bulk="create-all">Create all</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-relationship-bulk="link-existing">Link existing matches</button>
|
||||
@ -162,6 +162,14 @@
|
||||
<span class="text-muted">Save this batch to continue with @hiddenCandidateCount.ToString("N0") more relationship decision@(hiddenCandidateCount == 1 ? string.Empty : "s").</span>
|
||||
}
|
||||
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
|
||||
@if (Model.RelationshipReview.HasPreviousPage)
|
||||
{
|
||||
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceRelationships" asp-route-batchId="@Model.BatchID" asp-route-page="@(Model.RelationshipReview.PageNumber - 1)">Previous</a>
|
||||
}
|
||||
@if (Model.RelationshipReview.HasNextPage)
|
||||
{
|
||||
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceRelationships" asp-route-batchId="@Model.BatchID" asp-route-page="@(Model.RelationshipReview.PageNumber + 1)">Next</a>
|
||||
}
|
||||
<button class="btn btn-primary" type="submit" data-busy-label="Saving relationships...">Save relationship decisions</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
44
docs/story-intelligence/single-system-runtime.md
Normal file
44
docs/story-intelligence/single-system-runtime.md
Normal file
@ -0,0 +1,44 @@
|
||||
# Story Intelligence Single-System Runtime
|
||||
|
||||
Story Intelligence uses one production architecture:
|
||||
|
||||
Core Import -> canonical Project/Book/Chapter records -> canonical Scenes -> SceneManuscriptSources -> Scene Intelligence analysis -> retained raw Scene Intelligence results -> deterministic candidate preparation -> persisted review candidates/evidence -> Review Centre -> persisted candidate page GETs -> persisted decisions -> canonical materialisation.
|
||||
|
||||
Raw Scene Intelligence JSON is source and audit data. Normal review GET requests must not parse it. Scene JSON may be read only by the deliberate preparation/materialisation path or by explicit reanalysis.
|
||||
|
||||
## Sources Of Truth
|
||||
|
||||
| Concern | Authoritative Persisted Source |
|
||||
|---|---|
|
||||
| Scene text | `SceneManuscriptSources` |
|
||||
| Scene analysis audit | `StoryIntelligenceSceneResults` |
|
||||
| Entity review preparation | `StoryIntelligenceReviewCandidateGenerations` |
|
||||
| Entity review candidates | `StoryIntelligenceReviewCandidates` |
|
||||
| Entity review evidence | `StoryIntelligenceReviewCandidateEvidence` |
|
||||
| Entity review completion | `StoryIntelligenceBookPipelines.*ReviewCompletedUtc` |
|
||||
| Plot/Thread review candidates | `StoryIntelligenceNarrativeCandidates` |
|
||||
| Plot/Thread evidence/events | `StoryIntelligenceNarrativeCandidateEvidence`, `StoryIntelligenceNarrativeCandidateEvents` |
|
||||
| Metrics | `SceneMetricValues` |
|
||||
| Review Centre tiles | `StoryIntelligenceReviewCentre_GetSummary` |
|
||||
|
||||
## Candidate Preparation
|
||||
|
||||
Candidate preparation is deliberate server work. It reads retained Scene Intelligence results, applies the module generator, and stores the resulting candidate page payloads and compact evidence. Preparation creates a current generation per Book and module.
|
||||
|
||||
Characters are prepared after scene import/materialisation. After Character Review completes, peer candidates are prepared for Locations, Assets, Relationships, and Knowledge. Whole-book Plot synthesis remains the only final Plot/Thread candidate generator.
|
||||
|
||||
## Review GETs
|
||||
|
||||
Normal detailed review GETs perform:
|
||||
|
||||
authorization -> compact review frame -> `StoryIntelligenceReviewCandidate_ListPage` -> ViewModel -> Razor.
|
||||
|
||||
They do not call candidate generators, do not call `GetProgressAsync`, do not call `ResumeBookAsync`, do not call `EnsureCandidatesAsync`, and do not read `StoryIntelligenceSceneResults`.
|
||||
|
||||
## Failure
|
||||
|
||||
If a current generation is missing, preparing, or failed, review pages show no generated candidate rows from a fallback path. The fix is deliberate preparation or retry, not silent Scene JSON reconstruction during page render.
|
||||
|
||||
## Removed In Phase 27D
|
||||
|
||||
Normal review GETs no longer use the transient Scene-JSON-backed entity candidate rebuild path. Those builders remain reachable only as candidate preparation/materialisation generators.
|
||||
Loading…
x
Reference in New Issue
Block a user