From e8b42664f1530787ab7c9fb0511d6da24b06d3c7 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Thu, 9 Jul 2026 22:00:28 +0100 Subject: [PATCH] =?UTF-8?q?Phase=2020AS=20=E2=80=93=20Relationship=20Intel?= =?UTF-8?q?ligence=20Review=20and=20Import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...telligence_Phase20AS_RelationshipImport.md | 51 ++ PlotLine/Controllers/OnboardingController.cs | 63 ++ .../StoryIntelligencePersistenceModels.cs | 4 +- PlotLine/Program.cs | 1 + .../OnboardingStoryIntelligenceService.cs | 88 ++- .../StoryIntelligencePipelineStateService.cs | 18 +- ...ryIntelligenceRelationshipImportService.cs | 670 ++++++++++++++++++ PlotLine/Services/StoryIntelligenceService.cs | 15 +- ...e20AS_RelationshipReviewPipelineStages.sql | 38 + PlotLine/ViewModels/OnboardingViewModels.cs | 68 ++ PlotLine/Views/Books/Details.cshtml | 13 +- .../StoryIntelligenceAssetComplete.cshtml | 8 +- .../Onboarding/StoryIntelligenceAssets.cshtml | 4 +- .../StoryIntelligenceComplete.cshtml | 6 +- ...oryIntelligenceRelationshipComplete.cshtml | 52 ++ .../StoryIntelligenceRelationships.cshtml | 233 ++++++ .../_StoryIntelligencePipelineHeader.cshtml | 2 +- .../_StoryIntelligencePipelineSummary.cshtml | 8 +- 18 files changed, 1321 insertions(+), 21 deletions(-) create mode 100644 Docs/StoryIntelligence_Phase20AS_RelationshipImport.md create mode 100644 PlotLine/Services/StoryIntelligenceRelationshipImportService.cs create mode 100644 PlotLine/Sql/131_Phase20AS_RelationshipReviewPipelineStages.sql create mode 100644 PlotLine/Views/Onboarding/StoryIntelligenceRelationshipComplete.cshtml create mode 100644 PlotLine/Views/Onboarding/StoryIntelligenceRelationships.cshtml diff --git a/Docs/StoryIntelligence_Phase20AS_RelationshipImport.md b/Docs/StoryIntelligence_Phase20AS_RelationshipImport.md new file mode 100644 index 0000000..f5321a0 --- /dev/null +++ b/Docs/StoryIntelligence_Phase20AS_RelationshipImport.md @@ -0,0 +1,51 @@ +# Phase 20AS - Relationship Intelligence Review and Import + +Phase 20AS adds Relationship Review as the next implemented Story Intelligence review stage after Assets. + +## Implemented Behaviour + +- Relationship candidates are built only from stored Scene Intelligence JSON. +- No manuscript reread occurs. +- No OpenAI calls are made. +- No Story Intelligence prompts are changed. +- Duplicate relationship detections are merged by resolved character pair, ignoring direction. +- Candidates are only actionable when both character names resolve to existing PlotDirector characters or aliases. +- Existing PlotDirector relationships are suggested when the same character pair already has a relationship. +- Authors can choose: + - Create new relationship + - Link existing relationship + - Merge with another reviewed relationship + - Ignore + +## Imported Data + +Approved relationship decisions can create or link: + +- `CharacterRelationships` +- `RelationshipEvents` + +Relationship events are created only from stored relationship signals or observations already present in the scene intelligence result. The importer does not invent relationship changes. + +Imported relationship records and events are available to existing PlotDirector relationship surfaces, including character pages, relationship pages and relationship map views. + +## Pipeline Position + +The current implemented pipeline is now: + +1. Review Scenes +2. Create Scenes +3. Review Characters +4. Create Characters +5. Review Locations +6. Create Locations +7. Review Assets +8. Create Assets +9. Review Relationships +10. Create Relationships +11. Complete + +Future Story Intelligence stages remain: + +- Knowledge +- Continuity +- Timeline Events diff --git a/PlotLine/Controllers/OnboardingController.cs b/PlotLine/Controllers/OnboardingController.cs index 482e1df..17cf3eb 100644 --- a/PlotLine/Controllers/OnboardingController.cs +++ b/PlotLine/Controllers/OnboardingController.cs @@ -75,6 +75,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard return target.Route switch { StoryIntelligenceResumeRoutes.Complete => RedirectToAction(nameof(StoryIntelligenceComplete), new { batchId = target.BatchID }), + StoryIntelligenceResumeRoutes.Relationships => RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId = target.BatchID }), StoryIntelligenceResumeRoutes.Assets => RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId = target.BatchID }), StoryIntelligenceResumeRoutes.Locations => RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = target.BatchID }), StoryIntelligenceResumeRoutes.Characters => RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId = target.BatchID }), @@ -211,6 +212,40 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard : View(model); } + [HttpGet("story-intelligence/relationships")] + public async Task StoryIntelligenceRelationships(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 }); + } + + return !model.PipelineDashboard.AssetStageComplete + ? RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId }) + : View(model); + } + [HttpPost("story-intelligence/cancel")] [ValidateAntiForgeryToken] public async Task CancelStoryIntelligence(Guid batchId) @@ -330,6 +365,29 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard return model is null ? NotFound() : View(model); } + [HttpPost("story-intelligence/relationships")] + [ValidateAntiForgeryToken] + public async Task ImportStoryIntelligenceRelationships(StoryIntelligenceRelationshipImportForm form) + { + var (progress, result) = await storyIntelligence.ImportRelationshipsAsync(form.BatchID, form); + if (progress is null) + { + return NotFound(); + } + + TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; + return result.Success + ? RedirectToAction(nameof(StoryIntelligenceRelationshipComplete), new { batchId = form.BatchID }) + : RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId = form.BatchID }); + } + + [HttpGet("story-intelligence/relationships/complete")] + public async Task StoryIntelligenceRelationshipComplete(Guid batchId) + { + var model = await storyIntelligence.GetRelationshipImportResultAsync(batchId); + return model is null ? NotFound() : View(model); + } + [HttpGet("story-intelligence/complete")] public async Task StoryIntelligenceComplete(Guid batchId) { @@ -354,6 +412,11 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard return RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId }); } + if (!progress.PipelineDashboard.RelationshipStageComplete) + { + return RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId }); + } + var model = await storyIntelligence.GetCompletionAsync(batchId); return model is null ? NotFound() : View(model); } diff --git a/PlotLine/Models/StoryIntelligencePersistenceModels.cs b/PlotLine/Models/StoryIntelligencePersistenceModels.cs index 228d2dc..1eadac1 100644 --- a/PlotLine/Models/StoryIntelligencePersistenceModels.cs +++ b/PlotLine/Models/StoryIntelligencePersistenceModels.cs @@ -32,6 +32,8 @@ public static class StoryIntelligencePipelineStages public const string LocationImport = "LocationImport"; public const string AssetReview = "AssetReview"; public const string AssetImport = "AssetImport"; + public const string RelationshipReview = "RelationshipReview"; + public const string RelationshipImport = "RelationshipImport"; public const string Complete = "Complete"; } @@ -62,7 +64,7 @@ public sealed class StoryIntelligenceBookPipelineState public bool IsComplete => string.Equals(Status, StoryIntelligencePipelineStatuses.Complete, StringComparison.OrdinalIgnoreCase) - && string.Equals(LastCompletedStage, StoryIntelligencePipelineStages.AssetImport, StringComparison.OrdinalIgnoreCase); + && string.Equals(LastCompletedStage, StoryIntelligencePipelineStages.RelationshipImport, StringComparison.OrdinalIgnoreCase); } public sealed class StoryIntelligenceBookPipelineSaveRequest diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index 421d899..3302ae1 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -210,6 +210,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 65fd63e..9b50910 100644 --- a/PlotLine/Services/OnboardingStoryIntelligenceService.cs +++ b/PlotLine/Services/OnboardingStoryIntelligenceService.cs @@ -20,6 +20,8 @@ public interface IOnboardingStoryIntelligenceService Task GetLocationImportResultAsync(Guid batchId); Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportAssetsAsync(Guid batchId, StoryIntelligenceAssetImportForm form); Task GetAssetImportResultAsync(Guid batchId); + Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportRelationshipsAsync(Guid batchId, StoryIntelligenceRelationshipImportForm form); + Task GetRelationshipImportResultAsync(Guid batchId); Task GetCompletionAsync(Guid batchId); Task ResumeBookAsync(int bookId); } @@ -33,6 +35,7 @@ public sealed class OnboardingStoryIntelligenceService( IStoryIntelligenceCharacterImportService characterImport, IStoryIntelligenceLocationImportService locationImport, IStoryIntelligenceAssetImportService assetImport, + IStoryIntelligenceRelationshipImportService relationshipImport, IStoryIntelligencePipelineStateService pipelineState, IStoryIntelligenceClient client, IOnboardingStoryIntelligenceBatchStore batchStore, @@ -267,6 +270,7 @@ 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); return new StoryIntelligenceProgressViewModel { BatchID = batch.BatchID, @@ -279,6 +283,7 @@ public sealed class OnboardingStoryIntelligenceService( CharacterReview = characterReview, LocationReview = locationReview, AssetReview = assetReview, + RelationshipReview = relationshipReview, PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel { ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete), @@ -291,7 +296,10 @@ public sealed class OnboardingStoryIntelligenceService( LocationStageComplete = batch.LocationStageComplete || locationReview.IsComplete, AssetsIdentified = assetReview.Candidates.Count + batch.AssetDecisions.Count, AssetsCreatedOrLinked = batch.AssetDecisions.Count(decision => decision.CreatedOrLinked), - AssetStageComplete = batch.AssetStageComplete || assetReview.IsComplete + AssetStageComplete = batch.AssetStageComplete || assetReview.IsComplete, + RelationshipsIdentified = relationshipReview.Candidates.Count + batch.RelationshipDecisions.Count, + RelationshipsCreatedOrLinked = batch.RelationshipDecisions.Count(decision => decision.CreatedOrLinked), + RelationshipStageComplete = batch.RelationshipStageComplete || relationshipReview.IsComplete } }; } @@ -460,6 +468,39 @@ public sealed class OnboardingStoryIntelligenceService( }; } + public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportRelationshipsAsync(Guid batchId, StoryIntelligenceRelationshipImportForm 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 relationshipImport.ImportAsync(batch, form); + return (await GetProgressAsync(batchId), result); + } + + public async Task GetRelationshipImportResultAsync(Guid batchId) + { + var batch = await batchStore.GetAsync(RequireUserId(), batchId); + if (batch is null) + { + return null; + } + + return new StoryIntelligenceRelationshipImportResultViewModel + { + BatchID = batch.BatchID, + ProjectID = batch.ProjectID, + BookID = batch.BookID, + RelationshipsCreated = batch.LastRelationshipImportResult?.RelationshipsCreated ?? 0, + RelationshipsLinked = batch.LastRelationshipImportResult?.RelationshipsLinked ?? 0, + RelationshipsMerged = batch.LastRelationshipImportResult?.RelationshipsMerged ?? 0, + RelationshipsIgnored = batch.LastRelationshipImportResult?.RelationshipsIgnored ?? 0, + RelationshipEventsCreated = batch.LastRelationshipImportResult?.RelationshipEventsCreated ?? 0 + }; + } + public async Task GetCompletionAsync(Guid batchId) { var progress = await GetProgressAsync(batchId); @@ -483,7 +524,9 @@ public sealed class OnboardingStoryIntelligenceService( LocationsCreatedOrLinked = progress.PipelineDashboard.LocationsCreatedOrLinked, LocationStageComplete = progress.PipelineDashboard.LocationStageComplete, AssetsCreatedOrLinked = progress.PipelineDashboard.AssetsCreatedOrLinked, - AssetStageComplete = progress.PipelineDashboard.AssetStageComplete + AssetStageComplete = progress.PipelineDashboard.AssetStageComplete, + RelationshipsCreatedOrLinked = progress.PipelineDashboard.RelationshipsCreatedOrLinked, + RelationshipStageComplete = progress.PipelineDashboard.RelationshipStageComplete }; } @@ -530,27 +573,40 @@ public sealed class OnboardingStoryIntelligenceService( or StoryIntelligencePipelineStages.LocationImport or StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport + or StoryIntelligencePipelineStages.RelationshipReview + or StoryIntelligencePipelineStages.RelationshipImport or StoryIntelligencePipelineStages.Complete; var locationStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.LocationImport) || state.CurrentStage is StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport + or StoryIntelligencePipelineStages.RelationshipReview + or StoryIntelligencePipelineStages.RelationshipImport or StoryIntelligencePipelineStages.Complete; - var assetStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.AssetImport); + var assetStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.AssetImport) + || state.CurrentStage is StoryIntelligencePipelineStages.RelationshipReview + or StoryIntelligencePipelineStages.RelationshipImport + or StoryIntelligencePipelineStages.Complete; + var relationshipStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.RelationshipImport); batch.CharacterStageComplete = characterStageComplete; batch.LocationStageComplete = locationStageComplete; batch.AssetStageComplete = assetStageComplete; + batch.RelationshipStageComplete = relationshipStageComplete; await batchStore.SaveAsync(batch); - var route = assetStageComplete + var route = relationshipStageComplete ? StoryIntelligenceResumeRoutes.Complete - : locationStageComplete + : assetStageComplete + ? StoryIntelligenceResumeRoutes.Relationships + : locationStageComplete ? StoryIntelligenceResumeRoutes.Assets : characterStageComplete ? StoryIntelligenceResumeRoutes.Locations : state.CurrentStage switch { + StoryIntelligencePipelineStages.RelationshipReview => StoryIntelligenceResumeRoutes.Relationships, + StoryIntelligencePipelineStages.RelationshipImport => StoryIntelligenceResumeRoutes.Relationships, StoryIntelligencePipelineStages.CharacterReview => StoryIntelligenceResumeRoutes.Characters, StoryIntelligencePipelineStages.CharacterImport => StoryIntelligenceResumeRoutes.Characters, StoryIntelligencePipelineStages.SceneReview => StoryIntelligenceResumeRoutes.SceneReview, @@ -794,12 +850,15 @@ public sealed class OnboardingStoryIntelligenceBatch public List CharacterDecisions { get; } = []; public List LocationDecisions { get; } = []; public List AssetDecisions { get; } = []; + public List RelationshipDecisions { get; } = []; public StoryIntelligenceCharacterImportBatchResult? LastCharacterImportResult { get; set; } public StoryIntelligenceLocationImportBatchResult? LastLocationImportResult { get; set; } public StoryIntelligenceAssetImportBatchResult? LastAssetImportResult { get; set; } + public StoryIntelligenceRelationshipImportBatchResult? LastRelationshipImportResult { get; set; } public bool CharacterStageComplete { get; set; } public bool LocationStageComplete { get; set; } public bool AssetStageComplete { get; set; } + public bool RelationshipStageComplete { get; set; } } public sealed class OnboardingStoryIntelligenceBatchItem @@ -838,6 +897,15 @@ public sealed class OnboardingStoryIntelligenceAssetDecision public bool CreatedOrLinked { get; init; } } +public sealed class OnboardingStoryIntelligenceRelationshipDecision +{ + public string Key { get; init; } = string.Empty; + public string Action { get; init; } = string.Empty; + public int? CharacterRelationshipID { get; init; } + public string? RelationshipLabel { get; init; } + public bool CreatedOrLinked { get; init; } +} + public sealed class StoryIntelligenceCharacterImportBatchResult { public int CharactersCreated { get; init; } @@ -867,12 +935,22 @@ public sealed class StoryIntelligenceAssetImportBatchResult public int OwnershipLinksCreated { get; init; } } +public sealed class StoryIntelligenceRelationshipImportBatchResult +{ + public int RelationshipsCreated { get; init; } + public int RelationshipsLinked { get; init; } + public int RelationshipsMerged { get; init; } + public int RelationshipsIgnored { get; init; } + public int RelationshipEventsCreated { get; init; } +} + public static class StoryIntelligenceResumeRoutes { public const string SceneReview = "SceneReview"; public const string Characters = "Characters"; public const string Locations = "Locations"; public const string Assets = "Assets"; + public const string Relationships = "Relationships"; public const string Complete = "Complete"; } diff --git a/PlotLine/Services/StoryIntelligencePipelineStateService.cs b/PlotLine/Services/StoryIntelligencePipelineStateService.cs index 84b8b17..a87d539 100644 --- a/PlotLine/Services/StoryIntelligencePipelineStateService.cs +++ b/PlotLine/Services/StoryIntelligencePipelineStateService.cs @@ -12,6 +12,7 @@ public interface IStoryIntelligencePipelineStateService Task RecordCharacterImportAsync(int projectId, int bookId); Task RecordLocationImportAsync(int projectId, int bookId); Task RecordAssetImportAsync(int projectId, int bookId); + Task RecordRelationshipImportAsync(int projectId, int bookId); Task> ListCommittedRunsByBookAsync(int bookId, int userId); } @@ -112,8 +113,23 @@ public sealed class StoryIntelligencePipelineStateService( { ProjectID = projectId, BookID = bookId, - CurrentStage = StoryIntelligencePipelineStages.Complete, + CurrentStage = StoryIntelligencePipelineStages.RelationshipReview, LastCompletedStage = StoryIntelligencePipelineStages.AssetImport, + CurrentReviewStage = StoryIntelligencePipelineStages.RelationshipReview, + Status = StoryIntelligencePipelineStatuses.NeedsReview, + CompletedUtc = null, + LastRunID = null + }); + } + + public async Task RecordRelationshipImportAsync(int projectId, int bookId) + { + await pipelines.UpsertAsync(new StoryIntelligenceBookPipelineSaveRequest + { + ProjectID = projectId, + BookID = bookId, + CurrentStage = StoryIntelligencePipelineStages.Complete, + LastCompletedStage = StoryIntelligencePipelineStages.RelationshipImport, CurrentReviewStage = null, Status = StoryIntelligencePipelineStatuses.Complete, CompletedUtc = DateTime.UtcNow, diff --git a/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs b/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs new file mode 100644 index 0000000..35a11d4 --- /dev/null +++ b/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs @@ -0,0 +1,670 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using PlotLine.Data; +using PlotLine.Models; +using PlotLine.ViewModels; + +namespace PlotLine.Services; + +public interface IStoryIntelligenceRelationshipImportService +{ + Task BuildReviewAsync(OnboardingStoryIntelligenceBatch batch); + Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceRelationshipImportForm form); +} + +public sealed class StoryIntelligenceRelationshipImportService( + IStoryIntelligenceResultRepository runs, + ICharacterRepository characters, + ISceneRepository scenes, + IStoryIntelligencePipelineStateService pipelineState, + ILogger logger) : IStoryIntelligenceRelationshipImportService +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + + public async Task BuildReviewAsync(OnboardingStoryIntelligenceBatch batch) + { + var data = await BuildCandidateDataAsync(batch); + var decidedKeys = batch.RelationshipDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); + var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList(); + return new StoryIntelligenceRelationshipReviewViewModel + { + HasCommittedScenes = data.HasCommittedScenes, + CanImport = visibleCandidates.Count > 0, + AlreadyLinkedCount = data.AlreadyLinkedCount, + IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0, + Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceRelationshipReviewCandidateViewModel + { + Key = candidate.Key, + CharacterAName = candidate.CharacterAName, + CharacterBName = candidate.CharacterBName, + RelationshipType = candidate.RelationshipType, + AppearsInScenes = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count(), + Confidence = ConfidenceBand(candidate.Confidence), + FirstAppearance = candidate.FirstAppearanceNote, + LastAppearance = candidate.LastAppearanceNote, + ExampleScene = candidate.ExampleScene, + ExampleContext = candidate.ExampleContext, + ExistingRelationshipID = candidate.ExistingRelationshipID, + ExistingRelationshipLabel = candidate.ExistingRelationshipLabel + }).ToList() + }; + } + + public async Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceRelationshipImportForm form) + { + var data = await BuildCandidateDataAsync(batch); + if (!data.HasCommittedScenes) + { + return new StoryIntelligenceImportCommitResult { Success = false, Message = "Create scenes before importing relationships." }; + } + + var choices = form.Relationships + .Where(choice => !string.IsNullOrWhiteSpace(choice.Key)) + .ToDictionary(choice => choice.Key, choice => choice, StringComparer.OrdinalIgnoreCase); + if (choices.Count == 0) + { + if (data.Candidates.Count == 0) + { + batch.RelationshipStageComplete = true; + batch.LastRelationshipImportResult = new StoryIntelligenceRelationshipImportBatchResult(); + await pipelineState.RecordRelationshipImportAsync(batch.ProjectID, batch.BookID); + return new StoryIntelligenceImportCommitResult + { + Success = true, + Message = "Relationship review completed. No relationship decisions were waiting." + }; + } + + return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose at least one relationship to create, link or ignore." }; + } + + var lookupData = await characters.GetLookupsAsync(batch.ProjectID); + var created = 0; + var linkedExisting = 0; + var merged = 0; + var ignored = 0; + var eventsCreated = 0; + var resolvedRelationships = new Dictionary(StringComparer.OrdinalIgnoreCase); + var candidatesByKey = data.Candidates.ToDictionary(candidate => candidate.Key, StringComparer.OrdinalIgnoreCase); + + foreach (var candidate in data.Candidates) + { + if (!choices.TryGetValue(candidate.Key, out var choice)) + { + continue; + } + + if (string.Equals(choice.Action, StoryIntelligenceRelationshipImportActions.Ignore, StringComparison.OrdinalIgnoreCase)) + { + ignored++; + AddDecision(batch, candidate.Key, StoryIntelligenceRelationshipImportActions.Ignore, null, RelationshipLabel(candidate), false); + continue; + } + + if (string.Equals(choice.Action, StoryIntelligenceRelationshipImportActions.Alias, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var relationshipId = string.Equals(choice.Action, StoryIntelligenceRelationshipImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase) + ? candidate.ExistingRelationshipID + : null; + + if (!relationshipId.HasValue) + { + relationshipId = await characters.SaveRelationshipAsync(new CharacterRelationship + { + ProjectID = batch.ProjectID, + CharacterAID = candidate.CharacterAID, + CharacterBID = candidate.CharacterBID, + RelationshipTypeID = MatchRelationshipType(lookupData.RelationshipTypes, choice.RelationshipType ?? candidate.RelationshipType), + IsPermanent = false, + StartSceneID = candidate.Appearances.OrderBy(appearance => appearance.SceneNumber).FirstOrDefault()?.SceneID, + EndSceneID = null, + IsKnownToReader = true, + Notes = BuildRelationshipNotes(candidate), + IsInitialRelationship = false, + InitialBookID = batch.BookID, + ReaderInitiallyKnows = true, + InitialRelationshipStateID = MatchRelationshipState(lookupData.RelationshipStates, candidate.RelationshipType), + InitialIntensity = ConfidenceIntensity(candidate.Confidence), + IsReciprocal = true + }); + created++; + } + else + { + linkedExisting++; + } + + resolvedRelationships[candidate.Key] = relationshipId.Value; + AddDecision(batch, candidate.Key, choice.Action, relationshipId.Value, RelationshipLabel(candidate), true); + } + + foreach (var candidate in data.Candidates) + { + if (!choices.TryGetValue(candidate.Key, out var choice) + || !string.Equals(choice.Action, StoryIntelligenceRelationshipImportActions.Alias, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var targetKey = Clean(choice.AliasTargetKey); + if (string.IsNullOrWhiteSpace(targetKey) + || string.Equals(targetKey, candidate.Key, StringComparison.OrdinalIgnoreCase) + || !choices.TryGetValue(targetKey, out var targetChoice) + || string.Equals(targetChoice.Action, StoryIntelligenceRelationshipImportActions.Ignore, StringComparison.OrdinalIgnoreCase) + || string.Equals(targetChoice.Action, StoryIntelligenceRelationshipImportActions.Alias, StringComparison.OrdinalIgnoreCase) + || !candidatesByKey.ContainsKey(targetKey) + || !resolvedRelationships.TryGetValue(targetKey, out var targetRelationshipId)) + { + logger.LogWarning( + "Story Intelligence relationship merge candidate {CandidateKey} could not be imported because target {TargetKey} was not a resolved create/link target.", + candidate.Key, + targetKey); + continue; + } + + resolvedRelationships[candidate.Key] = targetRelationshipId; + merged++; + AddDecision(batch, candidate.Key, StoryIntelligenceRelationshipImportActions.Alias, targetRelationshipId, RelationshipLabel(candidate), true); + } + + foreach (var candidate in data.Candidates) + { + if (!choices.TryGetValue(candidate.Key, out var choice) + || string.Equals(choice.Action, StoryIntelligenceRelationshipImportActions.Ignore, StringComparison.OrdinalIgnoreCase) + || !resolvedRelationships.TryGetValue(candidate.Key, out var relationshipId)) + { + continue; + } + + foreach (var appearance in candidate.Appearances + .Where(appearance => !appearance.AlreadyLinked) + .GroupBy(appearance => appearance.SceneID) + .Select(group => group.OrderByDescending(appearance => appearance.Confidence ?? 0m).First())) + { + if (!HasMeaningfulRelationshipEvent(appearance)) + { + continue; + } + + await characters.SaveRelationshipEventAsync(new RelationshipEvent + { + CharacterRelationshipID = relationshipId, + SceneID = appearance.SceneID, + RelationshipStateID = MatchRelationshipState(lookupData.RelationshipStates, appearance.Signal ?? candidate.RelationshipType), + Intensity = ConfidenceIntensity(appearance.Confidence ?? candidate.Confidence), + IsKnownToOtherCharacter = true, + Description = BuildRelationshipEventDescription(candidate, appearance) + }); + eventsCreated++; + } + } + + batch.RelationshipStageComplete = true; + batch.LastRelationshipImportResult = new StoryIntelligenceRelationshipImportBatchResult + { + RelationshipsCreated = created, + RelationshipsLinked = linkedExisting, + RelationshipsMerged = merged, + RelationshipsIgnored = ignored, + RelationshipEventsCreated = eventsCreated + }; + 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}", + batch.BatchID, + created, + linkedExisting, + merged, + ignored, + eventsCreated); + + return new StoryIntelligenceImportCommitResult + { + Success = true, + ScenesCreated = eventsCreated, + Message = $"Relationships imported. {created:N0} created, {linkedExisting:N0} linked, {merged:N0} merged, {ignored:N0} ignored, {eventsCreated:N0} relationship event(s) added." + }; + } + + private async Task BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch) + { + var characterIndex = await BuildCharacterIndexAsync(batch.ProjectID); + var existingRelationships = await characters.ListRelationshipsByProjectAsync(batch.ProjectID); + var existingByPair = existingRelationships + .GroupBy(relationship => PairKey(relationship.CharacterAID, relationship.CharacterBID)) + .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); + var groups = new Dictionary(StringComparer.OrdinalIgnoreCase); + var alreadyLinked = 0; + var hasCommittedScenes = false; + + 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; + } + + hasCommittedScenes = true; + 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 parsed = TryReadScene(sceneResult); + if (parsed is null) + { + continue; + } + + var importedScene = ResolveImportedScene(sceneResult, importedByRange, importedByNumber); + if (importedScene is null) + { + continue; + } + + var existingEvents = await characters.ListRelationshipEventsBySceneAsync(importedScene.SceneID); + alreadyLinked += existingEvents.Select(item => item.CharacterRelationshipID).Distinct().Count(); + AddSceneRelationships(groups, characterIndex, existingByPair, importedScene, parsed, existingEvents); + } + } + + var candidates = groups.Values + .Where(candidate => candidate.Appearances.Any(appearance => !appearance.AlreadyLinked)) + .OrderByDescending(candidate => candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count()) + .ThenBy(candidate => candidate.CharacterAName) + .ThenBy(candidate => candidate.CharacterBName) + .ToList(); + + return new RelationshipCandidateData(hasCommittedScenes, alreadyLinked, candidates); + } + + private static void AddSceneRelationships( + Dictionary groups, + CharacterIndex characterIndex, + IReadOnlyDictionary existingByPair, + Scene importedScene, + SceneIntelligenceScene parsed, + IReadOnlyList existingEvents) + { + foreach (var relationship in parsed.Relationships ?? []) + { + AddRelationship(groups, characterIndex, existingByPair, importedScene, parsed, relationship.CharacterA, relationship.CharacterB, relationship.RelationshipSignal, relationship.Evidence, relationship.Confidence, existingEvents); + } + + foreach (var observation in parsed.Observations ?? []) + { + if (!IsRelationshipObservation(observation)) + { + continue; + } + + AddRelationship( + groups, + characterIndex, + existingByPair, + importedScene, + parsed, + observation.SubjectName, + observation.ObjectName, + FirstConfigured(observation.Predicate, observation.ObservationType), + FirstConfigured(observation.Description, observation.Evidence), + observation.Confidence, + existingEvents); + } + } + + private static void AddRelationship( + Dictionary groups, + CharacterIndex characterIndex, + IReadOnlyDictionary existingByPair, + Scene importedScene, + SceneIntelligenceScene parsed, + string? characterAName, + string? characterBName, + string? relationshipSignal, + string? evidence, + decimal? confidence, + IReadOnlyList existingEvents) + { + var characterA = characterIndex.Find(characterAName); + var characterB = characterIndex.Find(characterBName); + if (characterA is null || characterB is null || characterA.CharacterID == characterB.CharacterID) + { + return; + } + + var key = PairKey(characterA.CharacterID, characterB.CharacterID); + if (!groups.TryGetValue(key, out var candidate)) + { + existingByPair.TryGetValue(key, out var existingMatch); + candidate = new RelationshipCandidate( + key, + characterA.CharacterID, + characterA.CharacterName, + characterB.CharacterID, + characterB.CharacterName, + RelationshipTypeFromSignal(relationshipSignal), + existingMatch?.CharacterRelationshipID, + existingMatch is null ? null : $"{existingMatch.CharacterAName} / {existingMatch.RelationshipTypeName} / {existingMatch.CharacterBName}"); + groups[key] = candidate; + } + + if ((confidence ?? 0m) >= (candidate.Confidence ?? 0m)) + { + candidate.RelationshipType = RelationshipTypeFromSignal(relationshipSignal); + } + + candidate.Confidence = Max(candidate.Confidence, confidence); + candidate.FirstAppearanceNote ??= BuildSceneReference(importedScene); + candidate.LastAppearanceNote = BuildSceneReference(importedScene); + candidate.ExampleScene ??= BuildFirstAppearance(importedScene, parsed); + candidate.ExampleContext ??= Clean(evidence); + candidate.Appearances.Add(new RelationshipAppearanceImport( + importedScene.SceneID, + importedScene.SceneNumber, + importedScene.SceneTitle, + relationshipSignal, + evidence, + confidence, + candidate.ExistingRelationshipID.HasValue && existingEvents.Any(existing => existing.CharacterRelationshipID == candidate.ExistingRelationshipID.Value))); + } + + private async Task BuildCharacterIndexAsync(int projectId) + { + var index = new CharacterIndex(); + foreach (var character in await characters.ListCharactersAsync(projectId)) + { + index.Add(character.CharacterName, character.CharacterID, character.CharacterName); + index.Add(character.ShortName, character.CharacterID, character.CharacterName); + foreach (var alias in await characters.ListAliasesAsync(character.CharacterID)) + { + index.Add(alias.Alias, character.CharacterID, character.CharacterName); + } + } + + return index; + } + + private static Scene? ResolveImportedScene( + StoryIntelligenceSavedSceneResult sceneResult, + IReadOnlyDictionary byRange, + IReadOnlyDictionary byNumber) + { + if (sceneResult.StartParagraph.HasValue + && sceneResult.EndParagraph.HasValue + && byRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var rangeMatch)) + { + return rangeMatch; + } + + return byNumber.TryGetValue(sceneResult.TemporarySceneNumber, out var numberMatch) ? numberMatch : null; + } + + private static SceneIntelligenceScene? TryReadScene(StoryIntelligenceSavedSceneResult result) + { + if (string.IsNullOrWhiteSpace(result.ParsedJson)) + { + return null; + } + + try + { + var direct = JsonSerializer.Deserialize(result.ParsedJson, JsonOptions); + if (!string.IsNullOrWhiteSpace(direct?.SchemaVersion)) + { + return direct; + } + + using var document = JsonDocument.Parse(result.ParsedJson); + if (document.RootElement.TryGetProperty("parsedScene", out var parsedScene)) + { + return parsedScene.Deserialize(JsonOptions); + } + } + catch (JsonException) + { + } + + return null; + } + + private static bool IsRelationshipObservation(SceneIntelligenceObservation observation) + { + var type = Clean(observation.ObservationType); + if (!type.Contains("relationship", StringComparison.OrdinalIgnoreCase) + && !type.Contains("character", StringComparison.OrdinalIgnoreCase) + && !RelationshipSignalWords.Any(word => type.Contains(word, StringComparison.OrdinalIgnoreCase))) + { + return false; + } + + return IsCharacterEntity(observation.SubjectEntityType) + && IsCharacterEntity(observation.ObjectEntityType) + && !string.IsNullOrWhiteSpace(observation.SubjectName) + && !string.IsNullOrWhiteSpace(observation.ObjectName); + } + + private static int MatchRelationshipType(IReadOnlyList types, string? signal) + { + var normalised = Normalise(signal); + var mapped = RelationshipTypeFromSignal(signal); + return types.FirstOrDefault(type => string.Equals(type.TypeName, mapped, StringComparison.OrdinalIgnoreCase))?.RelationshipTypeID + ?? types.FirstOrDefault(type => normalised.Contains(Normalise(type.TypeName), StringComparison.OrdinalIgnoreCase))?.RelationshipTypeID + ?? types.FirstOrDefault(type => string.Equals(type.TypeName, "Unknown", StringComparison.OrdinalIgnoreCase))?.RelationshipTypeID + ?? types.First().RelationshipTypeID; + } + + private static int MatchRelationshipState(IReadOnlyList states, string? signal) + { + var value = Normalise(signal); + var stateName = + value.Contains("reconcil") ? "Reconciled" : + value.Contains("romantic") || value.Contains("lover") || value.Contains("love") ? "Romantic" : + value.Contains("friend") || value.Contains("ally") || value.Contains("support") ? "Friendly" : + value.Contains("argument") || value.Contains("argue") || value.Contains("conflict") || value.Contains("hostile") || value.Contains("enemy") ? "Hostile" : + value.Contains("distrust") || value.Contains("suspect") || value.Contains("suspicious") ? "Distrustful" : + value.Contains("estranged") || value.Contains("separat") || value.Contains("deteriorat") ? "Estranged" : + value.Contains("tense") || value.Contains("tension") ? "Tense" : + "Unknown"; + return states.FirstOrDefault(state => string.Equals(state.StateName, stateName, StringComparison.OrdinalIgnoreCase))?.RelationshipStateID + ?? states.FirstOrDefault(state => string.Equals(state.StateName, "Unknown", StringComparison.OrdinalIgnoreCase))?.RelationshipStateID + ?? states.First().RelationshipStateID; + } + + private static string RelationshipTypeFromSignal(string? signal) + { + var value = Normalise(signal); + if (value.Contains("mother") || value.Contains("father") || value.Contains("parent")) return "Parent"; + if (value.Contains("daughter") || value.Contains("son") || value.Contains("child")) return "Child"; + if (value.Contains("sister") || value.Contains("brother") || value.Contains("sibling")) return "Sibling"; + if (value.Contains("spouse") || value.Contains("husband") || value.Contains("wife") || value.Contains("married")) return "Spouse"; + if (value.Contains("romantic") || value.Contains("lover") || value.Contains("love")) return "Lover"; + if (value.Contains("friend")) return "Friend"; + if (value.Contains("enemy") || value.Contains("adversarial") || value.Contains("hostile")) return "Enemy"; + if (value.Contains("rival")) return "Rival"; + if (value.Contains("suspicious") || value.Contains("suspect")) return "Suspicious Of"; + if (value.Contains("neighbour") || value.Contains("neighbor")) return "Neighbour"; + if (value.Contains("teacher")) return "Teacher"; + if (value.Contains("student")) return "Student"; + if (value.Contains("employer") || value.Contains("boss")) return "Employer"; + if (value.Contains("employee")) return "Employee"; + if (value.Contains("colleague") || value.Contains("professional") || value.Contains("police") || value.Contains("detective") || value.Contains("sergeant")) return "Colleague"; + if (value.Contains("ally") || value.Contains("support")) return "Ally"; + if (value.Contains("guardian") || value.Contains("protector")) return "Protector"; + if (value.Contains("estranged") || value.Contains("separat")) return "Estranged From"; + return "Unknown"; + } + + private static bool HasMeaningfulRelationshipEvent(RelationshipAppearanceImport appearance) + { + var value = $"{appearance.Signal} {appearance.Evidence}"; + return RelationshipEventWords.Any(word => value.Contains(word, StringComparison.OrdinalIgnoreCase)); + } + + private static string BuildRelationshipNotes(RelationshipCandidate candidate) + { + var lines = new List { "Imported from Story Intelligence relationship review." }; + if (!string.IsNullOrWhiteSpace(candidate.ExampleContext)) + { + lines.Add(candidate.ExampleContext); + } + + if (!string.IsNullOrWhiteSpace(candidate.FirstAppearanceNote)) + { + lines.Add($"First detected: {candidate.FirstAppearanceNote}"); + } + + return string.Join(Environment.NewLine, lines); + } + + private static string BuildRelationshipEventDescription(RelationshipCandidate candidate, RelationshipAppearanceImport appearance) + => FirstConfigured( + Clean(appearance.Evidence), + Clean(appearance.Signal), + $"{candidate.CharacterAName} / {candidate.RelationshipType} / {candidate.CharacterBName}") ?? RelationshipLabel(candidate); + + private static string RelationshipLabel(RelationshipCandidate candidate) + => $"{candidate.CharacterAName} / {candidate.RelationshipType} / {candidate.CharacterBName}"; + + private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed) + { + var summary = Clean(parsed.Summary?.Short); + return string.IsNullOrWhiteSpace(summary) + ? BuildSceneReference(scene) + : $"{BuildSceneReference(scene)}: {summary}"; + } + + private static string BuildSceneReference(Scene scene) + => $"Scene {scene.SceneNumber:g}"; + + private static void AddDecision(OnboardingStoryIntelligenceBatch batch, string key, string action, int? relationshipId, string? label, bool createdOrLinked) + { + batch.RelationshipDecisions.RemoveAll(decision => string.Equals(decision.Key, key, StringComparison.OrdinalIgnoreCase)); + batch.RelationshipDecisions.Add(new OnboardingStoryIntelligenceRelationshipDecision + { + Key = key, + Action = action, + CharacterRelationshipID = relationshipId, + RelationshipLabel = label, + CreatedOrLinked = createdOrLinked + }); + } + + private static bool IsCharacterEntity(string? entityType) + => string.Equals(Clean(entityType), "Character", StringComparison.OrdinalIgnoreCase); + + private static int? ConfidenceIntensity(decimal? confidence) + => confidence.HasValue ? Math.Clamp((int)Math.Round(confidence.Value * 10m, MidpointRounding.AwayFromZero), 1, 10) : null; + + private static decimal? Max(decimal? current, decimal? next) + => current.HasValue && next.HasValue ? Math.Max(current.Value, next.Value) : current ?? next; + + private static string ConfidenceBand(decimal? confidence) + => confidence switch + { + >= 0.75m => "High", + >= 0.45m => "Medium", + null => "Unknown", + _ => "Low" + }; + + private static string PairKey(int characterAId, int characterBId) + => characterAId <= characterBId ? $"{characterAId}:{characterBId}" : $"{characterBId}:{characterAId}"; + + private static string? FirstConfigured(params string?[] values) + => values.Select(Clean).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)); + + private static string Normalise(string? value) + => Clean(value).ToLowerInvariant(); + + private static string Clean(string? value) + => string.IsNullOrWhiteSpace(value) ? string.Empty : Whitespace.Replace(value, " ").Trim(); + + private static readonly Regex Whitespace = new(@"\s+", RegexOptions.Compiled); + + private static readonly string[] RelationshipSignalWords = + [ + "friend", "family", "romantic", "lover", "spouse", "parent", "child", "sibling", "enemy", + "rival", "ally", "argument", "conflict", "reconciled", "separated", "deteriorates", "meeting" + ]; + + private static readonly string[] RelationshipEventWords = + [ + "begins", "begin", "starts", "first meeting", "meet", "met", "deteriorates", "deteriorate", + "reconciles", "reconciliation", "reconciled", "argument", "argues", "separation", "separate", + "estranged", "confront", "conflict", "tension", "hostile", "distrust", "supports", "protects" + ]; + + private sealed record RelationshipCandidateData(bool HasCommittedScenes, int AlreadyLinkedCount, IReadOnlyList Candidates); + + private sealed class RelationshipCandidate( + string key, + int characterAId, + string characterAName, + int characterBId, + string characterBName, + string relationshipType, + int? existingRelationshipId, + string? existingRelationshipLabel) + { + public string Key { get; } = key; + public int CharacterAID { get; } = characterAId; + public string CharacterAName { get; } = characterAName; + public int CharacterBID { get; } = characterBId; + public string CharacterBName { get; } = characterBName; + public string RelationshipType { get; set; } = relationshipType; + public int? ExistingRelationshipID { get; } = existingRelationshipId; + public string? ExistingRelationshipLabel { get; } = existingRelationshipLabel; + public decimal? Confidence { get; set; } + public string? FirstAppearanceNote { get; set; } + public string? LastAppearanceNote { get; set; } + public string? ExampleScene { get; set; } + public string? ExampleContext { get; set; } + public List Appearances { get; } = []; + } + + private sealed record RelationshipAppearanceImport( + int SceneID, + decimal SceneNumber, + string SceneTitle, + string? Signal, + string? Evidence, + decimal? Confidence, + bool AlreadyLinked); + + private sealed class CharacterIndex + { + private readonly Dictionary byName = new(StringComparer.OrdinalIgnoreCase); + + public void Add(string? name, int characterId, string characterName) + { + var key = Clean(name); + if (string.IsNullOrWhiteSpace(key) || byName.ContainsKey(key)) + { + return; + } + + byName[key] = new CharacterMatch(characterId, characterName); + } + + public CharacterMatch? Find(string? name) + => byName.TryGetValue(Clean(name), out var match) ? match : null; + } + + private sealed record CharacterMatch(int CharacterID, string CharacterName); +} diff --git a/PlotLine/Services/StoryIntelligenceService.cs b/PlotLine/Services/StoryIntelligenceService.cs index 61b69fd..2824c5b 100644 --- a/PlotLine/Services/StoryIntelligenceService.cs +++ b/PlotLine/Services/StoryIntelligenceService.cs @@ -269,10 +269,12 @@ public sealed class StoryIntelligenceService( return state.CurrentReviewStage switch { + StoryIntelligencePipelineStages.RelationshipReview => "Waiting for Relationship Review", StoryIntelligencePipelineStages.AssetReview => "Waiting for Asset Review", StoryIntelligencePipelineStages.LocationReview => "Waiting for Location Review", StoryIntelligencePipelineStages.CharacterReview => "Waiting for Character Review", StoryIntelligencePipelineStages.SceneReview => "Needs Scene Review", + _ when StoryIntelligenceReadyForRelationships(state) => "Waiting for Relationship Review", _ when StoryIntelligenceReadyForAssets(state) => "Waiting for Asset Review", _ when StoryIntelligenceReadyForLocations(state) => "Waiting for Location Review", _ => string.Equals(state.Status, StoryIntelligencePipelineStatuses.InProgress, StringComparison.OrdinalIgnoreCase) @@ -286,24 +288,33 @@ public sealed class StoryIntelligenceService( ? "All currently available Story Intelligence stages are complete." : state.CurrentReviewStage switch { + 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 => "Review detected characters and decide what to create or link.", StoryIntelligencePipelineStages.SceneReview => "Review prepared scenes before creating them.", + _ when StoryIntelligenceReadyForRelationships(state) => "Review detected relationships and decide what to create or link.", _ when StoryIntelligenceReadyForAssets(state) => "Review detected assets and decide what to create or link.", _ when StoryIntelligenceReadyForLocations(state) => "Review detected locations and decide what to create or link.", _ => "Continue from the next Story Intelligence stage." }; + private static bool StoryIntelligenceReadyForRelationships(StoryIntelligenceBookPipelineState state) + => string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.AssetImport, StringComparison.OrdinalIgnoreCase) + || state.CurrentStage is StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport; + private static bool StoryIntelligenceReadyForAssets(StoryIntelligenceBookPipelineState state) => string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.LocationImport, StringComparison.OrdinalIgnoreCase) || state.CurrentStage is StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport + or StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport || (string.Equals(state.CurrentStage, StoryIntelligencePipelineStages.Complete, StringComparison.OrdinalIgnoreCase) - && !string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.AssetImport, StringComparison.OrdinalIgnoreCase)); + && !string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.RelationshipImport, StringComparison.OrdinalIgnoreCase)); private static bool StoryIntelligenceReadyForLocations(StoryIntelligenceBookPipelineState state) => string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.CharacterImport, StringComparison.OrdinalIgnoreCase) - || state.CurrentStage is StoryIntelligencePipelineStages.LocationReview or StoryIntelligencePipelineStages.LocationImport; + || state.CurrentStage is StoryIntelligencePipelineStages.LocationReview or StoryIntelligencePipelineStages.LocationImport + or StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport + or StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport; private static string FormatElapsed(TimeSpan elapsed) => elapsed.TotalMinutes < 1 diff --git a/PlotLine/Sql/131_Phase20AS_RelationshipReviewPipelineStages.sql b/PlotLine/Sql/131_Phase20AS_RelationshipReviewPipelineStages.sql new file mode 100644 index 0000000..a7a767b --- /dev/null +++ b/PlotLine/Sql/131_Phase20AS_RelationshipReviewPipelineStages.sql @@ -0,0 +1,38 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +IF OBJECT_ID(N'dbo.StoryIntelligenceBookPipelines', N'U') IS NOT NULL +BEGIN + IF EXISTS + ( + SELECT 1 + FROM sys.check_constraints + WHERE name = N'CK_StoryIntelligenceBookPipelines_CurrentStage' + AND parent_object_id = OBJECT_ID(N'dbo.StoryIntelligenceBookPipelines') + ) + BEGIN + ALTER TABLE dbo.StoryIntelligenceBookPipelines + DROP CONSTRAINT CK_StoryIntelligenceBookPipelines_CurrentStage; + END; + + ALTER TABLE dbo.StoryIntelligenceBookPipelines + ADD CONSTRAINT CK_StoryIntelligenceBookPipelines_CurrentStage + CHECK (CurrentStage IN + ( + N'Chapters', + N'SceneReview', + N'SceneImport', + N'CharacterReview', + N'CharacterImport', + N'LocationReview', + N'LocationImport', + N'AssetReview', + N'AssetImport', + N'RelationshipReview', + N'RelationshipImport', + N'Complete' + )); +END; +GO diff --git a/PlotLine/ViewModels/OnboardingViewModels.cs b/PlotLine/ViewModels/OnboardingViewModels.cs index 38a784c..c3baaed 100644 --- a/PlotLine/ViewModels/OnboardingViewModels.cs +++ b/PlotLine/ViewModels/OnboardingViewModels.cs @@ -145,6 +145,7 @@ public sealed class StoryIntelligenceProgressViewModel public StoryIntelligenceCharacterReviewViewModel CharacterReview { get; init; } = new(); public StoryIntelligenceLocationReviewViewModel LocationReview { get; init; } = new(); public StoryIntelligenceAssetReviewViewModel AssetReview { get; init; } = new(); + public StoryIntelligenceRelationshipReviewViewModel RelationshipReview { get; init; } = new(); public StoryIntelligencePipelineDashboardViewModel PipelineDashboard { get; init; } = new(); public int ChapterCount => Chapters.Count; public int CompletedChapterCount => Chapters.Count(chapter => chapter.IsRunComplete); @@ -162,6 +163,7 @@ public sealed class StoryIntelligenceProgressViewModel public bool HasCharactersToReview => CharacterReview.Candidates.Count > 0; public bool HasLocationsToReview => LocationReview.Candidates.Count > 0; public bool HasAssetsToReview => AssetReview.Candidates.Count > 0; + public bool HasRelationshipsToReview => RelationshipReview.Candidates.Count > 0; } public sealed class StoryIntelligencePipelineDashboardViewModel @@ -177,6 +179,9 @@ public sealed class StoryIntelligencePipelineDashboardViewModel public int AssetsIdentified { get; init; } public int AssetsCreatedOrLinked { get; init; } public bool AssetStageComplete { get; init; } + public int RelationshipsIdentified { get; init; } + public int RelationshipsCreatedOrLinked { get; init; } + public bool RelationshipStageComplete { get; init; } } public sealed class StoryIntelligenceCharacterReviewViewModel @@ -322,6 +327,55 @@ public static class StoryIntelligenceAssetImportActions public const string Alias = "Alias"; } +public sealed class StoryIntelligenceRelationshipReviewViewModel +{ + public bool CanImport { get; init; } + public bool HasCommittedScenes { get; init; } + public int AlreadyLinkedCount { get; init; } + public bool IsComplete { get; init; } + public IReadOnlyList Candidates { get; init; } = []; +} + +public sealed class StoryIntelligenceRelationshipReviewCandidateViewModel +{ + public string Key { get; init; } = string.Empty; + public string CharacterAName { get; init; } = string.Empty; + public string CharacterBName { get; init; } = string.Empty; + public string RelationshipType { get; init; } = "Unknown"; + public int AppearsInScenes { get; init; } + public string Confidence { get; init; } = "Unknown"; + public string? FirstAppearance { get; init; } + public string? LastAppearance { get; init; } + public string? ExampleScene { get; init; } + public string? ExampleContext { get; init; } + public int? ExistingRelationshipID { get; init; } + public string? ExistingRelationshipLabel { get; init; } + public bool IsExistingMatch => ExistingRelationshipID.HasValue; + public string ActionLabel => IsExistingMatch ? "Link existing" : "Create"; +} + +public sealed class StoryIntelligenceRelationshipImportForm +{ + public Guid BatchID { get; set; } + public List Relationships { get; set; } = []; +} + +public sealed class StoryIntelligenceRelationshipImportChoiceForm +{ + public string Key { get; set; } = string.Empty; + public string Action { get; set; } = StoryIntelligenceRelationshipImportActions.CreateNew; + public string? RelationshipType { get; set; } + public string? AliasTargetKey { get; set; } +} + +public static class StoryIntelligenceRelationshipImportActions +{ + public const string CreateNew = "CreateNew"; + public const string LinkExisting = "LinkExisting"; + public const string Ignore = "Ignore"; + public const string Alias = "Alias"; +} + public sealed class StoryIntelligenceCompletionViewModel { public StoryIntelligenceJobProgress Job { get; init; } = new(); @@ -339,6 +393,8 @@ public sealed class StoryIntelligenceCompletionViewModel public bool LocationStageComplete { get; init; } public int AssetsCreatedOrLinked { get; init; } public bool AssetStageComplete { get; init; } + public int RelationshipsCreatedOrLinked { get; init; } + public bool RelationshipStageComplete { get; init; } } public sealed class StoryIntelligenceCharacterImportResultViewModel @@ -379,6 +435,18 @@ public sealed class StoryIntelligenceAssetImportResultViewModel public int OwnershipLinksCreated { get; init; } } +public sealed class StoryIntelligenceRelationshipImportResultViewModel +{ + public Guid BatchID { get; init; } + public int ProjectID { get; init; } + public int BookID { get; init; } + public int RelationshipsCreated { get; init; } + public int RelationshipsLinked { get; init; } + public int RelationshipsMerged { get; init; } + public int RelationshipsIgnored { get; init; } + public int RelationshipEventsCreated { get; init; } +} + public sealed class StoryIntelligenceOnboardingChapterViewModel { public string TemporaryChapterKey { get; init; } = string.Empty; diff --git a/PlotLine/Views/Books/Details.cshtml b/PlotLine/Views/Books/Details.cshtml index 73f3e59..7acbc2e 100644 --- a/PlotLine/Views/Books/Details.cshtml +++ b/PlotLine/Views/Books/Details.cshtml @@ -200,23 +200,32 @@ return pipeline.CurrentReviewStage switch { + 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 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.", _ => "PlotDirector will resume from the next Story Intelligence stage." }; } + private static bool StoryIntelligenceReadyForRelationships(StoryIntelligenceBookPipelineState pipeline) + => string.Equals(pipeline.LastCompletedStage, StoryIntelligencePipelineStages.AssetImport, StringComparison.OrdinalIgnoreCase) + || pipeline.CurrentStage is StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport; + private static bool StoryIntelligenceReadyForAssets(StoryIntelligenceBookPipelineState pipeline) => string.Equals(pipeline.LastCompletedStage, StoryIntelligencePipelineStages.LocationImport, StringComparison.OrdinalIgnoreCase) || pipeline.CurrentStage is StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport + or StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport || (string.Equals(pipeline.CurrentStage, StoryIntelligencePipelineStages.Complete, StringComparison.OrdinalIgnoreCase) - && !string.Equals(pipeline.LastCompletedStage, StoryIntelligencePipelineStages.AssetImport, StringComparison.OrdinalIgnoreCase)); + && !string.Equals(pipeline.LastCompletedStage, StoryIntelligencePipelineStages.RelationshipImport, StringComparison.OrdinalIgnoreCase)); private static bool StoryIntelligenceReadyForLocations(StoryIntelligenceBookPipelineState pipeline) => string.Equals(pipeline.LastCompletedStage, StoryIntelligencePipelineStages.CharacterImport, StringComparison.OrdinalIgnoreCase) - || pipeline.CurrentStage is StoryIntelligencePipelineStages.LocationReview or StoryIntelligencePipelineStages.LocationImport; + || pipeline.CurrentStage is StoryIntelligencePipelineStages.LocationReview or StoryIntelligencePipelineStages.LocationImport + or StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport + or StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport; } diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceAssetComplete.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceAssetComplete.cshtml index ed52d1b..4d8ff3f 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceAssetComplete.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceAssetComplete.cshtml @@ -5,14 +5,14 @@
- +

