From a6145a25e832fdfdda46f42fb169f7025cf8870c Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Tue, 25 Aug 2026 14:49:19 +0000 Subject: [PATCH] Add Story Intelligence narrative review stage --- PlotLine.Tests/Program.cs | 102 +++++ PlotLine/Controllers/OnboardingController.cs | 73 ++++ PlotLine/Data/Repositories.cs | 88 ++++ PlotLine/Docs/AI/Scene-Prompt-V2.md | 47 +- .../StoryIntelligenceNarrativeModels.cs | 84 ++++ .../StoryIntelligencePersistenceModels.cs | 4 +- PlotLine/Program.cs | 2 + .../OnboardingStoryIntelligenceService.cs | 59 ++- .../PersistedStoryIntelligenceRunner.cs | 56 ++- .../StoryIntelligenceImportCommitService.cs | 10 + ...StoryIntelligenceNarrativeReviewService.cs | 403 ++++++++++++++++++ .../StoryIntelligencePipelineStateService.cs | 18 +- PlotLine/Services/StorySceneValidator.cs | 14 +- ...IntelligenceCommitAndProjectHardDelete.sql | 12 + ...V_ArchivedBookProjectPermanentDeletion.sql | 8 + ...ase25_StoryIntelligenceNarrativeReview.sql | 209 +++++++++ PlotLine/ViewModels/OnboardingViewModels.cs | 66 +++ PlotLine/Views/Books/Details.cshtml | 6 + .../StoryIntelligenceKnowledgeComplete.cshtml | 2 +- .../StoryIntelligenceNarrative.cshtml | 117 +++++ .../StoryIntelligenceNarrativeComplete.cshtml | 30 ++ .../_StoryIntelligencePipelineSummary.cshtml | 8 + 22 files changed, 1364 insertions(+), 54 deletions(-) create mode 100644 PlotLine/Models/StoryIntelligenceNarrativeModels.cs create mode 100644 PlotLine/Services/StoryIntelligenceNarrativeReviewService.cs create mode 100644 PlotLine/Sql/155_Phase25_StoryIntelligenceNarrativeReview.sql create mode 100644 PlotLine/Views/Onboarding/StoryIntelligenceNarrative.cshtml create mode 100644 PlotLine/Views/Onboarding/StoryIntelligenceNarrativeComplete.cshtml diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index ab82125..cd4e311 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -96,6 +96,12 @@ var tests = new (string Name, Action Test)[] ("Story Intelligence review evidence includes chapter scene labels", StoryIntelligenceReviewEvidenceIncludesChapterSceneLabels), ("Story Intelligence import enables project metric defaults before save", StoryIntelligenceImportEnablesProjectMetricDefaultsBeforeSave), ("Project metrics add defaults backfills scene values", ProjectMetricsAddDefaultsBackfillsSceneValues), + ("Story Intelligence scene prompt uses configured metrics", StoryIntelligenceScenePromptUsesConfiguredMetrics), + ("Story Intelligence runner supplies metrics and Plot Lines", StoryIntelligenceRunnerSuppliesMetricsAndPlotLines), + ("Story Intelligence metric persistence uses configured keys", StoryIntelligenceMetricPersistenceUsesConfiguredKeys), + ("Story Intelligence narrative candidates require review", StoryIntelligenceNarrativeCandidatesRequireReview), + ("Story Intelligence narrative review creates canonical records via existing procs", StoryIntelligenceNarrativeReviewCreatesCanonicalRecordsViaExistingProcs), + ("Story Intelligence narrative stage follows knowledge", StoryIntelligenceNarrativeStageFollowsKnowledge), ("Word Companion replays live current scene to writing panes", WordCompanionReplaysLiveCurrentSceneToWritingPanes), ("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions), ("StoryPosition rejects incomplete or invalid runtime scene positions", StoryPositionRejectsInvalidRuntimeScenePositions), @@ -2501,6 +2507,102 @@ static void ProjectMetricsAddDefaultsBackfillsSceneValues() Assert(selfHealingDefaults.Contains("Overall Intensity", StringComparison.Ordinal), "Core metric seed should include the default PlotDirector metrics."); } +static void StoryIntelligenceScenePromptUsesConfiguredMetrics() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var prompt = File.ReadAllText(Path.Combine(root, "Docs/AI/Scene-Prompt-V2.md")); + var validator = File.ReadAllText(Path.Combine(root, "Services/StorySceneValidator.cs")); + + Assert(prompt.Contains("configuredMetrics", StringComparison.Ordinal), "Scene prompt should read configured metrics from scene context."); + Assert(prompt.Contains("use the exact `key` supplied in `sceneContext`", StringComparison.Ordinal), "Scene prompt should use runtime metric keys."); + Assert(prompt.Contains("Do not invent a separate metric catalogue", StringComparison.Ordinal), "Scene prompt should not embed an independent metric catalogue."); + Assert(!prompt.Contains("Preferred V1 metrics:", StringComparison.Ordinal), "Scene prompt should not force the old V1 metric list."); + Assert(!validator.Contains("PreferredMetrics", StringComparison.Ordinal), "Validator should not reject custom configured metric names."); + Assert(validator.Contains("configured metric scale", StringComparison.Ordinal), "Validator error guidance should refer to configured metric scales."); +} + +static void StoryIntelligenceRunnerSuppliesMetricsAndPlotLines() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var runner = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceRunner.cs")); + + Assert(runner.Contains("ISceneMetricTypeRepository metricTypes", StringComparison.Ordinal), "Runner should load metric definitions from the configured project repository."); + Assert(runner.Contains("IPlotRepository plots", StringComparison.Ordinal), "Runner should load existing Plot Lines for AI matching context."); + Assert(runner.Contains("metric.IsActive && metric.IsEnabledForProject", StringComparison.Ordinal), "Runner should only pass active project-enabled metric types."); + Assert(runner.Contains("configuredMetrics = configuredMetrics.Select", StringComparison.Ordinal), "Scene context should include configured metrics."); + Assert(runner.Contains("existingPlotLines = existingPlotLines.Select", StringComparison.Ordinal), "Scene context should include existing author-created Plot Lines."); + Assert(runner.Contains("key = MetricContextKey(metric)", StringComparison.Ordinal), "Scene prompt metrics should use stable configured keys."); + Assert(runner.Contains("metricTypeId = metric.MetricTypeID", StringComparison.Ordinal), "Scene context should preserve MetricTypeID for later persistence."); +} + +static void StoryIntelligenceMetricPersistenceUsesConfiguredKeys() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceImportCommitService.cs")); + var repository = File.ReadAllText(Path.Combine(root, "Data/StoryIntelligenceResultRepository.cs")); + + Assert(service.Contains("map[$\"metric_{metric.MetricTypeID}\"] = metric", StringComparison.Ordinal), "Metric persistence should map configured context keys back to MetricTypeID."); + Assert(service.Contains("map.TryAdd(cleanedName, metric)", StringComparison.Ordinal), "Metric persistence should also accept configured metric names."); + Assert(service.Contains("Math.Clamp(metric.Score.Value, metricType.MinValue, metricType.MaxValue)", StringComparison.Ordinal), "Metric values should be clamped to configured scale."); + Assert(repository.Contains("\"dbo.SceneMetric_SaveValue\"", StringComparison.Ordinal), "Metric values should persist through the existing stored procedure."); + Assert(repository.Contains("new { SceneID = sceneId, metric.MetricTypeID, metric.Value, metric.Notes }", StringComparison.Ordinal), "Metrics should persist by SceneID and MetricTypeID."); +} + +static void StoryIntelligenceNarrativeCandidatesRequireReview() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var sql = File.ReadAllText(Path.Combine(root, "Sql/155_Phase25_StoryIntelligenceNarrativeReview.sql")); + var models = File.ReadAllText(Path.Combine(root, "Models/StoryIntelligenceNarrativeModels.cs")); + var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceNarrativeReviewService.cs")); + var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceNarrative.cshtml")); + var projectDelete = File.ReadAllText(Path.Combine(root, "Sql/124_Phase20Z2_StoryIntelligenceCommitAndProjectHardDelete.sql")); + var bookDelete = File.ReadAllText(Path.Combine(root, "Sql/134_Phase20AV_ArchivedBookProjectPermanentDeletion.sql")); + + Assert(sql.Contains("StoryIntelligenceNarrativeCandidates", StringComparison.Ordinal), "Narrative candidates should be persisted for review."); + Assert(sql.Contains("Status nvarchar(20) NOT NULL", StringComparison.Ordinal), "Narrative candidates should have review status."); + Assert(sql.Contains("CONSTRAINT DF_StoryIntelligenceNarrativeCandidates_Status DEFAULT N'Pending'", StringComparison.Ordinal), "Narrative candidates should default to pending review."); + Assert(models.Contains("StoryIntelligenceNarrativeCandidateTypes", StringComparison.Ordinal), "Candidate types should be explicit."); + Assert(service.Contains("Where(signal => signal.SceneIds.Count > 1)", StringComparison.Ordinal), "Thread discovery should filter one-off incidental details."); + Assert(view.Contains("Review Plot Lines & Threads", StringComparison.Ordinal), "A dedicated narrative review page should be visible."); + Assert(view.Contains("Accept new", StringComparison.Ordinal) && view.Contains("Merge with existing", StringComparison.Ordinal) && view.Contains("Reject", StringComparison.Ordinal), "Review UI should support accept, merge and reject."); + Assert(projectDelete.Contains("StoryIntelligenceNarrativeCandidateEvidence", StringComparison.Ordinal) && projectDelete.Contains("StoryIntelligenceNarrativeCandidates", StringComparison.Ordinal), "Project hard delete should clear narrative review candidates."); + Assert(bookDelete.Contains("StoryIntelligenceNarrativeCandidateEvidence", StringComparison.Ordinal) && bookDelete.Contains("StoryIntelligenceNarrativeCandidates", StringComparison.Ordinal), "Book hard delete should clear narrative review candidates."); +} + +static void StoryIntelligenceNarrativeReviewCreatesCanonicalRecordsViaExistingProcs() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceNarrativeReviewService.cs")); + var repository = File.ReadAllText(Path.Combine(root, "Data/Repositories.cs")); + var sql = File.ReadAllText(Path.Combine(root, "Sql/155_Phase25_StoryIntelligenceNarrativeReview.sql")); + + Assert(service.Contains("plots.SavePlotLineAsync(new PlotLineItem", StringComparison.Ordinal), "Accepted Plot Line candidates should create canonical PlotLines through the existing plot service."); + Assert(service.Contains("plots.SavePlotThreadAsync(new PlotThread", StringComparison.Ordinal), "Accepted Thread candidates should create canonical PlotThreads through the existing plot service."); + Assert(service.Contains("plots.SaveThreadEventAsync(new ThreadEvent", StringComparison.Ordinal), "Accepted Thread evidence should create canonical ThreadEvents."); + Assert(repository.Contains("\"dbo.PlotLine_Save\"", StringComparison.Ordinal), "PlotLine creation should remain stored-procedure based."); + Assert(repository.Contains("\"dbo.PlotThread_Save\"", StringComparison.Ordinal), "PlotThread creation should remain stored-procedure based."); + Assert(repository.Contains("\"dbo.ThreadEvent_Save\"", StringComparison.Ordinal), "ThreadEvent creation should remain stored-procedure based."); + Assert(sql.Contains("StoryIntelligenceNarrativeCandidate_MarkReviewed", StringComparison.Ordinal), "Candidate decisions should be persisted."); +} + +static void StoryIntelligenceNarrativeStageFollowsKnowledge() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var stages = File.ReadAllText(Path.Combine(root, "Models/StoryIntelligencePersistenceModels.cs")); + var pipeline = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligencePipelineStateService.cs")); + var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs")); + var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs")); + var knowledgeComplete = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceKnowledgeComplete.cshtml")); + + Assert(stages.Contains("NarrativeReview", StringComparison.Ordinal) && stages.Contains("NarrativeImport", StringComparison.Ordinal), "Pipeline stages should include narrative review/import."); + Assert(stages.Contains("LastCompletedStage, StoryIntelligencePipelineStages.NarrativeImport", StringComparison.Ordinal), "Pipeline completion should require narrative import."); + Assert(pipeline.Contains("CurrentStage = StoryIntelligencePipelineStages.NarrativeReview", StringComparison.Ordinal), "Knowledge import should lead to narrative review."); + Assert(pipeline.Contains("RecordNarrativeImportAsync", StringComparison.Ordinal), "Narrative import should mark the pipeline complete."); + Assert(onboarding.Contains("StoryIntelligenceResumeRoutes.Narrative", StringComparison.Ordinal), "Resume logic should route to narrative review."); + Assert(controller.Contains("StoryIntelligenceNarrative", StringComparison.Ordinal), "Onboarding controller should expose the narrative review stage."); + Assert(knowledgeComplete.Contains("Review Plot Lines & Threads", StringComparison.Ordinal), "Knowledge completion should lead to narrative review."); +} + static void WordCompanionReplaysLiveCurrentSceneToWritingPanes() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); diff --git a/PlotLine/Controllers/OnboardingController.cs b/PlotLine/Controllers/OnboardingController.cs index 57cec69..138a890 100644 --- a/PlotLine/Controllers/OnboardingController.cs +++ b/PlotLine/Controllers/OnboardingController.cs @@ -99,6 +99,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard return target.Route switch { StoryIntelligenceResumeRoutes.Complete => RedirectToAction(nameof(StoryIntelligenceComplete), new { batchId = target.BatchID }), + StoryIntelligenceResumeRoutes.Narrative => RedirectToAction(nameof(StoryIntelligenceNarrative), new { batchId = target.BatchID }), StoryIntelligenceResumeRoutes.Knowledge => RedirectToAction(nameof(StoryIntelligenceKnowledge), new { batchId = target.BatchID }), StoryIntelligenceResumeRoutes.Relationships => RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId = target.BatchID }), StoryIntelligenceResumeRoutes.Assets => RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId = target.BatchID }), @@ -479,6 +480,73 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard return model is null ? NotFound() : View(model); } + [HttpGet("story-intelligence/narrative")] + public async Task StoryIntelligenceNarrative(Guid batchId) + { + var model = await storyIntelligence.GetProgressAsync(batchId); + if (model is null) + { + return NotFound(); + } + + if (model.HasActiveRuns) + { + return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId }); + } + + if (!model.AllCommitted) + { + return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId }); + } + + if (!model.PipelineDashboard.CharacterStageComplete) + { + return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId }); + } + + if (!model.PipelineDashboard.LocationStageComplete) + { + return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId }); + } + + if (!model.PipelineDashboard.AssetStageComplete) + { + return RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId }); + } + + if (!model.PipelineDashboard.RelationshipStageComplete) + { + return RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId }); + } + + return !model.PipelineDashboard.KnowledgeStageComplete + ? RedirectToAction(nameof(StoryIntelligenceKnowledge), new { batchId }) + : View(model); + } + + [HttpPost("story-intelligence/narrative")] + [ValidateAntiForgeryToken] + public async Task ImportStoryIntelligenceNarrative(StoryIntelligenceNarrativeImportForm form) + { + var (progress, result) = await storyIntelligence.ImportNarrativeAsync(form.BatchID, form); + if (progress is null) + { + return NotFound(); + } + + TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; + return result.Success + ? RedirectToAction(nameof(StoryIntelligenceNarrativeComplete), new { batchId = form.BatchID }) + : RedirectToAction(nameof(StoryIntelligenceNarrative), new { batchId = form.BatchID }); + } + + [HttpGet("story-intelligence/narrative/complete")] + public async Task StoryIntelligenceNarrativeComplete(Guid batchId) + { + var model = await storyIntelligence.GetNarrativeImportResultAsync(batchId); + return model is null ? NotFound() : View(model); + } + [HttpGet("story-intelligence/complete")] public async Task StoryIntelligenceComplete(Guid batchId) { @@ -513,6 +581,11 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard return RedirectToAction(nameof(StoryIntelligenceKnowledge), new { batchId }); } + if (!progress.PipelineDashboard.NarrativeStageComplete) + { + return RedirectToAction(nameof(StoryIntelligenceNarrative), new { batchId }); + } + var model = await storyIntelligence.GetCompletionAsync(batchId); return model is null ? NotFound() : View(model); } diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index badc217..46ad49d 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -166,6 +166,14 @@ public interface IPlotRepository Task> ListSceneOptionsAsync(int projectId); } +public interface IStoryIntelligenceNarrativeCandidateRepository +{ + Task SaveAsync(StoryIntelligenceNarrativeCandidateSeed candidate); + Task SaveEvidenceAsync(int candidateId, int sceneId, string? contributionType, string? evidence, int sortOrder); + Task> ListByBookAsync(int bookId); + Task MarkReviewedAsync(int candidateId, string status, string reviewAction, int userId, int? acceptedPlotLineId, int? acceptedPlotThreadId); +} + public interface IAssetRepository { Task GetLookupsAsync(int? projectId = null); @@ -3319,6 +3327,86 @@ public sealed class PlotRepository(ISqlConnectionFactory connectionFactory) : IP } } +public sealed class StoryIntelligenceNarrativeCandidateRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligenceNarrativeCandidateRepository +{ + public async Task SaveAsync(StoryIntelligenceNarrativeCandidateSeed candidate) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.StoryIntelligenceNarrativeCandidate_Save", + new + { + candidate.ProjectID, + candidate.BookID, + candidate.CandidateType, + candidate.CandidateKey, + candidate.ProposedTitle, + candidate.ProposedDescription, + candidate.Category, + candidate.InferredState, + candidate.ExistingPlotLineID, + candidate.ExistingPlotThreadID, + candidate.RelatedPlotLineID, + candidate.Confidence + }, + commandType: CommandType.StoredProcedure); + } + + public async Task SaveEvidenceAsync(int candidateId, int sceneId, string? contributionType, string? evidence, int sortOrder) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + "dbo.StoryIntelligenceNarrativeCandidateEvidence_Save", + new + { + NarrativeCandidateID = candidateId, + SceneID = sceneId, + ContributionType = contributionType, + Evidence = evidence, + SortOrder = sortOrder + }, + commandType: CommandType.StoredProcedure); + } + + public async Task> ListByBookAsync(int bookId) + { + using var connection = connectionFactory.CreateConnection(); + using var result = await connection.QueryMultipleAsync( + "dbo.StoryIntelligenceNarrativeCandidate_ListByBook", + new { BookID = bookId }, + commandType: CommandType.StoredProcedure); + var candidates = (await result.ReadAsync()).ToList(); + var evidence = (await result.ReadAsync()).ToList(); + var evidenceByCandidate = evidence + .GroupBy(item => item.NarrativeCandidateID) + .ToDictionary(group => group.Key, group => (IReadOnlyList)group.ToList()); + + foreach (var candidate in candidates) + { + candidate.Evidence = evidenceByCandidate.TryGetValue(candidate.NarrativeCandidateID, out var items) ? items : []; + } + + return candidates; + } + + public async Task MarkReviewedAsync(int candidateId, string status, string reviewAction, int userId, int? acceptedPlotLineId, int? acceptedPlotThreadId) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + "dbo.StoryIntelligenceNarrativeCandidate_MarkReviewed", + new + { + NarrativeCandidateID = candidateId, + Status = status, + ReviewAction = reviewAction, + ReviewedByUserID = userId, + AcceptedPlotLineID = acceptedPlotLineId, + AcceptedPlotThreadID = acceptedPlotThreadId + }, + commandType: CommandType.StoredProcedure); + } +} + public sealed class AssetRepository(ISqlConnectionFactory connectionFactory) : IAssetRepository { public async Task GetLookupsAsync(int? projectId = null) diff --git a/PlotLine/Docs/AI/Scene-Prompt-V2.md b/PlotLine/Docs/AI/Scene-Prompt-V2.md index 277626d..ab911d9 100644 --- a/PlotLine/Docs/AI/Scene-Prompt-V2.md +++ b/PlotLine/Docs/AI/Scene-Prompt-V2.md @@ -172,32 +172,23 @@ Bad summary style: ## Metric Scoring Rules -Metrics are descriptive intensity scores from 1 to 10. They are not quality ratings. +Metrics are descriptive intensity scores. They are not quality ratings. -Use: +Use the `configuredMetrics` array supplied in `sceneContext`. Do not invent a separate metric catalogue. -- `1`: barely present; -- `2`: very low; -- `3`: low; -- `4`: mild; -- `5`: moderate; -- `6`: noticeable; -- `7`: strong; -- `8`: very strong; -- `9`: dominant; -- `10`: defining feature of the scene. +For each configured metric: -Preferred V1 metrics: +- use the exact `key` supplied in `sceneContext` as the property name in `metrics`; +- use the supplied `name`, `description`, `minValue`, `maxValue` and `defaultValue` to understand the metric; +- score only what occurs in this scene, not imagined future consequences; +- treat the scale comparatively across scenes in the book; +- use the configured minimum when the metric is absent or barely present; +- use the low range for minor/background presence; +- use the middle range for meaningful/moderate presence; +- use the high range for dominant/intense presence; +- use the configured maximum only for exceptional/extreme presence. -- `action` -- `emotion` -- `tension` -- `conflict` -- `mystery` -- `romance` -- `humour` -- `revelation` -- `pacingIntensity` +If no configured metrics are supplied, return an empty `metrics` object. Each metric must be an object: @@ -205,7 +196,7 @@ Each metric must be an object: { "score": 8, "confidence": 0.75 } ``` -Always emit every preferred V1 metric. If a metric is barely present, use score `1` with an appropriate confidence. Do not omit preferred metric objects. +Always emit every configured metric. If a metric is barely present, use the configured minimum value with an appropriate confidence. Do not omit configured metric objects. ## Character Reporting Rules @@ -680,15 +671,7 @@ Return exactly one JSON object matching this shape: } ], "metrics": { - "action": { "score": 1, "confidence": 0.0 }, - "emotion": { "score": 1, "confidence": 0.0 }, - "tension": { "score": 1, "confidence": 0.0 }, - "conflict": { "score": 1, "confidence": 0.0 }, - "mystery": { "score": 1, "confidence": 0.0 }, - "romance": { "score": 1, "confidence": 0.0 }, - "humour": { "score": 1, "confidence": 0.0 }, - "revelation": { "score": 1, "confidence": 0.0 }, - "pacingIntensity": { "score": 1, "confidence": 0.0 } + "configuredMetricKey": { "score": 1, "confidence": 0.0 } }, "sourceLimits": { "containsAmbiguity": false, diff --git a/PlotLine/Models/StoryIntelligenceNarrativeModels.cs b/PlotLine/Models/StoryIntelligenceNarrativeModels.cs new file mode 100644 index 0000000..c4a3adb --- /dev/null +++ b/PlotLine/Models/StoryIntelligenceNarrativeModels.cs @@ -0,0 +1,84 @@ +namespace PlotLine.Models; + +public static class StoryIntelligenceNarrativeCandidateTypes +{ + public const string PlotLine = "PlotLine"; + public const string Thread = "Thread"; +} + +public static class StoryIntelligenceNarrativeCandidateStatuses +{ + public const string Pending = "Pending"; + public const string Accepted = "Accepted"; + public const string Rejected = "Rejected"; + public const string Merged = "Merged"; +} + +public static class StoryIntelligenceNarrativeReviewActions +{ + public const string AcceptNew = "AcceptNew"; + public const string MergeExisting = "MergeExisting"; + public const string Reject = "Reject"; +} + +public sealed class StoryIntelligenceNarrativeCandidate +{ + public int NarrativeCandidateID { get; set; } + public int ProjectID { get; set; } + public int BookID { get; set; } + public string CandidateType { get; set; } = string.Empty; + public string CandidateKey { get; set; } = string.Empty; + public string ProposedTitle { get; set; } = string.Empty; + public string? ProposedDescription { get; set; } + public string? Category { get; set; } + public string? InferredState { get; set; } + public int? ExistingPlotLineID { get; set; } + public string? ExistingPlotLineName { get; set; } + public int? ExistingPlotThreadID { get; set; } + public string? ExistingPlotThreadTitle { get; set; } + public int? RelatedPlotLineID { get; set; } + public string? RelatedPlotLineName { get; set; } + public decimal? Confidence { get; set; } + public string Status { get; set; } = StoryIntelligenceNarrativeCandidateStatuses.Pending; + public string? ReviewAction { get; set; } + public int? AcceptedPlotLineID { get; set; } + public string? AcceptedPlotLineName { get; set; } + public int? AcceptedPlotThreadID { get; set; } + public string? AcceptedPlotThreadTitle { get; set; } + public int? ReviewedByUserID { get; set; } + public DateTime? ReviewedUtc { get; set; } + public DateTime CreatedUtc { get; set; } + public DateTime UpdatedUtc { get; set; } + public IReadOnlyList Evidence { get; set; } = []; +} + +public sealed class StoryIntelligenceNarrativeCandidateEvidence +{ + public int NarrativeCandidateEvidenceID { get; set; } + public int NarrativeCandidateID { get; set; } + public int SceneID { get; set; } + public decimal SceneNumber { get; set; } + public string SceneTitle { get; set; } = string.Empty; + public decimal ChapterNumber { get; set; } + public string ChapterTitle { get; set; } = string.Empty; + public string? ContributionType { get; set; } + public string? Evidence { get; set; } + public int SortOrder { get; set; } + public DateTime CreatedUtc { get; set; } +} + +public sealed class StoryIntelligenceNarrativeCandidateSeed +{ + public int ProjectID { get; init; } + public int BookID { get; init; } + public string CandidateType { get; init; } = string.Empty; + public string CandidateKey { get; init; } = string.Empty; + public string ProposedTitle { get; init; } = string.Empty; + public string? ProposedDescription { get; init; } + public string? Category { get; init; } + public string? InferredState { get; init; } + public int? ExistingPlotLineID { get; init; } + public int? ExistingPlotThreadID { get; init; } + public int? RelatedPlotLineID { get; init; } + public decimal? Confidence { get; init; } +} diff --git a/PlotLine/Models/StoryIntelligencePersistenceModels.cs b/PlotLine/Models/StoryIntelligencePersistenceModels.cs index 5a714dd..870f5cd 100644 --- a/PlotLine/Models/StoryIntelligencePersistenceModels.cs +++ b/PlotLine/Models/StoryIntelligencePersistenceModels.cs @@ -36,6 +36,8 @@ public static class StoryIntelligencePipelineStages public const string RelationshipImport = "RelationshipImport"; public const string KnowledgeReview = "KnowledgeReview"; public const string KnowledgeImport = "KnowledgeImport"; + public const string NarrativeReview = "NarrativeReview"; + public const string NarrativeImport = "NarrativeImport"; public const string Complete = "Complete"; } @@ -66,7 +68,7 @@ public sealed class StoryIntelligenceBookPipelineState public bool IsComplete => string.Equals(Status, StoryIntelligencePipelineStatuses.Complete, StringComparison.OrdinalIgnoreCase) - && string.Equals(LastCompletedStage, StoryIntelligencePipelineStages.KnowledgeImport, StringComparison.OrdinalIgnoreCase); + && string.Equals(LastCompletedStage, StoryIntelligencePipelineStages.NarrativeImport, StringComparison.OrdinalIgnoreCase); } public sealed class StoryIntelligenceImportNotificationTarget diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index 6470b25..e2ae28c 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -122,6 +122,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -226,6 +227,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/PlotLine/Services/OnboardingStoryIntelligenceService.cs b/PlotLine/Services/OnboardingStoryIntelligenceService.cs index f0fa816..ff92b7b 100644 --- a/PlotLine/Services/OnboardingStoryIntelligenceService.cs +++ b/PlotLine/Services/OnboardingStoryIntelligenceService.cs @@ -24,6 +24,8 @@ public interface IOnboardingStoryIntelligenceService Task GetRelationshipImportResultAsync(Guid batchId); Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportKnowledgeAsync(Guid batchId, StoryIntelligenceKnowledgeImportForm form); Task GetKnowledgeImportResultAsync(Guid batchId); + Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportNarrativeAsync(Guid batchId, StoryIntelligenceNarrativeImportForm form); + Task GetNarrativeImportResultAsync(Guid batchId); Task GetCompletionAsync(Guid batchId); Task ResumeBookAsync(int bookId); } @@ -39,6 +41,7 @@ public sealed class OnboardingStoryIntelligenceService( IStoryIntelligenceAssetImportService assetImport, IStoryIntelligenceRelationshipImportService relationshipImport, IStoryIntelligenceKnowledgeImportService knowledgeImport, + IStoryIntelligenceNarrativeReviewService narrativeReview, IStoryIntelligenceSourceRepository sources, IStoryIntelligencePipelineStateService pipelineState, IStoryMemoryRepository storyMemory, @@ -414,6 +417,7 @@ public sealed class OnboardingStoryIntelligenceService( var assetReview = await assetImport.BuildReviewAsync(batch); var relationshipReview = await relationshipImport.BuildReviewAsync(batch); var knowledgeReview = await knowledgeImport.BuildReviewAsync(batch); + var narrative = await narrativeReview.GetReviewAsync(batch); var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID); return new StoryIntelligenceProgressViewModel { @@ -430,6 +434,7 @@ public sealed class OnboardingStoryIntelligenceService( AssetReview = assetReview, RelationshipReview = relationshipReview, KnowledgeReview = knowledgeReview, + NarrativeReview = narrative, PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel { ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete), @@ -448,7 +453,12 @@ public sealed class OnboardingStoryIntelligenceService( RelationshipStageComplete = batch.RelationshipStageComplete || relationshipReview.IsComplete, KnowledgeIdentified = knowledgeReview.Candidates.Count + batch.KnowledgeDecisions.Count, KnowledgeCreatedOrLinked = batch.KnowledgeDecisions.Count(decision => decision.CreatedOrLinked), - KnowledgeStageComplete = batch.KnowledgeStageComplete || knowledgeReview.IsComplete + KnowledgeStageComplete = batch.KnowledgeStageComplete || knowledgeReview.IsComplete, + NarrativeCandidatesIdentified = narrative.Candidates.Count + (batch.LastNarrativeImportResult?.TotalReviewed ?? 0), + NarrativeCandidatesApplied = (batch.LastNarrativeImportResult?.PlotLinesCreated ?? 0) + + (batch.LastNarrativeImportResult?.PlotLinesMerged ?? 0) + + (batch.LastNarrativeImportResult?.ThreadsCreated ?? 0), + NarrativeStageComplete = batch.NarrativeStageComplete || narrative.IsComplete } }; } @@ -682,6 +692,25 @@ public sealed class OnboardingStoryIntelligenceService( }; } + public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportNarrativeAsync(Guid batchId, StoryIntelligenceNarrativeImportForm form) + { + var batch = await batchStore.GetAsync(RequireUserId(), batchId); + if (batch is null) + { + return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." }); + } + + var result = await narrativeReview.ImportAsync(batch, form, RequireUserId()); + await batchStore.SaveAsync(batch); + return (await GetProgressAsync(batchId), result); + } + + public async Task GetNarrativeImportResultAsync(Guid batchId) + { + var batch = await batchStore.GetAsync(RequireUserId(), batchId); + return batch is null ? null : await narrativeReview.GetResultAsync(batch); + } + public async Task GetCompletionAsync(Guid batchId) { var progress = await GetProgressAsync(batchId); @@ -774,6 +803,8 @@ public sealed class OnboardingStoryIntelligenceService( or StoryIntelligencePipelineStages.RelationshipImport or StoryIntelligencePipelineStages.KnowledgeReview or StoryIntelligencePipelineStages.KnowledgeImport + or StoryIntelligencePipelineStages.NarrativeReview + or StoryIntelligencePipelineStages.NarrativeImport or StoryIntelligencePipelineStages.Complete; var locationStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.LocationImport) || state.CurrentStage is StoryIntelligencePipelineStages.AssetReview @@ -782,29 +813,39 @@ public sealed class OnboardingStoryIntelligenceService( or StoryIntelligencePipelineStages.RelationshipImport or StoryIntelligencePipelineStages.KnowledgeReview or StoryIntelligencePipelineStages.KnowledgeImport + or StoryIntelligencePipelineStages.NarrativeReview + or StoryIntelligencePipelineStages.NarrativeImport or StoryIntelligencePipelineStages.Complete; var assetStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.AssetImport) || state.CurrentStage is StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport or StoryIntelligencePipelineStages.KnowledgeReview or StoryIntelligencePipelineStages.KnowledgeImport + or StoryIntelligencePipelineStages.NarrativeReview + or StoryIntelligencePipelineStages.NarrativeImport or StoryIntelligencePipelineStages.Complete; var relationshipStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.RelationshipImport) || state.CurrentStage is StoryIntelligencePipelineStages.KnowledgeReview or StoryIntelligencePipelineStages.KnowledgeImport + or StoryIntelligencePipelineStages.NarrativeReview + or StoryIntelligencePipelineStages.NarrativeImport or StoryIntelligencePipelineStages.Complete; var knowledgeStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.KnowledgeImport); + var narrativeStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.NarrativeImport); batch.CharacterStageComplete = characterStageComplete; batch.LocationStageComplete = locationStageComplete; batch.AssetStageComplete = assetStageComplete; batch.RelationshipStageComplete = relationshipStageComplete; batch.KnowledgeStageComplete = knowledgeStageComplete; + batch.NarrativeStageComplete = narrativeStageComplete; await batchStore.SaveAsync(batch); - var route = knowledgeStageComplete + var route = narrativeStageComplete ? StoryIntelligenceResumeRoutes.Complete + : knowledgeStageComplete + ? StoryIntelligenceResumeRoutes.Narrative : relationshipStageComplete ? StoryIntelligenceResumeRoutes.Knowledge : assetStageComplete @@ -819,6 +860,8 @@ public sealed class OnboardingStoryIntelligenceService( StoryIntelligencePipelineStages.RelationshipImport => StoryIntelligenceResumeRoutes.Relationships, StoryIntelligencePipelineStages.KnowledgeReview => StoryIntelligenceResumeRoutes.Knowledge, StoryIntelligencePipelineStages.KnowledgeImport => StoryIntelligenceResumeRoutes.Knowledge, + StoryIntelligencePipelineStages.NarrativeReview => StoryIntelligenceResumeRoutes.Narrative, + StoryIntelligencePipelineStages.NarrativeImport => StoryIntelligenceResumeRoutes.Narrative, StoryIntelligencePipelineStages.CharacterReview => StoryIntelligenceResumeRoutes.Characters, StoryIntelligencePipelineStages.CharacterImport => StoryIntelligenceResumeRoutes.Characters, StoryIntelligencePipelineStages.SceneReview => StoryIntelligenceResumeRoutes.SceneReview, @@ -1075,11 +1118,13 @@ public sealed class OnboardingStoryIntelligenceBatch public StoryIntelligenceAssetImportBatchResult? LastAssetImportResult { get; set; } public StoryIntelligenceRelationshipImportBatchResult? LastRelationshipImportResult { get; set; } public StoryIntelligenceKnowledgeImportBatchResult? LastKnowledgeImportResult { get; set; } + public StoryIntelligenceNarrativeImportBatchResult? LastNarrativeImportResult { get; set; } public bool CharacterStageComplete { get; set; } public bool LocationStageComplete { get; set; } public bool AssetStageComplete { get; set; } public bool RelationshipStageComplete { get; set; } public bool KnowledgeStageComplete { get; set; } + public bool NarrativeStageComplete { get; set; } } public sealed class OnboardingStoryIntelligenceBatchItem @@ -1194,6 +1239,15 @@ public sealed class StoryIntelligenceKnowledgeImportBatchResult public int KnowledgeIgnored { get; init; } } +public sealed class StoryIntelligenceNarrativeImportBatchResult +{ + public int PlotLinesCreated { get; init; } + public int PlotLinesMerged { get; init; } + public int ThreadsCreated { get; init; } + public int CandidatesRejected { get; init; } + public int TotalReviewed => PlotLinesCreated + PlotLinesMerged + ThreadsCreated + CandidatesRejected; +} + public static class StoryIntelligenceResumeRoutes { public const string SceneReview = "SceneReview"; @@ -1202,6 +1256,7 @@ public static class StoryIntelligenceResumeRoutes public const string Assets = "Assets"; public const string Relationships = "Relationships"; public const string Knowledge = "Knowledge"; + public const string Narrative = "Narrative"; public const string Complete = "Complete"; } diff --git a/PlotLine/Services/PersistedStoryIntelligenceRunner.cs b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs index 88b84b0..15aa48f 100644 --- a/PlotLine/Services/PersistedStoryIntelligenceRunner.cs +++ b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs @@ -14,6 +14,8 @@ public interface IPersistedStoryIntelligenceRunner public sealed class PersistedStoryIntelligenceRunner( IStoryIntelligenceResultRepository repository, IStoryIntelligencePipelineRepository pipelines, + ISceneMetricTypeRepository metricTypes, + IPlotRepository plots, IStoryPromptRepository prompts, IStoryPromptVersionService versions, IStoryPromptBuilder scenePromptBuilder, @@ -207,8 +209,22 @@ public sealed class PersistedStoryIntelligenceRunner( BuildValidationDetail(chapterValidation)); } + var configuredMetrics = run.ProjectID.HasValue + ? (await metricTypes.ListForManagementAsync(run.ProjectID.Value)) + .Where(metric => metric.IsActive && metric.IsEnabledForProject) + .OrderBy(metric => metric.SortOrder) + .ThenBy(metric => metric.MetricName) + .ToList() + : []; + var existingPlotLines = run.ProjectID.HasValue + ? (await plots.ListPlotLinesAsync(run.ProjectID.Value)) + .Where(plotLine => !run.BookID.HasValue || !plotLine.BookID.HasValue || plotLine.BookID == run.BookID) + .OrderBy(plotLine => plotLine.SortOrder) + .ThenBy(plotLine => plotLine.PlotLineName) + .ToList() + : []; var sceneBlocks = chapterForProcessing.SceneBoundaries - .Select(boundary => BuildSceneBlock(run, paragraphs, boundary)) + .Select(boundary => BuildSceneBlock(run, paragraphs, boundary, configuredMetrics, existingPlotLines)) .ToList(); failedScenes += sceneBlocks.Count(block => !block.SplitValid); currentFailureStage = StoryIntelligenceFailureStages.SceneSplit; @@ -843,7 +859,12 @@ public sealed class PersistedStoryIntelligenceRunner( ? SceneIntelligenceMaxOutputTokens() : settings.MaxOutputTokens; - private static StorySceneTextBlock BuildSceneBlock(StoryIntelligenceQueuedRun run, IReadOnlyList paragraphs, ChapterSceneBoundary boundary) + private static StorySceneTextBlock BuildSceneBlock( + StoryIntelligenceQueuedRun run, + IReadOnlyList paragraphs, + ChapterSceneBoundary boundary, + IReadOnlyList configuredMetrics, + IReadOnlyList existingPlotLines) { var sceneNumber = boundary.SceneNumber ?? 0; var start = boundary.StartParagraph ?? 0; @@ -862,7 +883,7 @@ public sealed class PersistedStoryIntelligenceRunner( end, sourceLabel, sceneText, - BuildSceneContextJson(run, boundary), + BuildSceneContextJson(run, boundary, configuredMetrics, existingPlotLines), true); } @@ -877,7 +898,11 @@ public sealed class PersistedStoryIntelligenceRunner( knownCharacters = KnownCharacters(run) }, JsonOptions); - private static string BuildSceneContextJson(StoryIntelligenceQueuedRun run, ChapterSceneBoundary boundary) + private static string BuildSceneContextJson( + StoryIntelligenceQueuedRun run, + ChapterSceneBoundary boundary, + IReadOnlyList configuredMetrics, + IReadOnlyList existingPlotLines) => JsonSerializer.Serialize(new { projectId = run.ProjectID, @@ -888,9 +913,30 @@ public sealed class PersistedStoryIntelligenceRunner( sceneNumber = boundary.SceneNumber, structuralSummary = boundary.StructuralSummary, sourceLabel = $"{SourceLabel(run)}, suggested scene {boundary.SceneNumber}", - knownCharacters = KnownCharacters(run) + knownCharacters = KnownCharacters(run), + configuredMetrics = configuredMetrics.Select(metric => new + { + key = MetricContextKey(metric), + metricTypeId = metric.MetricTypeID, + name = metric.MetricName, + description = metric.Description, + minValue = metric.MinValue, + maxValue = metric.MaxValue, + defaultValue = metric.DefaultValue + }), + existingPlotLines = existingPlotLines.Select(plotLine => new + { + plotLineId = plotLine.PlotLineID, + name = plotLine.PlotLineName, + description = plotLine.Description, + status = plotLine.PlotImportanceName, + bookId = plotLine.BookID + }) }, JsonOptions); + private static string MetricContextKey(SceneMetricType metric) + => $"metric_{metric.MetricTypeID}"; + private static IReadOnlyList KnownCharacters(StoryIntelligenceQueuedRun run) { if (string.IsNullOrWhiteSpace(run.KnownCharactersJson)) diff --git a/PlotLine/Services/StoryIntelligenceImportCommitService.cs b/PlotLine/Services/StoryIntelligenceImportCommitService.cs index 86c599e..ac8fdb5 100644 --- a/PlotLine/Services/StoryIntelligenceImportCommitService.cs +++ b/PlotLine/Services/StoryIntelligenceImportCommitService.cs @@ -358,6 +358,16 @@ public sealed class StoryIntelligenceImportCommitService( { var byName = metricTypes.ToDictionary(metric => Clean(metric.MetricName), metric => metric, StringComparer.OrdinalIgnoreCase); var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var metric in metricTypes) + { + map[$"metric_{metric.MetricTypeID}"] = metric; + var cleanedName = Clean(metric.MetricName); + if (!string.IsNullOrWhiteSpace(cleanedName)) + { + map.TryAdd(cleanedName, metric); + } + } + foreach (var (schemaName, aliases) in MetricAliases) { var metric = aliases.Select(alias => byName.TryGetValue(Clean(alias), out var match) ? match : null).FirstOrDefault(match => match is not null); diff --git a/PlotLine/Services/StoryIntelligenceNarrativeReviewService.cs b/PlotLine/Services/StoryIntelligenceNarrativeReviewService.cs new file mode 100644 index 0000000..5adf9ab --- /dev/null +++ b/PlotLine/Services/StoryIntelligenceNarrativeReviewService.cs @@ -0,0 +1,403 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Mvc.Rendering; +using PlotLine.Data; +using PlotLine.Models; +using PlotLine.ViewModels; + +namespace PlotLine.Services; + +public interface IStoryIntelligenceNarrativeReviewService +{ + Task GetReviewAsync(OnboardingStoryIntelligenceBatch batch); + Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceNarrativeImportForm form, int userId); + Task GetResultAsync(OnboardingStoryIntelligenceBatch batch); +} + +public sealed class StoryIntelligenceNarrativeReviewService( + IStoryIntelligenceNarrativeCandidateRepository candidates, + IStoryIntelligenceResultRepository runs, + ISceneRepository scenes, + IPlotRepository plots, + IStoryIntelligencePipelineStateService pipelineState) : IStoryIntelligenceNarrativeReviewService +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + + public async Task GetReviewAsync(OnboardingStoryIntelligenceBatch batch) + { + await EnsureCandidatesAsync(batch); + var rows = await candidates.ListByBookAsync(batch.BookID); + var pending = rows + .Where(candidate => string.Equals(candidate.Status, StoryIntelligenceNarrativeCandidateStatuses.Pending, StringComparison.OrdinalIgnoreCase)) + .ToList(); + var plotLines = await plots.ListPlotLinesAsync(batch.ProjectID); + + return new StoryIntelligenceNarrativeReviewViewModel + { + HasCommittedScenes = await HasCommittedScenesAsync(batch), + CanImport = pending.Count > 0, + IsComplete = rows.Count > 0 && pending.Count == 0, + PlotLineOptions = plotLines + .OrderBy(plotLine => plotLine.SortOrder) + .ThenBy(plotLine => plotLine.PlotLineName) + .Select(plotLine => new SelectListItem(plotLine.PlotLineName, plotLine.PlotLineID.ToString())) + .ToList(), + Candidates = pending.Select(ToViewModel).ToList() + }; + } + + public async Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceNarrativeImportForm form, int userId) + { + await EnsureCandidatesAsync(batch); + var rows = (await candidates.ListByBookAsync(batch.BookID)).ToDictionary(candidate => candidate.NarrativeCandidateID); + var lookups = await plots.GetLookupsAsync(); + var plotLineTypeId = lookups.PlotLineTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Main Plot", StringComparison.OrdinalIgnoreCase))?.PlotLineTypeID + ?? lookups.PlotLineTypes.FirstOrDefault()?.PlotLineTypeID + ?? 0; + var plotImportanceId = lookups.PlotImportance.FirstOrDefault(importance => string.Equals(importance.ImportanceName, "Primary", StringComparison.OrdinalIgnoreCase))?.PlotImportanceID + ?? lookups.PlotImportance.FirstOrDefault()?.PlotImportanceID + ?? 0; + var threadTypeId = lookups.ThreadTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Question", StringComparison.OrdinalIgnoreCase))?.ThreadTypeID + ?? lookups.ThreadTypes.FirstOrDefault()?.ThreadTypeID + ?? 0; + var threadStatusId = lookups.ThreadStatuses.FirstOrDefault(status => string.Equals(status.StatusName, "Open", StringComparison.OrdinalIgnoreCase))?.ThreadStatusID + ?? lookups.ThreadStatuses.FirstOrDefault()?.ThreadStatusID + ?? 0; + var threadEventTypeId = lookups.ThreadEventTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Raised", StringComparison.OrdinalIgnoreCase))?.ThreadEventTypeID + ?? lookups.ThreadEventTypes.FirstOrDefault()?.ThreadEventTypeID + ?? 0; + var plotEventTypeId = lookups.PlotEventTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Progress", StringComparison.OrdinalIgnoreCase))?.PlotEventTypeID + ?? lookups.PlotEventTypes.FirstOrDefault()?.PlotEventTypeID + ?? 0; + + var plotLinesCreated = 0; + var plotLinesMerged = 0; + var threadsCreated = 0; + var rejected = 0; + + foreach (var choice in form.Candidates.Where(choice => choice.NarrativeCandidateID > 0)) + { + if (!rows.TryGetValue(choice.NarrativeCandidateID, out var candidate) + || !string.Equals(candidate.Status, StoryIntelligenceNarrativeCandidateStatuses.Pending, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var action = string.IsNullOrWhiteSpace(choice.Action) ? StoryIntelligenceNarrativeReviewActions.AcceptNew : choice.Action; + if (string.Equals(action, StoryIntelligenceNarrativeReviewActions.Reject, StringComparison.OrdinalIgnoreCase)) + { + await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Rejected, action, userId, null, null); + rejected++; + continue; + } + + if (string.Equals(candidate.CandidateType, StoryIntelligenceNarrativeCandidateTypes.PlotLine, StringComparison.OrdinalIgnoreCase)) + { + if (string.Equals(action, StoryIntelligenceNarrativeReviewActions.MergeExisting, StringComparison.OrdinalIgnoreCase) && choice.ExistingPlotLineID is > 0) + { + await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Merged, action, userId, choice.ExistingPlotLineID, null); + plotLinesMerged++; + continue; + } + + var plotLineId = await plots.SavePlotLineAsync(new PlotLineItem + { + ProjectID = batch.ProjectID, + BookID = batch.BookID, + PlotLineName = CleanTitle(choice.Title, candidate.ProposedTitle), + Description = CleanNullable(choice.Description) ?? candidate.ProposedDescription, + PlotLineTypeID = plotLineTypeId, + PlotImportanceID = plotImportanceId, + SortOrder = 0, + Colour = "#2f6f63", + IsVisibleOnTimeline = true + }); + await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Accepted, action, userId, plotLineId, null); + plotLinesCreated++; + continue; + } + + var relatedPlotLineId = choice.RelatedPlotLineID ?? candidate.RelatedPlotLineID ?? choice.ExistingPlotLineID; + if (relatedPlotLineId is not > 0) + { + await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Rejected, StoryIntelligenceNarrativeReviewActions.Reject, userId, null, null); + rejected++; + continue; + } + + var firstSceneId = candidate.Evidence.OrderBy(evidence => evidence.SortOrder).FirstOrDefault()?.SceneID; + var threadId = await plots.SavePlotThreadAsync(new PlotThread + { + PlotLineID = relatedPlotLineId.Value, + ThreadTitle = CleanTitle(choice.Title, candidate.ProposedTitle), + Summary = CleanNullable(choice.Description) ?? candidate.ProposedDescription, + ThreadTypeID = threadTypeId, + ThreadStatusID = threadStatusId, + Importance = 5, + IntroducedSceneID = firstSceneId + }); + + foreach (var evidence in candidate.Evidence.Where(evidence => evidence.SceneID > 0)) + { + await plots.SaveThreadEventAsync(new ThreadEvent + { + PlotThreadID = threadId, + SceneID = evidence.SceneID, + EventTypeID = threadEventTypeId, + PlotEventTypeID = plotEventTypeId, + EventTitle = string.IsNullOrWhiteSpace(evidence.ContributionType) ? candidate.ProposedTitle : evidence.ContributionType, + EventDescription = evidence.Evidence + }); + } + + await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Accepted, action, userId, null, threadId); + threadsCreated++; + } + + batch.LastNarrativeImportResult = new StoryIntelligenceNarrativeImportBatchResult + { + PlotLinesCreated = plotLinesCreated, + PlotLinesMerged = plotLinesMerged, + ThreadsCreated = threadsCreated, + CandidatesRejected = rejected + }; + batch.NarrativeStageComplete = true; + await pipelineState.RecordNarrativeImportAsync(batch.ProjectID, batch.BookID); + + return new StoryIntelligenceImportCommitResult + { + Success = true, + Message = $"Narrative review applied. {plotLinesCreated:N0} Plot Line(s) created, {threadsCreated:N0} Thread(s) created, {plotLinesMerged:N0} merged, {rejected:N0} rejected." + }; + } + + public Task GetResultAsync(OnboardingStoryIntelligenceBatch batch) + => Task.FromResult(new StoryIntelligenceNarrativeImportResultViewModel + { + BatchID = batch.BatchID, + ProjectID = batch.ProjectID, + BookID = batch.BookID, + PlotLinesCreated = batch.LastNarrativeImportResult?.PlotLinesCreated ?? 0, + PlotLinesMerged = batch.LastNarrativeImportResult?.PlotLinesMerged ?? 0, + ThreadsCreated = batch.LastNarrativeImportResult?.ThreadsCreated ?? 0, + CandidatesRejected = batch.LastNarrativeImportResult?.CandidatesRejected ?? 0 + }); + + private async Task EnsureCandidatesAsync(OnboardingStoryIntelligenceBatch batch) + { + var existingPlotLines = await plots.ListPlotLinesAsync(batch.ProjectID); + var existingThreads = await plots.ListPlotThreadsByProjectAsync(batch.ProjectID); + var existingPlotLineByKey = existingPlotLines + .GroupBy(plotLine => Key(plotLine.PlotLineName), StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); + var existingThreadByKey = existingThreads + .GroupBy(thread => Key(thread.ThreadTitle), StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); + var signals = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var item in batch.Items) + { + var commit = await runs.GetImportCommitAsync(item.RunID); + if (commit is null || !string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID)) + .Where(scene => scene.ImportRunID == item.RunID) + .ToList(); + var importedByRange = importedScenes + .Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue) + .ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase); + var importedByNumber = importedScenes.ToDictionary(scene => Convert.ToInt32(scene.SceneNumber), scene => scene); + var sceneResults = await runs.ListSceneResultsAsync(item.RunID); + + foreach (var sceneResult in sceneResults) + { + var importedScene = ResolveImportedScene(sceneResult, importedByRange, importedByNumber); + var parsed = TryReadScene(sceneResult); + if (importedScene is null || parsed is null) + { + continue; + } + + foreach (var question in parsed.QuestionsRaised ?? []) + { + AddSignal(signals, batch, importedScene, CleanNullable(question.Question), "Question", "Open", question.Evidence, question.Confidence); + } + + foreach (var clue in parsed.TimelineClues ?? []) + { + AddSignal(signals, batch, importedScene, CleanNullable(clue.Clue), "Timeline clue", "Introduced", clue.Evidence, clue.Confidence); + } + } + } + + foreach (var signal in signals.Values.Where(signal => signal.SceneIds.Count > 1).OrderBy(signal => signal.Title)) + { + var existingThread = existingThreadByKey.GetValueOrDefault(Key(signal.Title)); + var existingPlotLine = existingPlotLineByKey.GetValueOrDefault(Key(signal.Title)); + var candidateType = signal.SceneIds.Count >= 3 && existingThread is null + ? StoryIntelligenceNarrativeCandidateTypes.PlotLine + : StoryIntelligenceNarrativeCandidateTypes.Thread; + var candidateId = await candidates.SaveAsync(new StoryIntelligenceNarrativeCandidateSeed + { + ProjectID = batch.ProjectID, + BookID = batch.BookID, + CandidateType = candidateType, + CandidateKey = $"{candidateType}:{Key(signal.Title)}", + ProposedTitle = signal.Title, + ProposedDescription = signal.Description, + Category = signal.Category, + InferredState = signal.State, + ExistingPlotLineID = existingPlotLine?.PlotLineID, + ExistingPlotThreadID = existingThread?.PlotThreadID, + RelatedPlotLineID = existingThread?.PlotLineID, + Confidence = signal.Confidence + }); + + var sortOrder = 0; + foreach (var item in signal.Evidence.OrderBy(item => item.SceneID)) + { + await candidates.SaveEvidenceAsync(candidateId, item.SceneID, item.ContributionType, item.Evidence, sortOrder++); + } + } + } + + private async Task HasCommittedScenesAsync(OnboardingStoryIntelligenceBatch batch) + { + foreach (var item in batch.Items) + { + var commit = await runs.GetImportCommitAsync(item.RunID); + if (commit is not null && string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static StoryIntelligenceNarrativeReviewCandidateViewModel ToViewModel(StoryIntelligenceNarrativeCandidate candidate) + => new() + { + NarrativeCandidateID = candidate.NarrativeCandidateID, + CandidateType = candidate.CandidateType, + ProposedTitle = candidate.ProposedTitle, + ProposedDescription = candidate.ProposedDescription ?? string.Empty, + Category = candidate.Category ?? string.Empty, + InferredState = candidate.InferredState ?? string.Empty, + Confidence = candidate.Confidence.HasValue ? $"{candidate.Confidence.Value:P0}" : "Unknown", + ExistingPlotLineID = candidate.ExistingPlotLineID, + RelatedPlotLineID = candidate.RelatedPlotLineID, + RelatedPlotLineName = candidate.RelatedPlotLineName, + DefaultAction = candidate.ExistingPlotLineID.HasValue || candidate.ExistingPlotThreadID.HasValue + ? StoryIntelligenceNarrativeReviewActions.MergeExisting + : StoryIntelligenceNarrativeReviewActions.AcceptNew, + Evidence = candidate.Evidence.Select(evidence => new StoryIntelligenceNarrativeEvidenceViewModel + { + SceneID = evidence.SceneID, + SceneLabel = $"Chapter {evidence.ChapterNumber:0.##}, Scene {evidence.SceneNumber:0.##}: {evidence.SceneTitle}", + ContributionType = evidence.ContributionType ?? string.Empty, + Evidence = evidence.Evidence ?? string.Empty + }).ToList() + }; + + private static void AddSignal( + Dictionary signals, + OnboardingStoryIntelligenceBatch batch, + Scene scene, + string? title, + string category, + string state, + string? evidence, + decimal? confidence) + { + if (string.IsNullOrWhiteSpace(title) || title.Length < 12) + { + return; + } + + var key = Key(title); + if (!signals.TryGetValue(key, out var signal)) + { + signal = new NarrativeSignal(batch.ProjectID, batch.BookID, title, category, state); + signals[key] = signal; + } + + signal.SceneIds.Add(scene.SceneID); + signal.Confidence = confidence.HasValue + ? Math.Max(signal.Confidence ?? 0m, confidence.Value) + : signal.Confidence; + signal.Evidence.Add(new NarrativeSignalEvidence(scene.SceneID, category, evidence)); + } + + private static Scene? ResolveImportedScene( + StoryIntelligenceSavedSceneResult sceneResult, + IReadOnlyDictionary importedByRange, + IReadOnlyDictionary importedByNumber) + { + if (sceneResult.StartParagraph.HasValue + && sceneResult.EndParagraph.HasValue + && importedByRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var byRange)) + { + return byRange; + } + + return importedByNumber.TryGetValue(sceneResult.TemporarySceneNumber, out var byNumber) ? byNumber : null; + } + + private static SceneIntelligenceScene? TryReadScene(StoryIntelligenceSavedSceneResult result) + { + foreach (var json in new[] { result.ParsedJson, result.OutputTextJson }) + { + if (string.IsNullOrWhiteSpace(json)) + { + continue; + } + + try + { + var direct = JsonSerializer.Deserialize(json, JsonOptions); + if (direct?.Summary is not null) + { + return direct; + } + + using var document = JsonDocument.Parse(json); + if (document.RootElement.TryGetProperty("parsedScene", out var parsedScene)) + { + return parsedScene.Deserialize(JsonOptions); + } + } + catch (JsonException) + { + // Try the next persisted JSON form. + } + } + + return null; + } + + private static string CleanTitle(string? preferred, string fallback) + => string.IsNullOrWhiteSpace(preferred) ? fallback.Trim() : preferred.Trim(); + + private static string? CleanNullable(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private static string Key(string? value) + => new((value ?? string.Empty).Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray()); + + private sealed record NarrativeSignal(int ProjectID, int BookID, string Title, string Category, string State) + { + public HashSet SceneIds { get; } = []; + public List Evidence { get; } = []; + public decimal? Confidence { get; set; } + public string Description => $"{Category} recurring across {SceneIds.Count:N0} scenes."; + } + + private sealed record NarrativeSignalEvidence(int SceneID, string ContributionType, string? Evidence); +} diff --git a/PlotLine/Services/StoryIntelligencePipelineStateService.cs b/PlotLine/Services/StoryIntelligencePipelineStateService.cs index 474f816..d276322 100644 --- a/PlotLine/Services/StoryIntelligencePipelineStateService.cs +++ b/PlotLine/Services/StoryIntelligencePipelineStateService.cs @@ -15,6 +15,7 @@ public interface IStoryIntelligencePipelineStateService Task RecordAssetImportAsync(int projectId, int bookId); Task RecordRelationshipImportAsync(int projectId, int bookId); Task RecordKnowledgeImportAsync(int projectId, int bookId); + Task RecordNarrativeImportAsync(int projectId, int bookId); Task> ListCommittedRunsByBookAsync(int bookId, int userId); } @@ -160,8 +161,23 @@ public sealed class StoryIntelligencePipelineStateService( { ProjectID = projectId, BookID = bookId, - CurrentStage = StoryIntelligencePipelineStages.Complete, + CurrentStage = StoryIntelligencePipelineStages.NarrativeReview, LastCompletedStage = StoryIntelligencePipelineStages.KnowledgeImport, + CurrentReviewStage = StoryIntelligencePipelineStages.NarrativeReview, + Status = StoryIntelligencePipelineStatuses.NeedsReview, + CompletedUtc = null, + LastRunID = null + }); + } + + public async Task RecordNarrativeImportAsync(int projectId, int bookId) + { + await pipelines.UpsertAsync(new StoryIntelligenceBookPipelineSaveRequest + { + ProjectID = projectId, + BookID = bookId, + CurrentStage = StoryIntelligencePipelineStages.Complete, + LastCompletedStage = StoryIntelligencePipelineStages.NarrativeImport, CurrentReviewStage = null, Status = StoryIntelligencePipelineStatuses.Complete, CompletedUtc = DateTime.UtcNow, diff --git a/PlotLine/Services/StorySceneValidator.cs b/PlotLine/Services/StorySceneValidator.cs index c057068..7b8079e 100644 --- a/PlotLine/Services/StorySceneValidator.cs +++ b/PlotLine/Services/StorySceneValidator.cs @@ -16,7 +16,6 @@ public sealed class StorySceneValidator : IStorySceneValidator private static readonly HashSet KnowledgeChangeTypes = CreateSet("Learns", "Realises", "IsTold", "Discovers", "Confirms", "Suspects", "Misunderstands"); private static readonly HashSet ObservationTypes = CreateSet("CharacterAction", "CharacterKnowledge", "CharacterLocation", "RelationshipSignal", "AssetInteraction", "LocationSignal", "TimelineSignal", "QuestionSignal"); private static readonly HashSet EntityTypes = CreateSet("Character", "Location", "Asset", "Knowledge", "Relationship", "Timeline", "Scene", "Unknown"); - private static readonly HashSet PreferredMetrics = CreateSet("action", "emotion", "tension", "conflict", "mystery", "romance", "humour", "revelation", "pacingIntensity"); private static readonly HashSet RelationshipSignals = CreateSet("trust", "mistrust", "affection", "fear", "authority", "secrecy", "conflict", "dependence", "protection", "rivalry", "obligation"); public ValidationResult Validate(SceneIntelligenceScene? scene) @@ -399,24 +398,15 @@ public sealed class StorySceneValidator : IStorySceneValidator foreach (var (name, metric) in metrics) { var path = $"metrics.{name}"; - if (!PreferredMetrics.Contains(name)) - { - AddWarning(result, path, "Metric is outside the preferred V1 metric set.", "Use preferred V1 metrics unless the schema is intentionally expanded."); - } - if (metric is null) { - AddError(result, path, "Metric object is required when a metric is present.", "Return { \"score\": 1-10, \"confidence\": 0.0-1.0 }."); + AddError(result, path, "Metric object is required when a metric is present.", "Return { \"score\": configured-scale-number, \"confidence\": 0.0-1.0 }."); continue; } if (!metric.Score.HasValue) { - AddError(result, $"{path}.score", "Metric score is required.", "Return a score from 1 to 10."); - } - else if (metric.Score < 1 || metric.Score > 10) - { - AddError(result, $"{path}.score", "Metric score must be from 1 to 10.", "Use the V1 1-10 descriptive intensity scale."); + AddError(result, $"{path}.score", "Metric score is required.", "Return a numeric score using the configured metric scale."); } Confidence(metric.Confidence, $"{path}.confidence", result); diff --git a/PlotLine/Sql/124_Phase20Z2_StoryIntelligenceCommitAndProjectHardDelete.sql b/PlotLine/Sql/124_Phase20Z2_StoryIntelligenceCommitAndProjectHardDelete.sql index f55f84a..ff28979 100644 --- a/PlotLine/Sql/124_Phase20Z2_StoryIntelligenceCommitAndProjectHardDelete.sql +++ b/PlotLine/Sql/124_Phase20Z2_StoryIntelligenceCommitAndProjectHardDelete.sql @@ -111,6 +111,18 @@ BEGIN WHERE consent.ProjectID = @ProjectID OR EXISTS (SELECT 1 FROM @BookIDs b WHERE b.BookID = consent.BookID); + DELETE evidence + FROM dbo.StoryIntelligenceNarrativeCandidateEvidence evidence + INNER JOIN dbo.StoryIntelligenceNarrativeCandidates candidate ON candidate.NarrativeCandidateID = evidence.NarrativeCandidateID + WHERE candidate.ProjectID = @ProjectID + OR EXISTS (SELECT 1 FROM @BookIDs b WHERE b.BookID = candidate.BookID) + OR EXISTS (SELECT 1 FROM @SceneIDs scene WHERE scene.SceneID = evidence.SceneID); + + DELETE candidate + FROM dbo.StoryIntelligenceNarrativeCandidates candidate + WHERE candidate.ProjectID = @ProjectID + OR EXISTS (SELECT 1 FROM @BookIDs b WHERE b.BookID = candidate.BookID); + DELETE FROM dbo.ProjectActivity WHERE ProjectID = @ProjectID; DELETE prh FROM dbo.ProjectRestoreHistory prh diff --git a/PlotLine/Sql/134_Phase20AV_ArchivedBookProjectPermanentDeletion.sql b/PlotLine/Sql/134_Phase20AV_ArchivedBookProjectPermanentDeletion.sql index 66a047f..50b84fa 100644 --- a/PlotLine/Sql/134_Phase20AV_ArchivedBookProjectPermanentDeletion.sql +++ b/PlotLine/Sql/134_Phase20AV_ArchivedBookProjectPermanentDeletion.sql @@ -118,6 +118,14 @@ BEGIN DELETE FROM dbo.StoryIntelligenceConsents WHERE BookID = @BookID; + DELETE evidence + FROM dbo.StoryIntelligenceNarrativeCandidateEvidence evidence + INNER JOIN dbo.StoryIntelligenceNarrativeCandidates candidate ON candidate.NarrativeCandidateID = evidence.NarrativeCandidateID + WHERE candidate.BookID = @BookID + OR EXISTS (SELECT 1 FROM @SceneIDs scene WHERE scene.SceneID = evidence.SceneID); + + DELETE FROM dbo.StoryIntelligenceNarrativeCandidates WHERE BookID = @BookID; + DELETE petpl FROM dbo.PlotEventTargetPlotLines petpl INNER JOIN dbo.ThreadEvents te ON te.ThreadEventID = petpl.PlotEventID diff --git a/PlotLine/Sql/155_Phase25_StoryIntelligenceNarrativeReview.sql b/PlotLine/Sql/155_Phase25_StoryIntelligenceNarrativeReview.sql new file mode 100644 index 0000000..b338968 --- /dev/null +++ b/PlotLine/Sql/155_Phase25_StoryIntelligenceNarrativeReview.sql @@ -0,0 +1,209 @@ +IF OBJECT_ID(N'dbo.StoryIntelligenceNarrativeCandidateEvidence', N'U') IS NULL +BEGIN + CREATE TABLE dbo.StoryIntelligenceNarrativeCandidateEvidence + ( + NarrativeCandidateEvidenceID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryIntelligenceNarrativeCandidateEvidence PRIMARY KEY, + NarrativeCandidateID int NOT NULL, + SceneID int NOT NULL, + ContributionType nvarchar(40) NULL, + Evidence nvarchar(1000) NULL, + SortOrder int NOT NULL CONSTRAINT DF_StoryIntelligenceNarrativeCandidateEvidence_SortOrder DEFAULT 0, + CreatedUtc datetime2 NOT NULL CONSTRAINT DF_StoryIntelligenceNarrativeCandidateEvidence_CreatedUtc DEFAULT SYSUTCDATETIME() + ); +END; + +IF OBJECT_ID(N'dbo.StoryIntelligenceNarrativeCandidates', N'U') IS NULL +BEGIN + CREATE TABLE dbo.StoryIntelligenceNarrativeCandidates + ( + NarrativeCandidateID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryIntelligenceNarrativeCandidates PRIMARY KEY, + ProjectID int NOT NULL, + BookID int NOT NULL, + CandidateType nvarchar(20) NOT NULL, + CandidateKey nvarchar(220) NOT NULL, + ProposedTitle nvarchar(200) NOT NULL, + ProposedDescription nvarchar(1000) NULL, + Category nvarchar(80) NULL, + InferredState nvarchar(40) NULL, + ExistingPlotLineID int NULL, + ExistingPlotThreadID int NULL, + RelatedPlotLineID int NULL, + Confidence decimal(5,4) NULL, + Status nvarchar(20) NOT NULL CONSTRAINT DF_StoryIntelligenceNarrativeCandidates_Status DEFAULT N'Pending', + ReviewAction nvarchar(40) NULL, + AcceptedPlotLineID int NULL, + AcceptedPlotThreadID int NULL, + ReviewedByUserID int NULL, + ReviewedUtc datetime2 NULL, + CreatedUtc datetime2 NOT NULL CONSTRAINT DF_StoryIntelligenceNarrativeCandidates_CreatedUtc DEFAULT SYSUTCDATETIME(), + UpdatedUtc datetime2 NOT NULL CONSTRAINT DF_StoryIntelligenceNarrativeCandidates_UpdatedUtc DEFAULT SYSUTCDATETIME(), + CONSTRAINT FK_StoryIntelligenceNarrativeCandidates_Projects FOREIGN KEY (ProjectID) REFERENCES dbo.Projects(ProjectID), + CONSTRAINT FK_StoryIntelligenceNarrativeCandidates_Books FOREIGN KEY (BookID) REFERENCES dbo.Books(BookID), + CONSTRAINT FK_StoryIntelligenceNarrativeCandidates_ExistingPlotLines FOREIGN KEY (ExistingPlotLineID) REFERENCES dbo.PlotLines(PlotLineID), + CONSTRAINT FK_StoryIntelligenceNarrativeCandidates_RelatedPlotLines FOREIGN KEY (RelatedPlotLineID) REFERENCES dbo.PlotLines(PlotLineID), + CONSTRAINT FK_StoryIntelligenceNarrativeCandidates_AcceptedPlotLines FOREIGN KEY (AcceptedPlotLineID) REFERENCES dbo.PlotLines(PlotLineID), + CONSTRAINT FK_StoryIntelligenceNarrativeCandidates_ExistingPlotThreads FOREIGN KEY (ExistingPlotThreadID) REFERENCES dbo.PlotThreads(PlotThreadID), + CONSTRAINT FK_StoryIntelligenceNarrativeCandidates_AcceptedPlotThreads FOREIGN KEY (AcceptedPlotThreadID) REFERENCES dbo.PlotThreads(PlotThreadID) + ); +END; + +IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_StoryIntelligenceNarrativeCandidateEvidence_Candidates') +BEGIN + ALTER TABLE dbo.StoryIntelligenceNarrativeCandidateEvidence + ADD CONSTRAINT FK_StoryIntelligenceNarrativeCandidateEvidence_Candidates + FOREIGN KEY (NarrativeCandidateID) REFERENCES dbo.StoryIntelligenceNarrativeCandidates(NarrativeCandidateID) ON DELETE CASCADE; +END; + +IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_StoryIntelligenceNarrativeCandidateEvidence_Scenes') +BEGIN + ALTER TABLE dbo.StoryIntelligenceNarrativeCandidateEvidence + ADD CONSTRAINT FK_StoryIntelligenceNarrativeCandidateEvidence_Scenes + FOREIGN KEY (SceneID) REFERENCES dbo.Scenes(SceneID); +END; + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_StoryIntelligenceNarrativeCandidates_BookTypeKey' AND object_id = OBJECT_ID(N'dbo.StoryIntelligenceNarrativeCandidates')) + CREATE UNIQUE INDEX UX_StoryIntelligenceNarrativeCandidates_BookTypeKey ON dbo.StoryIntelligenceNarrativeCandidates(BookID, CandidateType, CandidateKey); + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_StoryIntelligenceNarrativeCandidates_Status' AND object_id = OBJECT_ID(N'dbo.StoryIntelligenceNarrativeCandidates')) + CREATE INDEX IX_StoryIntelligenceNarrativeCandidates_Status ON dbo.StoryIntelligenceNarrativeCandidates(BookID, Status, CandidateType); + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_StoryIntelligenceNarrativeCandidateEvidence_Candidate' AND object_id = OBJECT_ID(N'dbo.StoryIntelligenceNarrativeCandidateEvidence')) + CREATE INDEX IX_StoryIntelligenceNarrativeCandidateEvidence_Candidate ON dbo.StoryIntelligenceNarrativeCandidateEvidence(NarrativeCandidateID, SceneID); +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceNarrativeCandidate_Save + @ProjectID int, + @BookID int, + @CandidateType nvarchar(20), + @CandidateKey nvarchar(220), + @ProposedTitle nvarchar(200), + @ProposedDescription nvarchar(1000) = NULL, + @Category nvarchar(80) = NULL, + @InferredState nvarchar(40) = NULL, + @ExistingPlotLineID int = NULL, + @ExistingPlotThreadID int = NULL, + @RelatedPlotLineID int = NULL, + @Confidence decimal(5,4) = NULL +AS +BEGIN + SET NOCOUNT ON; + + DECLARE @NarrativeCandidateID int; + + SELECT @NarrativeCandidateID = NarrativeCandidateID + FROM dbo.StoryIntelligenceNarrativeCandidates + WHERE BookID = @BookID AND CandidateType = @CandidateType AND CandidateKey = @CandidateKey; + + IF @NarrativeCandidateID IS NULL + BEGIN + INSERT dbo.StoryIntelligenceNarrativeCandidates + (ProjectID, BookID, CandidateType, CandidateKey, ProposedTitle, ProposedDescription, Category, InferredState, + ExistingPlotLineID, ExistingPlotThreadID, RelatedPlotLineID, Confidence) + VALUES + (@ProjectID, @BookID, @CandidateType, @CandidateKey, @ProposedTitle, @ProposedDescription, @Category, @InferredState, + @ExistingPlotLineID, @ExistingPlotThreadID, @RelatedPlotLineID, @Confidence); + + SET @NarrativeCandidateID = CAST(SCOPE_IDENTITY() AS int); + END + ELSE + BEGIN + UPDATE dbo.StoryIntelligenceNarrativeCandidates + SET ProposedTitle = @ProposedTitle, + ProposedDescription = @ProposedDescription, + Category = @Category, + InferredState = @InferredState, + ExistingPlotLineID = @ExistingPlotLineID, + ExistingPlotThreadID = @ExistingPlotThreadID, + RelatedPlotLineID = @RelatedPlotLineID, + Confidence = @Confidence, + UpdatedUtc = SYSUTCDATETIME() + WHERE NarrativeCandidateID = @NarrativeCandidateID + AND Status = N'Pending'; + END; + + SELECT @NarrativeCandidateID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceNarrativeCandidateEvidence_Save + @NarrativeCandidateID int, + @SceneID int, + @ContributionType nvarchar(40) = NULL, + @Evidence nvarchar(1000) = NULL, + @SortOrder int = 0 +AS +BEGIN + SET NOCOUNT ON; + + IF NOT EXISTS + ( + SELECT 1 + FROM dbo.StoryIntelligenceNarrativeCandidateEvidence + WHERE NarrativeCandidateID = @NarrativeCandidateID + AND SceneID = @SceneID + AND ISNULL(Evidence, N'') = ISNULL(@Evidence, N'') + ) + BEGIN + INSERT dbo.StoryIntelligenceNarrativeCandidateEvidence (NarrativeCandidateID, SceneID, ContributionType, Evidence, SortOrder) + VALUES (@NarrativeCandidateID, @SceneID, @ContributionType, @Evidence, @SortOrder); + END; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceNarrativeCandidate_ListByBook + @BookID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT nc.NarrativeCandidateID, nc.ProjectID, nc.BookID, nc.CandidateType, nc.CandidateKey, + nc.ProposedTitle, nc.ProposedDescription, nc.Category, nc.InferredState, + nc.ExistingPlotLineID, epl.PlotLineName AS ExistingPlotLineName, + nc.ExistingPlotThreadID, ept.ThreadTitle AS ExistingPlotThreadTitle, + nc.RelatedPlotLineID, rpl.PlotLineName AS RelatedPlotLineName, + nc.Confidence, nc.Status, nc.ReviewAction, nc.AcceptedPlotLineID, apl.PlotLineName AS AcceptedPlotLineName, + nc.AcceptedPlotThreadID, apt.ThreadTitle AS AcceptedPlotThreadTitle, + nc.ReviewedByUserID, nc.ReviewedUtc, nc.CreatedUtc, nc.UpdatedUtc + FROM dbo.StoryIntelligenceNarrativeCandidates nc + LEFT JOIN dbo.PlotLines epl ON epl.PlotLineID = nc.ExistingPlotLineID + LEFT JOIN dbo.PlotThreads ept ON ept.PlotThreadID = nc.ExistingPlotThreadID + LEFT JOIN dbo.PlotLines rpl ON rpl.PlotLineID = nc.RelatedPlotLineID + LEFT JOIN dbo.PlotLines apl ON apl.PlotLineID = nc.AcceptedPlotLineID + LEFT JOIN dbo.PlotThreads apt ON apt.PlotThreadID = nc.AcceptedPlotThreadID + WHERE nc.BookID = @BookID + ORDER BY CASE nc.Status WHEN N'Pending' THEN 0 ELSE 1 END, nc.CandidateType, nc.ProposedTitle; + + SELECT e.NarrativeCandidateEvidenceID, e.NarrativeCandidateID, e.SceneID, + s.SceneNumber, s.SceneTitle, c.ChapterNumber, c.ChapterTitle, + e.ContributionType, e.Evidence, e.SortOrder, e.CreatedUtc + FROM dbo.StoryIntelligenceNarrativeCandidateEvidence e + INNER JOIN dbo.StoryIntelligenceNarrativeCandidates nc ON nc.NarrativeCandidateID = e.NarrativeCandidateID + INNER JOIN dbo.Scenes s ON s.SceneID = e.SceneID + INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID + WHERE nc.BookID = @BookID + ORDER BY e.NarrativeCandidateID, c.SortOrder, s.SortOrder, e.SortOrder; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceNarrativeCandidate_MarkReviewed + @NarrativeCandidateID int, + @Status nvarchar(20), + @ReviewAction nvarchar(40), + @ReviewedByUserID int, + @AcceptedPlotLineID int = NULL, + @AcceptedPlotThreadID int = NULL +AS +BEGIN + SET NOCOUNT ON; + + UPDATE dbo.StoryIntelligenceNarrativeCandidates + SET Status = @Status, + ReviewAction = @ReviewAction, + ReviewedByUserID = @ReviewedByUserID, + ReviewedUtc = SYSUTCDATETIME(), + AcceptedPlotLineID = @AcceptedPlotLineID, + AcceptedPlotThreadID = @AcceptedPlotThreadID, + UpdatedUtc = SYSUTCDATETIME() + WHERE NarrativeCandidateID = @NarrativeCandidateID; +END; +GO diff --git a/PlotLine/ViewModels/OnboardingViewModels.cs b/PlotLine/ViewModels/OnboardingViewModels.cs index 26ece0e..fc123a8 100644 --- a/PlotLine/ViewModels/OnboardingViewModels.cs +++ b/PlotLine/ViewModels/OnboardingViewModels.cs @@ -153,6 +153,7 @@ public sealed class StoryIntelligenceProgressViewModel public StoryIntelligenceAssetReviewViewModel AssetReview { get; init; } = new(); public StoryIntelligenceRelationshipReviewViewModel RelationshipReview { get; init; } = new(); public StoryIntelligenceKnowledgeReviewViewModel KnowledgeReview { get; init; } = new(); + public StoryIntelligenceNarrativeReviewViewModel NarrativeReview { get; init; } = new(); public StoryIntelligencePipelineDashboardViewModel PipelineDashboard { get; init; } = new(); public int ChapterCount => Chapters.Count; public int CompletedChapterCount => Chapters.Count(chapter => chapter.IsRunComplete); @@ -172,6 +173,7 @@ public sealed class StoryIntelligenceProgressViewModel public bool HasAssetsToReview => AssetReview.Candidates.Count > 0; public bool HasRelationshipsToReview => RelationshipReview.Candidates.Count > 0; public bool HasKnowledgeToReview => KnowledgeReview.Candidates.Count > 0; + public bool HasNarrativeToReview => NarrativeReview.Candidates.Count > 0; } public sealed class StoryIntelligencePipelineDashboardViewModel @@ -193,6 +195,70 @@ public sealed class StoryIntelligencePipelineDashboardViewModel public int KnowledgeIdentified { get; init; } public int KnowledgeCreatedOrLinked { get; init; } public bool KnowledgeStageComplete { get; init; } + public int NarrativeCandidatesIdentified { get; init; } + public int NarrativeCandidatesApplied { get; init; } + public bool NarrativeStageComplete { get; init; } +} + +public sealed class StoryIntelligenceNarrativeReviewViewModel +{ + public bool CanImport { get; init; } + public bool HasCommittedScenes { get; init; } + public bool IsComplete { get; init; } + public IReadOnlyList Candidates { get; init; } = []; + public IReadOnlyList PlotLineOptions { get; init; } = []; +} + +public sealed class StoryIntelligenceNarrativeReviewCandidateViewModel +{ + public int NarrativeCandidateID { get; init; } + public string CandidateType { get; init; } = string.Empty; + public string ProposedTitle { get; init; } = string.Empty; + public string ProposedDescription { get; init; } = string.Empty; + public string Category { get; init; } = string.Empty; + public string InferredState { get; init; } = string.Empty; + public string Confidence { get; init; } = "Unknown"; + public int? ExistingPlotLineID { get; init; } + public int? RelatedPlotLineID { get; init; } + public string? RelatedPlotLineName { get; init; } + public string DefaultAction { get; init; } = StoryIntelligenceNarrativeReviewActions.AcceptNew; + public IReadOnlyList Evidence { get; init; } = []; +} + +public sealed class StoryIntelligenceNarrativeEvidenceViewModel +{ + public int SceneID { get; init; } + public string SceneLabel { get; init; } = string.Empty; + public string ContributionType { get; init; } = string.Empty; + public string Evidence { get; init; } = string.Empty; +} + +public sealed class StoryIntelligenceNarrativeImportForm +{ + public Guid BatchID { get; set; } + public List Candidates { get; set; } = []; +} + +public sealed class StoryIntelligenceNarrativeImportChoiceForm +{ + public int NarrativeCandidateID { get; set; } + public string CandidateType { get; set; } = string.Empty; + public string Action { get; set; } = StoryIntelligenceNarrativeReviewActions.AcceptNew; + public string Title { get; set; } = string.Empty; + public string? Description { get; set; } + public int? ExistingPlotLineID { get; set; } + public int? RelatedPlotLineID { get; set; } +} + +public sealed class StoryIntelligenceNarrativeImportResultViewModel +{ + public Guid BatchID { get; init; } + public int ProjectID { get; init; } + public int BookID { get; init; } + public int PlotLinesCreated { get; init; } + public int PlotLinesMerged { get; init; } + public int ThreadsCreated { get; init; } + public int CandidatesRejected { get; init; } } public sealed class StoryIntelligenceCharacterReviewViewModel diff --git a/PlotLine/Views/Books/Details.cshtml b/PlotLine/Views/Books/Details.cshtml index 7acbc2e..5f58ff6 100644 --- a/PlotLine/Views/Books/Details.cshtml +++ b/PlotLine/Views/Books/Details.cshtml @@ -200,11 +200,13 @@ return pipeline.CurrentReviewStage switch { + StoryIntelligencePipelineStages.NarrativeReview => "Review detected Plot Lines and Threads before adding them to the story database.", StoryIntelligencePipelineStages.RelationshipReview => "Review detected relationships and decide what to create or link.", StoryIntelligencePipelineStages.AssetReview => "Review detected assets and decide what to create or link.", StoryIntelligencePipelineStages.LocationReview => "Review detected locations and decide what to create or link.", StoryIntelligencePipelineStages.CharacterReview => "Scene creation is complete. Review detected characters to continue building the story database.", StoryIntelligencePipelineStages.SceneReview => "Review prepared scenes before creating them in PlotDirector.", + _ when StoryIntelligenceReadyForNarrative(pipeline) => "Review detected Plot Lines and Threads before adding them to the story database.", _ when StoryIntelligenceReadyForRelationships(pipeline) => "Review detected relationships and decide what to create or link.", _ when StoryIntelligenceReadyForAssets(pipeline) => "Review detected assets and decide what to create or link.", _ when StoryIntelligenceReadyForLocations(pipeline) => "Review detected locations and decide what to create or link.", @@ -212,6 +214,10 @@ }; } + private static bool StoryIntelligenceReadyForNarrative(StoryIntelligenceBookPipelineState pipeline) + => string.Equals(pipeline.LastCompletedStage, StoryIntelligencePipelineStages.KnowledgeImport, StringComparison.OrdinalIgnoreCase) + || pipeline.CurrentStage is StoryIntelligencePipelineStages.NarrativeReview or StoryIntelligencePipelineStages.NarrativeImport; + private static bool StoryIntelligenceReadyForRelationships(StoryIntelligenceBookPipelineState pipeline) => string.Equals(pipeline.LastCompletedStage, StoryIntelligencePipelineStages.AssetImport, StringComparison.OrdinalIgnoreCase) || pipeline.CurrentStage is StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport; diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceKnowledgeComplete.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceKnowledgeComplete.cshtml index 2378537..aab06e1 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceKnowledgeComplete.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceKnowledgeComplete.cshtml @@ -41,7 +41,7 @@ diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceNarrative.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceNarrative.cshtml new file mode 100644 index 0000000..83ef4d6 --- /dev/null +++ b/PlotLine/Views/Onboarding/StoryIntelligenceNarrative.cshtml @@ -0,0 +1,117 @@ +@model StoryIntelligenceProgressViewModel +@{ + ViewData["Title"] = "Review Plot Lines & Threads"; +} + +
+
+ +
+

Phase 3

+

Review Story Intelligence

+

Stage 7 of 7

+

Review Plot Lines & Threads

+

Approve recurring narrative arcs and useful thread candidates before PlotDirector adds them to the canonical story database.

+
+ + @if (TempData["OnboardingStoryIntelligenceError"] is string error) + { +
@error
+ } + @if (TempData["OnboardingStoryIntelligenceMessage"] is string message) + { +
@message
+ } + + + + @if (Model.NarrativeReview.Candidates.Count == 0) + { +
+ No Plot Line or Thread candidates are waiting. +

PlotDirector did not find recurring narrative candidates that require approval, or this review has already been completed.

+
+
+ + Back to knowledge + +
+ } + else + { +
+ +
+ @for (var i = 0; i < Model.NarrativeReview.Candidates.Count; i++) + { + var candidate = Model.NarrativeReview.Candidates[i]; +
+ + + @candidate.ProposedTitle + @candidate.CandidateType @(!string.IsNullOrWhiteSpace(candidate.Category) ? $"- {candidate.Category}" : string.Empty) + + @candidate.Confidence + + + +
+
+ + +
+
+ + +
+ @if (string.Equals(candidate.CandidateType, StoryIntelligenceNarrativeCandidateTypes.Thread, StringComparison.OrdinalIgnoreCase)) + { +
+ + +
+ } +
+ Decision + + + +
+
+ + +
+
+ Supporting scenes +
    + @foreach (var evidence in candidate.Evidence) + { +
  • @evidence.SceneLabel: @(string.IsNullOrWhiteSpace(evidence.Evidence) ? evidence.ContributionType : evidence.Evidence)
  • + } +
+
+
+
+ } +
+ +
+ Back to knowledge + +
+
+ } +
+
diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceNarrativeComplete.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceNarrativeComplete.cshtml new file mode 100644 index 0000000..2352608 --- /dev/null +++ b/PlotLine/Views/Onboarding/StoryIntelligenceNarrativeComplete.cshtml @@ -0,0 +1,30 @@ +@model StoryIntelligenceNarrativeImportResultViewModel +@{ + ViewData["Title"] = "Narrative review complete"; +} + +
+
+ +
+

Build Story Database

+
+ +

Plot Lines & Threads reviewed

+
+

PlotDirector applied the approved narrative candidates and left rejected candidates out of the canonical story database.

+
+ +
+
Plot Lines created@Model.PlotLinesCreated.ToString("N0")
+
Plot Lines merged@Model.PlotLinesMerged.ToString("N0")
+
Threads created@Model.ThreadsCreated.ToString("N0")
+
Rejected@Model.CandidatesRejected.ToString("N0")
+
+ + +
+
diff --git a/PlotLine/Views/Onboarding/_StoryIntelligencePipelineSummary.cshtml b/PlotLine/Views/Onboarding/_StoryIntelligencePipelineSummary.cshtml index 9b4d7de..7e276eb 100644 --- a/PlotLine/Views/Onboarding/_StoryIntelligencePipelineSummary.cshtml +++ b/PlotLine/Views/Onboarding/_StoryIntelligencePipelineSummary.cshtml @@ -49,4 +49,12 @@ Knowledge created / linked @(Model.KnowledgeStageComplete ? $"Done: {Model.KnowledgeCreatedOrLinked:N0}" : "In review") +
+ Plot Lines / Threads found + @Model.NarrativeCandidatesIdentified.ToString("N0") +
+
+ Plot Lines / Threads applied + @(Model.NarrativeStageComplete ? $"Done: {Model.NarrativeCandidatesApplied:N0}" : "In review") +