Build Story Database

Assets created

-

PlotDirector updated your story database from the approved asset decisions. Later Story Intelligence stages will continue from here.

+

PlotDirector updated your story database from the approved asset decisions. Next, review the relationships detected between imported characters.

@@ -43,7 +43,7 @@
-
Review RelationshipsComing Soon
+
Review RelationshipsNext
Review KnowledgeComing Soon
Review ContinuityComing Soon
Timeline EventsComing Soon
@@ -51,7 +51,7 @@
diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceAssets.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceAssets.cshtml index 70fb4a3..d382539 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceAssets.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceAssets.cshtml @@ -9,7 +9,7 @@

Phase 3

Review Story Intelligence

-

Stage 4 of 4

+

Stage 4 of 5

Review Assets

Approve the important objects PlotDirector should create or link from your imported scenes. Nothing is created unless you approve it.

@@ -33,7 +33,7 @@

Assets are already up to date, or there were no story-significant asset suggestions ready to import.

- @FutureStage("Review Relationships") +
Review RelationshipsNext
@FutureStage("Review Knowledge") @FutureStage("Review Continuity") @FutureStage("Timeline Events") diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceComplete.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceComplete.cshtml index e60f84e..f9dbc99 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceComplete.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceComplete.cshtml @@ -24,7 +24,7 @@
  • Characters
  • Locations
  • Assets
  • -
  • RelationshipsComing Soon
  • +
  • Relationships
  • KnowledgeComing Soon
  • Preparing continuity...
  • Generating story indexes...
  • @@ -53,6 +53,10 @@ Assets created / linked @Model.AssetsCreatedOrLinked.ToString("N0")
    +
    + Relationships created / linked + @Model.RelationshipsCreatedOrLinked.ToString("N0") +
    Book @Model.BookTitle diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceRelationshipComplete.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceRelationshipComplete.cshtml new file mode 100644 index 0000000..62e9669 --- /dev/null +++ b/PlotLine/Views/Onboarding/StoryIntelligenceRelationshipComplete.cshtml @@ -0,0 +1,52 @@ +@model StoryIntelligenceRelationshipImportResultViewModel +@{ + ViewData["Title"] = "Relationships created"; +} + +
    +
    + +
    +

    Build Story Database

    +
    + +

    Relationships created

    +
    +

    PlotDirector updated your story database from the approved relationship decisions. The implemented Story Intelligence import stages are now complete.

    +
    + +
    +
    + Relationships created + @Model.RelationshipsCreated.ToString("N0") +
    +
    + Relationships linked + @Model.RelationshipsLinked.ToString("N0") +
    +
    + Relationships merged + @Model.RelationshipsMerged.ToString("N0") +
    +
    + Relationships ignored + @Model.RelationshipsIgnored.ToString("N0") +
    +
    + Relationship events added + @Model.RelationshipEventsCreated.ToString("N0") +
    +
    + +
    +
    Review KnowledgeComing Soon
    +
    Review ContinuityComing Soon
    +
    Timeline EventsComing Soon
    +
    + + +
    +
    diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceRelationships.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceRelationships.cshtml new file mode 100644 index 0000000..ba97a2a --- /dev/null +++ b/PlotLine/Views/Onboarding/StoryIntelligenceRelationships.cshtml @@ -0,0 +1,233 @@ +@model StoryIntelligenceProgressViewModel +@{ + ViewData["Title"] = "Review relationships"; +} + +
    +
    + +
    +

    Phase 3

    +

    Review Story Intelligence

    +

    Stage 5 of 5

    +

    Review Relationships

    +

    Approve the character relationships PlotDirector should create or link from your imported scenes. Nothing is created unless you approve it.

    +
    + + @if (TempData["OnboardingStoryIntelligenceError"] is string error) + { +
    @error
    + } + @if (TempData["OnboardingStoryIntelligenceMessage"] is string message) + { +
    @message
    + } + + + +
    + @if (Model.RelationshipReview.Candidates.Count == 0) + { +
    + No relationship decisions are waiting. +

    Relationships are already up to date, or there were no relationship suggestions ready to import from the stored scene analysis.

    +
    +
    + @FutureStage("Review Knowledge") + @FutureStage("Review Continuity") + @FutureStage("Timeline Events") +
    +
    + Back to assets +
    + + +
    +
    + } + else + { +
    + + + + + +
    + +
    + +
    + @for (var i = 0; i < Model.RelationshipReview.Candidates.Count; i++) + { + var candidate = Model.RelationshipReview.Candidates[i]; +
    + + + @candidate.CharacterAName + @candidate.RelationshipType / @candidate.CharacterBName - @candidate.AppearsInScenes.ToString("N0") scene@(candidate.AppearsInScenes == 1 ? string.Empty : "s") + + @candidate.Confidence + + + + +
    +
    +
    Character A
    @candidate.CharacterAName
    +
    Character B
    @candidate.CharacterBName
    +
    First seen
    @Display(candidate.FirstAppearance)
    +
    Last seen
    @Display(candidate.LastAppearance)
    +
    Existing match
    @(candidate.ExistingRelationshipLabel ?? "None")
    +
    + + @if (!string.IsNullOrWhiteSpace(candidate.ExampleScene)) + { +
    + Example scene +

    @candidate.ExampleScene

    +
    + } + + @if (!string.IsNullOrWhiteSpace(candidate.ExampleContext)) + { +
    + Supporting context +

    @candidate.ExampleContext

    +
    + } + + @if (candidate.IsExistingMatch) + { +
    + Possible existing relationship +

    @candidate.CharacterAName and @candidate.CharacterBName may already have a relationship in PlotDirector. Choose whether to link them or create a separate relationship.

    +
    + } + +
    + Decision + + + + +
    + +
    + + +
    + + +
    +
    + } +
    + +
    + Back to assets + +
    +
    + } +
    +
    +
    + +@section Scripts { + +} + +@functions { + private static string Display(string? value) => string.IsNullOrWhiteSpace(value) ? "Not detected" : value; + + private static Microsoft.AspNetCore.Html.IHtmlContent FutureStage(string label) + => new Microsoft.AspNetCore.Html.HtmlString($"
    {System.Net.WebUtility.HtmlEncode(label)}Coming Soon
    "); +} diff --git a/PlotLine/Views/Onboarding/_StoryIntelligencePipelineHeader.cshtml b/PlotLine/Views/Onboarding/_StoryIntelligencePipelineHeader.cshtml index 54d1e23..a641a7c 100644 --- a/PlotLine/Views/Onboarding/_StoryIntelligencePipelineHeader.cshtml +++ b/PlotLine/Views/Onboarding/_StoryIntelligencePipelineHeader.cshtml @@ -6,7 +6,7 @@ new PipelineStage("Review Characters", "Characters", true), new PipelineStage("Review Locations", "Locations", true), new PipelineStage("Review Assets", "Assets", true), - new PipelineStage("Review Relationships", "Relationships", false), + new PipelineStage("Review Relationships", "Relationships", true), new PipelineStage("Review Knowledge", "Knowledge", false) }; var implementedReviewStages = reviewStages.Where(stage => stage.IsImplemented).ToList(); diff --git a/PlotLine/Views/Onboarding/_StoryIntelligencePipelineSummary.cshtml b/PlotLine/Views/Onboarding/_StoryIntelligencePipelineSummary.cshtml index f05cb33..affba8b 100644 --- a/PlotLine/Views/Onboarding/_StoryIntelligencePipelineSummary.cshtml +++ b/PlotLine/Views/Onboarding/_StoryIntelligencePipelineSummary.cshtml @@ -34,8 +34,12 @@ @(Model.AssetStageComplete ? $"Done: {Model.AssetsCreatedOrLinked:N0}" : "In review")
    - Relationships - Coming next + Relationships identified + @Model.RelationshipsIdentified.ToString("N0") +
    +
    + Relationships created / linked + @(Model.RelationshipStageComplete ? $"Done: {Model.RelationshipsCreatedOrLinked:N0}" : "In review")
    Knowledge