From faf0e434125d7e10c597ea6e07b3ff3933417632 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Fri, 10 Jul 2026 21:30:53 +0000 Subject: [PATCH] =?UTF-8?q?Phase=2020AU=20=E2=80=93=20Knowledge=20Intellig?= =?UTF-8?q?ence=20Review=20and=20Import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PlotLine.Tests/Program.cs | 48 +- PlotLine/Controllers/OnboardingController.cs | 68 ++ .../StoryIntelligencePersistenceModels.cs | 4 +- PlotLine/Program.cs | 1 + .../OnboardingStoryIntelligenceService.cs | 89 ++- ...StoryIntelligenceKnowledgeImportService.cs | 730 ++++++++++++++++++ .../StoryIntelligencePipelineStateService.cs | 18 +- PlotLine/Services/StoryIntelligenceService.cs | 17 +- ...hase20AU_KnowledgeReviewPipelineStages.sql | 53 ++ PlotLine/ViewModels/OnboardingViewModels.cs | 65 ++ .../StoryIntelligenceComplete.cshtml | 2 +- .../StoryIntelligenceKnowledge.cshtml | 236 ++++++ .../StoryIntelligenceKnowledgeComplete.cshtml | 47 ++ ...oryIntelligenceRelationshipComplete.cshtml | 5 +- .../_StoryIntelligencePipelineHeader.cshtml | 2 +- .../_StoryIntelligencePipelineSummary.cshtml | 8 +- 16 files changed, 1377 insertions(+), 16 deletions(-) create mode 100644 PlotLine/Services/StoryIntelligenceKnowledgeImportService.cs create mode 100644 PlotLine/Sql/133_Phase20AU_KnowledgeReviewPipelineStages.sql create mode 100644 PlotLine/Views/Onboarding/StoryIntelligenceKnowledge.cshtml create mode 100644 PlotLine/Views/Onboarding/StoryIntelligenceKnowledgeComplete.cshtml diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index fd9f928..aa3ba87 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -19,7 +19,9 @@ var tests = new (string Name, Action Test)[] ("Asset filtering rejects generic objects", AssetFilteringRejectsGenericObjects), ("Asset filtering preserves story assets", AssetFilteringPreservesStoryAssets), ("Asset canonical keys merge trivial variants", AssetCanonicalKeysMergeTrivialVariants), - ("Relationship signals map to broad lookup types", RelationshipSignalsMapToBroadTypes) + ("Relationship signals map to broad lookup types", RelationshipSignalsMapToBroadTypes), + ("Knowledge signals map to existing knowledge states", KnowledgeSignalsMapToExistingStates), + ("Knowledge duplicate statements share canonical keys", KnowledgeDuplicateStatementsShareCanonicalKeys) }; foreach (var test in tests) @@ -238,6 +240,32 @@ static void RelationshipSignalsMapToBroadTypes() Assert(RelationshipTypeFromSignal("uncertain emotional connection") == "Unknown", "Uncertain signals should map to Unknown."); } +static void KnowledgeSignalsMapToExistingStates() +{ + var states = new List + { + new() { KnowledgeStateID = 1, StateName = "Unaware", SortOrder = 10, IsActive = true }, + new() { KnowledgeStateID = 2, StateName = "Suspects", SortOrder = 20, IsActive = true }, + new() { KnowledgeStateID = 3, StateName = "Partially Knows", SortOrder = 30, IsActive = true }, + new() { KnowledgeStateID = 4, StateName = "Knows", SortOrder = 40, IsActive = true }, + new() { KnowledgeStateID = 5, StateName = "Misunderstands", SortOrder = 50, IsActive = true } + }; + + Assert(KnowledgeStateFromSignal("Discovers", "the letter exists", states) == "Knows", "Discovers should map to Knows."); + Assert(KnowledgeStateFromSignal("Suspects", "Maggie is lying", states) == "Suspects", "Suspects should map to Suspects."); + Assert(KnowledgeStateFromSignal("Believes", "Gareth is trustworthy", states) == "Partially Knows", "Believes should map to Partially Knows."); + Assert(KnowledgeStateFromSignal("Misunderstands", "Beth is dead", states) == "Misunderstands", "Misunderstands should map to Misunderstands."); + Assert(KnowledgeStateFromSignal("Forgets", "the hiding place", states) == "Unaware", "Forgets should map to Unaware."); +} + +static void KnowledgeDuplicateStatementsShareCanonicalKeys() +{ + var first = CanonicalKnowledgeKey("Simon is Beth's father."); + var second = CanonicalKnowledgeKey("Beth discovers Simon is her father."); + var third = CanonicalKnowledgeKey("Beth learns Simon is her biological father."); + Assert(first == second && second == third, $"Expected duplicate father facts to merge, got {first}, {second}, {third}."); +} + static bool IsAssetNameCandidate(string name) => InvokePrivateAssetFilter("IsAssetNameCandidate", name); @@ -268,6 +296,24 @@ static string RelationshipTypeFromSignal(string signal, string? evidence = null) return (string)method!.Invoke(null, [signal, evidence])!; } +static string KnowledgeStateFromSignal(string changeType, string statement, IReadOnlyList states) +{ + var method = typeof(StoryIntelligenceKnowledgeImportService).GetMethod( + "KnowledgeStateFromSignal", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + Assert(method is not null, "KnowledgeStateFromSignal was not found."); + return (string)method!.Invoke(null, [changeType, statement, states])!; +} + +static string CanonicalKnowledgeKey(string statement) +{ + var method = typeof(StoryIntelligenceKnowledgeImportService).GetMethod( + "CanonicalKnowledgeKey", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + Assert(method is not null, "CanonicalKnowledgeKey was not found."); + return (string)method!.Invoke(null, [statement])!; +} + static JsonSerializerOptions JsonOptions() => new() { diff --git a/PlotLine/Controllers/OnboardingController.cs b/PlotLine/Controllers/OnboardingController.cs index 17cf3eb..6f8433d 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.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 }), StoryIntelligenceResumeRoutes.Locations => RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = target.BatchID }), @@ -388,6 +389,68 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard return model is null ? NotFound() : View(model); } + [HttpGet("story-intelligence/knowledge")] + public async Task StoryIntelligenceKnowledge(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 }); + } + + return !model.PipelineDashboard.RelationshipStageComplete + ? RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId }) + : View(model); + } + + [HttpPost("story-intelligence/knowledge")] + [ValidateAntiForgeryToken] + public async Task ImportStoryIntelligenceKnowledge(StoryIntelligenceKnowledgeImportForm form) + { + var (progress, result) = await storyIntelligence.ImportKnowledgeAsync(form.BatchID, form); + if (progress is null) + { + return NotFound(); + } + + TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; + return result.Success + ? RedirectToAction(nameof(StoryIntelligenceKnowledgeComplete), new { batchId = form.BatchID }) + : RedirectToAction(nameof(StoryIntelligenceKnowledge), new { batchId = form.BatchID }); + } + + [HttpGet("story-intelligence/knowledge/complete")] + public async Task StoryIntelligenceKnowledgeComplete(Guid batchId) + { + var model = await storyIntelligence.GetKnowledgeImportResultAsync(batchId); + return model is null ? NotFound() : View(model); + } + [HttpGet("story-intelligence/complete")] public async Task StoryIntelligenceComplete(Guid batchId) { @@ -417,6 +480,11 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard return RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId }); } + if (!progress.PipelineDashboard.KnowledgeStageComplete) + { + return RedirectToAction(nameof(StoryIntelligenceKnowledge), 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 1eadac1..ed8ed04 100644 --- a/PlotLine/Models/StoryIntelligencePersistenceModels.cs +++ b/PlotLine/Models/StoryIntelligencePersistenceModels.cs @@ -34,6 +34,8 @@ public static class StoryIntelligencePipelineStages public const string AssetImport = "AssetImport"; public const string RelationshipReview = "RelationshipReview"; public const string RelationshipImport = "RelationshipImport"; + public const string KnowledgeReview = "KnowledgeReview"; + public const string KnowledgeImport = "KnowledgeImport"; public const string Complete = "Complete"; } @@ -64,7 +66,7 @@ public sealed class StoryIntelligenceBookPipelineState public bool IsComplete => string.Equals(Status, StoryIntelligencePipelineStatuses.Complete, StringComparison.OrdinalIgnoreCase) - && string.Equals(LastCompletedStage, StoryIntelligencePipelineStages.RelationshipImport, StringComparison.OrdinalIgnoreCase); + && string.Equals(LastCompletedStage, StoryIntelligencePipelineStages.KnowledgeImport, StringComparison.OrdinalIgnoreCase); } public sealed class StoryIntelligenceBookPipelineSaveRequest diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index 3302ae1..413fb51 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -211,6 +211,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 62c33e8..e21ca2d 100644 --- a/PlotLine/Services/OnboardingStoryIntelligenceService.cs +++ b/PlotLine/Services/OnboardingStoryIntelligenceService.cs @@ -22,6 +22,8 @@ public interface IOnboardingStoryIntelligenceService Task GetAssetImportResultAsync(Guid batchId); Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportRelationshipsAsync(Guid batchId, StoryIntelligenceRelationshipImportForm form); Task GetRelationshipImportResultAsync(Guid batchId); + Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportKnowledgeAsync(Guid batchId, StoryIntelligenceKnowledgeImportForm form); + Task GetKnowledgeImportResultAsync(Guid batchId); Task GetCompletionAsync(Guid batchId); Task ResumeBookAsync(int bookId); } @@ -36,6 +38,7 @@ public sealed class OnboardingStoryIntelligenceService( IStoryIntelligenceLocationImportService locationImport, IStoryIntelligenceAssetImportService assetImport, IStoryIntelligenceRelationshipImportService relationshipImport, + IStoryIntelligenceKnowledgeImportService knowledgeImport, IStoryIntelligencePipelineStateService pipelineState, IStoryIntelligenceClient client, IOnboardingStoryIntelligenceBatchStore batchStore, @@ -271,6 +274,7 @@ public sealed class OnboardingStoryIntelligenceService( var locationReview = await locationImport.BuildReviewAsync(batch); var assetReview = await assetImport.BuildReviewAsync(batch); var relationshipReview = await relationshipImport.BuildReviewAsync(batch); + var knowledgeReview = await knowledgeImport.BuildReviewAsync(batch); return new StoryIntelligenceProgressViewModel { BatchID = batch.BatchID, @@ -284,6 +288,7 @@ public sealed class OnboardingStoryIntelligenceService( LocationReview = locationReview, AssetReview = assetReview, RelationshipReview = relationshipReview, + KnowledgeReview = knowledgeReview, PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel { ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete), @@ -299,7 +304,10 @@ public sealed class OnboardingStoryIntelligenceService( AssetStageComplete = batch.AssetStageComplete || assetReview.IsComplete, RelationshipsIdentified = relationshipReview.Candidates.Count + batch.RelationshipDecisions.Count, RelationshipsCreatedOrLinked = batch.RelationshipDecisions.Count(decision => decision.CreatedOrLinked), - RelationshipStageComplete = batch.RelationshipStageComplete || relationshipReview.IsComplete + RelationshipStageComplete = batch.RelationshipStageComplete || relationshipReview.IsComplete, + KnowledgeIdentified = knowledgeReview.Candidates.Count + batch.KnowledgeDecisions.Count, + KnowledgeCreatedOrLinked = batch.KnowledgeDecisions.Count(decision => decision.CreatedOrLinked), + KnowledgeStageComplete = batch.KnowledgeStageComplete || knowledgeReview.IsComplete } }; } @@ -501,6 +509,38 @@ public sealed class OnboardingStoryIntelligenceService( }; } + public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportKnowledgeAsync(Guid batchId, StoryIntelligenceKnowledgeImportForm 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 knowledgeImport.ImportAsync(batch, form); + return (await GetProgressAsync(batchId), result); + } + + public async Task GetKnowledgeImportResultAsync(Guid batchId) + { + var batch = await batchStore.GetAsync(RequireUserId(), batchId); + if (batch is null) + { + return null; + } + + return new StoryIntelligenceKnowledgeImportResultViewModel + { + BatchID = batch.BatchID, + ProjectID = batch.ProjectID, + BookID = batch.BookID, + KnowledgeCreated = batch.LastKnowledgeImportResult?.KnowledgeCreated ?? 0, + KnowledgeLinked = batch.LastKnowledgeImportResult?.KnowledgeLinked ?? 0, + KnowledgeMerged = batch.LastKnowledgeImportResult?.KnowledgeMerged ?? 0, + KnowledgeIgnored = batch.LastKnowledgeImportResult?.KnowledgeIgnored ?? 0 + }; + } + public async Task GetCompletionAsync(Guid batchId) { var progress = await GetProgressAsync(batchId); @@ -575,28 +615,41 @@ public sealed class OnboardingStoryIntelligenceService( or StoryIntelligencePipelineStages.AssetImport or StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport + or StoryIntelligencePipelineStages.KnowledgeReview + or StoryIntelligencePipelineStages.KnowledgeImport 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.KnowledgeReview + or StoryIntelligencePipelineStages.KnowledgeImport 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.Complete; - var relationshipStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.RelationshipImport); + var relationshipStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.RelationshipImport) + || state.CurrentStage is StoryIntelligencePipelineStages.KnowledgeReview + or StoryIntelligencePipelineStages.KnowledgeImport + or StoryIntelligencePipelineStages.Complete; + var knowledgeStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.KnowledgeImport); batch.CharacterStageComplete = characterStageComplete; batch.LocationStageComplete = locationStageComplete; batch.AssetStageComplete = assetStageComplete; batch.RelationshipStageComplete = relationshipStageComplete; + batch.KnowledgeStageComplete = knowledgeStageComplete; await batchStore.SaveAsync(batch); - var route = relationshipStageComplete + var route = knowledgeStageComplete ? StoryIntelligenceResumeRoutes.Complete + : relationshipStageComplete + ? StoryIntelligenceResumeRoutes.Knowledge : assetStageComplete ? StoryIntelligenceResumeRoutes.Relationships : locationStageComplete @@ -607,6 +660,8 @@ public sealed class OnboardingStoryIntelligenceService( { StoryIntelligencePipelineStages.RelationshipReview => StoryIntelligenceResumeRoutes.Relationships, StoryIntelligencePipelineStages.RelationshipImport => StoryIntelligenceResumeRoutes.Relationships, + StoryIntelligencePipelineStages.KnowledgeReview => StoryIntelligenceResumeRoutes.Knowledge, + StoryIntelligencePipelineStages.KnowledgeImport => StoryIntelligenceResumeRoutes.Knowledge, StoryIntelligencePipelineStages.CharacterReview => StoryIntelligenceResumeRoutes.Characters, StoryIntelligencePipelineStages.CharacterImport => StoryIntelligenceResumeRoutes.Characters, StoryIntelligencePipelineStages.SceneReview => StoryIntelligenceResumeRoutes.SceneReview, @@ -852,14 +907,18 @@ public sealed class OnboardingStoryIntelligenceBatch public List AssetDecisions { get; } = []; public List RelationshipSelections { get; } = []; public List RelationshipDecisions { get; } = []; + public List KnowledgeSelections { get; } = []; + public List KnowledgeDecisions { get; } = []; public StoryIntelligenceCharacterImportBatchResult? LastCharacterImportResult { get; set; } public StoryIntelligenceLocationImportBatchResult? LastLocationImportResult { get; set; } public StoryIntelligenceAssetImportBatchResult? LastAssetImportResult { get; set; } public StoryIntelligenceRelationshipImportBatchResult? LastRelationshipImportResult { get; set; } + public StoryIntelligenceKnowledgeImportBatchResult? LastKnowledgeImportResult { 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 sealed class OnboardingStoryIntelligenceBatchItem @@ -951,6 +1010,29 @@ public sealed class StoryIntelligenceRelationshipImportBatchResult public int RelationshipEventsCreated { get; init; } } +public sealed class OnboardingStoryIntelligenceKnowledgeDecision +{ + public string Key { get; init; } = string.Empty; + public string Action { get; init; } = string.Empty; + public int? CharacterKnowledgeID { get; init; } + public string? KnowledgeLabel { get; init; } + public bool CreatedOrLinked { get; init; } +} + +public sealed class OnboardingStoryIntelligenceKnowledgeSelection +{ + public string Key { get; init; } = string.Empty; + public int KnowledgeStateID { get; init; } +} + +public sealed class StoryIntelligenceKnowledgeImportBatchResult +{ + public int KnowledgeCreated { get; init; } + public int KnowledgeLinked { get; init; } + public int KnowledgeMerged { get; init; } + public int KnowledgeIgnored { get; init; } +} + public static class StoryIntelligenceResumeRoutes { public const string SceneReview = "SceneReview"; @@ -958,6 +1040,7 @@ public static class StoryIntelligenceResumeRoutes public const string Locations = "Locations"; public const string Assets = "Assets"; public const string Relationships = "Relationships"; + public const string Knowledge = "Knowledge"; public const string Complete = "Complete"; } diff --git a/PlotLine/Services/StoryIntelligenceKnowledgeImportService.cs b/PlotLine/Services/StoryIntelligenceKnowledgeImportService.cs new file mode 100644 index 0000000..9230e7b --- /dev/null +++ b/PlotLine/Services/StoryIntelligenceKnowledgeImportService.cs @@ -0,0 +1,730 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Mvc.Rendering; +using PlotLine.Data; +using PlotLine.Models; +using PlotLine.ViewModels; + +namespace PlotLine.Services; + +public interface IStoryIntelligenceKnowledgeImportService +{ + Task BuildReviewAsync(OnboardingStoryIntelligenceBatch batch); + Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceKnowledgeImportForm form); +} + +public sealed class StoryIntelligenceKnowledgeImportService( + IStoryIntelligenceResultRepository runs, + ICharacterRepository characters, + ISceneRepository scenes, + IAssetRepository assets, + IStoryIntelligencePipelineStateService pipelineState, + ILogger logger) : IStoryIntelligenceKnowledgeImportService +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + + public async Task BuildReviewAsync(OnboardingStoryIntelligenceBatch batch) + { + var lookupData = await characters.GetLookupsAsync(batch.ProjectID); + var data = await BuildCandidateDataAsync(batch, lookupData.KnowledgeStates); + var decidedKeys = batch.KnowledgeDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); + var selectedStateIds = batch.KnowledgeSelections.ToDictionary(selection => selection.Key, selection => selection.KnowledgeStateID, StringComparer.OrdinalIgnoreCase); + var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList(); + + return new StoryIntelligenceKnowledgeReviewViewModel + { + HasCommittedScenes = data.HasCommittedScenes, + CanImport = visibleCandidates.Count > 0, + AlreadyLinkedCount = data.AlreadyLinkedCount, + IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0, + KnowledgeStateOptions = lookupData.KnowledgeStates + .OrderBy(state => state.SortOrder) + .ThenBy(state => state.StateName) + .Select(state => new SelectListItem(state.StateName, state.KnowledgeStateID.ToString())) + .ToList(), + Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceKnowledgeReviewCandidateViewModel + { + Key = candidate.Key, + CharacterName = candidate.CharacterName, + KnowledgeStatement = candidate.KnowledgeStatement, + KnowledgeStateID = ResolveKnowledgeStateID(lookupData.KnowledgeStates, selectedStateIds.GetValueOrDefault(candidate.Key), candidate.KnowledgeStateName), + KnowledgeStateName = candidate.KnowledgeStateName, + AppearsInScenes = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count(), + Confidence = ConfidenceBand(candidate.Confidence), + FirstScene = candidate.FirstScene, + ExampleScene = candidate.ExampleScene, + Evidence = candidate.Evidence, + ExistingKnowledgeID = candidate.ExistingKnowledgeID, + ExistingKnowledgeLabel = candidate.ExistingKnowledgeLabel + }).ToList() + }; + } + + public async Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceKnowledgeImportForm form) + { + var lookupData = await characters.GetLookupsAsync(batch.ProjectID); + var data = await BuildCandidateDataAsync(batch, lookupData.KnowledgeStates); + if (!data.HasCommittedScenes) + { + return new StoryIntelligenceImportCommitResult { Success = false, Message = "Create scenes before importing knowledge." }; + } + + var choices = form.Knowledge + .Where(choice => !string.IsNullOrWhiteSpace(choice.Key)) + .ToDictionary(choice => choice.Key, choice => choice, StringComparer.OrdinalIgnoreCase); + SaveKnowledgeSelections(batch, choices.Values); + if (choices.Count == 0) + { + if (data.Candidates.Count == 0) + { + batch.KnowledgeStageComplete = true; + batch.LastKnowledgeImportResult = new StoryIntelligenceKnowledgeImportBatchResult(); + await pipelineState.RecordKnowledgeImportAsync(batch.ProjectID, batch.BookID); + return new StoryIntelligenceImportCommitResult + { + Success = true, + Message = "Knowledge review completed. No knowledge decisions were waiting." + }; + } + + return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose at least one knowledge item to create, link, merge or ignore." }; + } + + var stateIds = lookupData.KnowledgeStates.Select(state => state.KnowledgeStateID).ToHashSet(); + var created = 0; + var linkedExisting = 0; + var merged = 0; + var ignored = 0; + var resolvedKnowledge = 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, StoryIntelligenceKnowledgeImportActions.Ignore, StringComparison.OrdinalIgnoreCase)) + { + ignored++; + AddDecision(batch, candidate.Key, StoryIntelligenceKnowledgeImportActions.Ignore, null, KnowledgeLabel(candidate), false); + continue; + } + + if (string.Equals(choice.Action, StoryIntelligenceKnowledgeImportActions.Alias, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var knowledgeId = string.Equals(choice.Action, StoryIntelligenceKnowledgeImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase) + ? candidate.ExistingKnowledgeID + : null; + + if (!knowledgeId.HasValue) + { + if (!choice.KnowledgeStateID.HasValue || !stateIds.Contains(choice.KnowledgeStateID.Value)) + { + return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose a valid knowledge state for each knowledge item you want to create." }; + } + + var firstAppearance = candidate.Appearances.OrderBy(appearance => appearance.SceneNumber).First(); + knowledgeId = await characters.SaveKnowledgeAsync(new CharacterKnowledgeItem + { + CharacterID = candidate.CharacterID, + StoryAssetID = candidate.StoryAssetID, + PlotThreadID = null, + SceneID = firstAppearance.SceneID, + KnowledgeStateID = choice.KnowledgeStateID.Value, + SourceEventID = null, + Description = BuildKnowledgeDescription(candidate, firstAppearance) + }); + created++; + } + else + { + linkedExisting++; + } + + resolvedKnowledge[candidate.Key] = knowledgeId.Value; + AddDecision(batch, candidate.Key, choice.Action, knowledgeId.Value, KnowledgeLabel(candidate), true); + } + + foreach (var candidate in data.Candidates) + { + if (!choices.TryGetValue(candidate.Key, out var choice) + || !string.Equals(choice.Action, StoryIntelligenceKnowledgeImportActions.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, StoryIntelligenceKnowledgeImportActions.Ignore, StringComparison.OrdinalIgnoreCase) + || string.Equals(targetChoice.Action, StoryIntelligenceKnowledgeImportActions.Alias, StringComparison.OrdinalIgnoreCase) + || !candidatesByKey.ContainsKey(targetKey) + || !resolvedKnowledge.TryGetValue(targetKey, out var targetKnowledgeId)) + { + logger.LogWarning( + "Story Intelligence knowledge merge candidate {CandidateKey} could not be imported because target {TargetKey} was not a resolved create/link target.", + candidate.Key, + targetKey); + continue; + } + + resolvedKnowledge[candidate.Key] = targetKnowledgeId; + merged++; + AddDecision(batch, candidate.Key, StoryIntelligenceKnowledgeImportActions.Alias, targetKnowledgeId, KnowledgeLabel(candidate), true); + } + + batch.KnowledgeStageComplete = true; + batch.LastKnowledgeImportResult = new StoryIntelligenceKnowledgeImportBatchResult + { + KnowledgeCreated = created, + KnowledgeLinked = linkedExisting, + KnowledgeMerged = merged, + KnowledgeIgnored = ignored + }; + await pipelineState.RecordKnowledgeImportAsync(batch.ProjectID, batch.BookID); + + logger.LogInformation( + "Imported Story Intelligence knowledge for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Merged={Merged} Ignored={Ignored}", + batch.BatchID, + created, + linkedExisting, + merged, + ignored); + + return new StoryIntelligenceImportCommitResult + { + Success = true, + Message = $"Knowledge imported. {created:N0} created, {linkedExisting:N0} linked, {merged:N0} merged, {ignored:N0} ignored." + }; + } + + private async Task BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch, IReadOnlyList knowledgeStates) + { + var characterIndex = await BuildCharacterIndexAsync(batch.ProjectID); + var assetIndex = await BuildAssetIndexAsync(batch.ProjectID); + var existingKnowledge = await ListExistingKnowledgeAsync(batch.ProjectID); + 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 existingSceneKnowledge = await characters.ListKnowledgeBySceneAsync(importedScene.SceneID); + alreadyLinked += existingSceneKnowledge.Count; + AddSceneKnowledge(groups, characterIndex, assetIndex, existingKnowledge, importedScene, parsed, knowledgeStates); + } + } + + var candidates = groups.Values + .OrderByDescending(candidate => candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count()) + .ThenBy(candidate => candidate.CharacterName) + .ThenBy(candidate => candidate.KnowledgeStatement) + .ToList(); + + return new KnowledgeCandidateData(hasCommittedScenes, alreadyLinked, candidates); + } + + private static void AddSceneKnowledge( + Dictionary groups, + CharacterIndex characterIndex, + AssetIndex assetIndex, + IReadOnlyDictionary existingKnowledge, + Scene importedScene, + SceneIntelligenceScene parsed, + IReadOnlyList knowledgeStates) + { + foreach (var change in parsed.KnowledgeChanges ?? []) + { + AddKnowledge(groups, characterIndex, assetIndex, existingKnowledge, importedScene, parsed, change.RecipientCharacter, change.KnowledgeItem, change.ChangeType, change.Evidence, change.Confidence, knowledgeStates); + } + + foreach (var observation in parsed.Observations ?? []) + { + if (!IsKnowledgeObservation(observation)) + { + continue; + } + + AddKnowledge( + groups, + characterIndex, + assetIndex, + existingKnowledge, + importedScene, + parsed, + observation.SubjectName, + FirstConfigured(observation.Description, JoinConfigured(observation.Predicate, observation.ObjectName)), + FirstConfigured(observation.Predicate, observation.ObservationType), + observation.Evidence, + observation.Confidence, + knowledgeStates); + } + } + + private static void AddKnowledge( + Dictionary groups, + CharacterIndex characterIndex, + AssetIndex assetIndex, + IReadOnlyDictionary existingKnowledge, + Scene importedScene, + SceneIntelligenceScene parsed, + string? characterName, + string? knowledgeItem, + string? changeType, + string? evidence, + decimal? confidence, + IReadOnlyList knowledgeStates) + { + var character = characterIndex.Find(characterName); + var statement = CleanKnowledgeStatement(knowledgeItem); + if (character is null || string.IsNullOrWhiteSpace(statement)) + { + return; + } + + var key = KnowledgeKey(character.CharacterID, statement); + if (!groups.TryGetValue(key, out var candidate)) + { + var existingKey = ExistingKnowledgeKey(character.CharacterID, statement); + existingKnowledge.TryGetValue(existingKey, out var existingMatch); + var stateName = KnowledgeStateFromSignal(changeType, statement, knowledgeStates); + var assetMatch = assetIndex.Find(statement); + candidate = new KnowledgeCandidate( + key, + character.CharacterID, + character.CharacterName, + statement, + stateName, + assetMatch?.StoryAssetID, + existingMatch?.CharacterKnowledgeID, + existingMatch is null ? null : ExistingKnowledgeLabel(existingMatch)); + groups[key] = candidate; + } + + if ((confidence ?? 0m) >= (candidate.Confidence ?? 0m)) + { + candidate.KnowledgeStateName = KnowledgeStateFromSignal(changeType, statement, knowledgeStates); + } + + candidate.Confidence = Max(candidate.Confidence, confidence); + candidate.FirstScene ??= BuildSceneReference(importedScene); + candidate.ExampleScene ??= BuildFirstAppearance(importedScene, parsed); + candidate.Evidence ??= Clean(evidence); + candidate.Appearances.Add(new KnowledgeAppearanceImport( + importedScene.SceneID, + importedScene.SceneNumber, + importedScene.SceneTitle, + changeType, + evidence, + confidence)); + } + + private async Task> ListExistingKnowledgeAsync(int projectId) + { + var rows = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var character in await characters.ListCharactersAsync(projectId)) + { + foreach (var item in await characters.ListKnowledgeByCharacterAsync(character.CharacterID)) + { + var key = ExistingKnowledgeKey(item.CharacterID, KnowledgeTitle(item)); + if (!rows.ContainsKey(key)) + { + rows[key] = item; + } + } + } + + return rows; + } + + 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 async Task BuildAssetIndexAsync(int projectId) + { + var index = new AssetIndex(); + foreach (var asset in await assets.ListAssetsAsync(projectId)) + { + index.Add(asset.AssetName, asset.StoryAssetID, asset.AssetName); + foreach (var alias in await assets.ListAliasesAsync(asset.StoryAssetID)) + { + index.Add(alias.Alias, asset.StoryAssetID, asset.AssetName); + } + } + + 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 IsKnowledgeObservation(SceneIntelligenceObservation observation) + { + var type = Clean(observation.ObservationType); + if (!type.Contains("knowledge", StringComparison.OrdinalIgnoreCase) + && !type.Contains("discover", StringComparison.OrdinalIgnoreCase) + && !type.Contains("belief", StringComparison.OrdinalIgnoreCase) + && !type.Contains("suspect", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return IsCharacterEntity(observation.SubjectEntityType) + && !string.IsNullOrWhiteSpace(observation.SubjectName) + && !string.IsNullOrWhiteSpace(FirstConfigured(observation.Description, observation.Predicate, observation.ObjectName)); + } + + private static string KnowledgeStateFromSignal(string? changeType, string? statement, IReadOnlyList states) + { + var value = NormaliseForMatching($"{changeType} {statement}"); + var preferred = + ContainsAny(value, "unaware", "does not know", "doesn't know", "not know", "forgets", "forgot", "stops believing") ? "Unaware" : + ContainsAny(value, "suspects", "suspect", "suspicion") ? "Suspects" : + ContainsAny(value, "misunderstands", "misunderstand", "mistaken", "false belief", "wrongly believes") ? "Misunderstands" : + ContainsAny(value, "believes", "belief", "partially", "may know") ? "Partially Knows" : + ContainsAny(value, "learns", "discovers", "confirms", "realises", "realizes", "is told", "knows", "reveals", "revealed") ? "Knows" : + "Knows"; + + return states.FirstOrDefault(state => string.Equals(state.StateName, preferred, StringComparison.OrdinalIgnoreCase))?.StateName + ?? states.FirstOrDefault(state => string.Equals(state.StateName, "Knows", StringComparison.OrdinalIgnoreCase))?.StateName + ?? states.FirstOrDefault()?.StateName + ?? "Knows"; + } + + private static int ResolveKnowledgeStateID(IReadOnlyList states, int? selectedKnowledgeStateId, string? suggestedState) + => selectedKnowledgeStateId.HasValue && states.Any(state => state.KnowledgeStateID == selectedKnowledgeStateId.Value) + ? selectedKnowledgeStateId.Value + : states.FirstOrDefault(state => string.Equals(state.StateName, suggestedState, StringComparison.OrdinalIgnoreCase))?.KnowledgeStateID + ?? states.FirstOrDefault(state => string.Equals(state.StateName, "Knows", StringComparison.OrdinalIgnoreCase))?.KnowledgeStateID + ?? states.First().KnowledgeStateID; + + private static void SaveKnowledgeSelections(OnboardingStoryIntelligenceBatch batch, IEnumerable choices) + { + foreach (var choice in choices) + { + if (string.IsNullOrWhiteSpace(choice.Key) || choice.KnowledgeStateID is not { } knowledgeStateId) + { + continue; + } + + batch.KnowledgeSelections.RemoveAll(selection => string.Equals(selection.Key, choice.Key, StringComparison.OrdinalIgnoreCase)); + batch.KnowledgeSelections.Add(new OnboardingStoryIntelligenceKnowledgeSelection + { + Key = choice.Key, + KnowledgeStateID = knowledgeStateId + }); + } + } + + private static void AddDecision(OnboardingStoryIntelligenceBatch batch, string key, string action, int? knowledgeId, string? label, bool createdOrLinked) + { + batch.KnowledgeDecisions.RemoveAll(decision => string.Equals(decision.Key, key, StringComparison.OrdinalIgnoreCase)); + batch.KnowledgeDecisions.Add(new OnboardingStoryIntelligenceKnowledgeDecision + { + Key = key, + Action = action, + CharacterKnowledgeID = knowledgeId, + KnowledgeLabel = label, + CreatedOrLinked = createdOrLinked + }); + } + + private static string BuildKnowledgeDescription(KnowledgeCandidate candidate, KnowledgeAppearanceImport appearance) + { + var lines = new List { candidate.KnowledgeStatement }; + if (!string.IsNullOrWhiteSpace(appearance.ChangeType)) + { + lines.Add($"Change: {Clean(appearance.ChangeType)}."); + } + + if (!string.IsNullOrWhiteSpace(appearance.Evidence)) + { + lines.Add($"Evidence: {Clean(appearance.Evidence)}"); + } + + lines.Add("Imported from Story Intelligence knowledge review."); + return string.Join(Environment.NewLine, lines); + } + + private static string KnowledgeLabel(KnowledgeCandidate candidate) + => $"{candidate.CharacterName}: {candidate.KnowledgeStatement}"; + + private static string ExistingKnowledgeLabel(CharacterKnowledgeItem item) + => $"{item.CharacterName}: {item.KnowledgeStateName} - {KnowledgeTitle(item)}"; + + private static string KnowledgeTitle(CharacterKnowledgeItem item) + => CleanKnowledgeStatement(item.Description); + + private static string CleanKnowledgeStatement(string? value) + { + var cleaned = Clean(value); + if (string.IsNullOrWhiteSpace(cleaned)) + { + return string.Empty; + } + + foreach (var prefix in new[] { "Knowledge:", "Evidence:", "Change:" }) + { + if (cleaned.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + cleaned = cleaned[prefix.Length..].Trim(); + } + } + + var firstLine = cleaned.Split('\n', '\r').Select(line => line.Trim()).FirstOrDefault(line => !string.IsNullOrWhiteSpace(line)); + return firstLine ?? cleaned; + } + + private static string KnowledgeKey(int characterId, string statement) + => $"{characterId}:{CanonicalKnowledgeKey(statement)}"; + + private static string ExistingKnowledgeKey(int characterId, string statement) + => KnowledgeKey(characterId, statement); + + private static string CanonicalKnowledgeKey(string? value) + { + var normalised = NormaliseForMatching(value); + var possessiveFamily = Regex.Match( + normalised, + @"\b(?\w+)\s+(?:is|was)\s+(?\w+)\s+s\s+(?:biological\s+)?(?father|mother|parent|sister|brother|sibling|aunt|uncle|cousin|child|son|daughter)\b", + RegexOptions.IgnoreCase); + if (possessiveFamily.Success) + { + return $"{possessiveFamily.Groups["relation"].Value} {possessiveFamily.Groups["subject"].Value} {possessiveFamily.Groups["holder"].Value}"; + } + + var pronounFamily = Regex.Match( + normalised, + @"\b(?\w+)\s+.*?\b(?\w+)\s+(?:is|was)\s+(?:her|his|their)\s+(?:biological\s+)?(?father|mother|parent|sister|brother|sibling|aunt|uncle|cousin|child|son|daughter)\b", + RegexOptions.IgnoreCase); + if (pronounFamily.Success) + { + return $"{pronounFamily.Groups["relation"].Value} {pronounFamily.Groups["subject"].Value} {pronounFamily.Groups["holder"].Value}"; + } + + normalised = Regex.Replace(normalised, @"\b(discovers?|learns?|realises?|realizes?|knows?|believes?|suspects?|confirms?|is told|that|the|a|an|his|her|their|its|about)\b", " ", RegexOptions.IgnoreCase); + return Whitespace.Replace(normalised, " ").Trim(); + } + + 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 bool IsCharacterEntity(string? entityType) + => string.Equals(Clean(entityType), "Character", StringComparison.OrdinalIgnoreCase); + + private static string ConfidenceBand(decimal? confidence) + => confidence switch + { + >= 0.75m => "High", + >= 0.45m => "Medium", + null => "Unknown", + _ => "Low" + }; + + private static decimal? Max(decimal? current, decimal? next) + => current.HasValue && next.HasValue ? Math.Max(current.Value, next.Value) : current ?? next; + + private static string? JoinConfigured(params string?[] values) + { + var configured = values.Select(Clean).Where(value => !string.IsNullOrWhiteSpace(value)).ToList(); + return configured.Count == 0 ? null : string.Join(" ", configured); + } + + 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 NormaliseForMatching(string? value) + => $" {NonWord.Replace(Normalise(value), " ")} "; + + private static bool ContainsAny(string value, params string[] needles) + => needles.Any(needle => value.Contains(NormaliseForMatching(needle), StringComparison.Ordinal)); + + 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 Regex NonWord = new(@"[^\p{L}\p{N}]+", RegexOptions.Compiled); + + private sealed record KnowledgeCandidateData(bool HasCommittedScenes, int AlreadyLinkedCount, IReadOnlyList Candidates); + + private sealed class KnowledgeCandidate( + string key, + int characterId, + string characterName, + string knowledgeStatement, + string knowledgeStateName, + int? storyAssetId, + int? existingKnowledgeId, + string? existingKnowledgeLabel) + { + public string Key { get; } = key; + public int CharacterID { get; } = characterId; + public string CharacterName { get; } = characterName; + public string KnowledgeStatement { get; } = knowledgeStatement; + public string KnowledgeStateName { get; set; } = knowledgeStateName; + public int? StoryAssetID { get; } = storyAssetId; + public int? ExistingKnowledgeID { get; } = existingKnowledgeId; + public string? ExistingKnowledgeLabel { get; } = existingKnowledgeLabel; + public decimal? Confidence { get; set; } + public string? FirstScene { get; set; } + public string? ExampleScene { get; set; } + public string? Evidence { get; set; } + public List Appearances { get; } = []; + } + + private sealed record KnowledgeAppearanceImport( + int SceneID, + decimal SceneNumber, + string SceneTitle, + string? ChangeType, + string? Evidence, + decimal? Confidence); + + 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 class AssetIndex + { + private readonly Dictionary byName = new(StringComparer.OrdinalIgnoreCase); + + public void Add(string? name, int storyAssetId, string assetName) + { + var key = Clean(name); + if (string.IsNullOrWhiteSpace(key) || byName.ContainsKey(key)) + { + return; + } + + byName[key] = new AssetMatch(storyAssetId, assetName); + } + + public AssetMatch? Find(string? statement) + { + var value = NormaliseForMatching(statement); + return byName.Values.FirstOrDefault(asset => value.Contains(NormaliseForMatching(asset.AssetName), StringComparison.Ordinal)); + } + } + + private sealed record CharacterMatch(int CharacterID, string CharacterName); + private sealed record AssetMatch(int StoryAssetID, string AssetName); +} diff --git a/PlotLine/Services/StoryIntelligencePipelineStateService.cs b/PlotLine/Services/StoryIntelligencePipelineStateService.cs index a87d539..63156c0 100644 --- a/PlotLine/Services/StoryIntelligencePipelineStateService.cs +++ b/PlotLine/Services/StoryIntelligencePipelineStateService.cs @@ -13,6 +13,7 @@ public interface IStoryIntelligencePipelineStateService Task RecordLocationImportAsync(int projectId, int bookId); Task RecordAssetImportAsync(int projectId, int bookId); Task RecordRelationshipImportAsync(int projectId, int bookId); + Task RecordKnowledgeImportAsync(int projectId, int bookId); Task> ListCommittedRunsByBookAsync(int bookId, int userId); } @@ -128,8 +129,23 @@ public sealed class StoryIntelligencePipelineStateService( { ProjectID = projectId, BookID = bookId, - CurrentStage = StoryIntelligencePipelineStages.Complete, + CurrentStage = StoryIntelligencePipelineStages.KnowledgeReview, LastCompletedStage = StoryIntelligencePipelineStages.RelationshipImport, + CurrentReviewStage = StoryIntelligencePipelineStages.KnowledgeReview, + Status = StoryIntelligencePipelineStatuses.NeedsReview, + CompletedUtc = null, + LastRunID = null + }); + } + + public async Task RecordKnowledgeImportAsync(int projectId, int bookId) + { + await pipelines.UpsertAsync(new StoryIntelligenceBookPipelineSaveRequest + { + ProjectID = projectId, + BookID = bookId, + CurrentStage = StoryIntelligencePipelineStages.Complete, + LastCompletedStage = StoryIntelligencePipelineStages.KnowledgeImport, CurrentReviewStage = null, Status = StoryIntelligencePipelineStatuses.Complete, CompletedUtc = DateTime.UtcNow, diff --git a/PlotLine/Services/StoryIntelligenceService.cs b/PlotLine/Services/StoryIntelligenceService.cs index 2824c5b..f22543a 100644 --- a/PlotLine/Services/StoryIntelligenceService.cs +++ b/PlotLine/Services/StoryIntelligenceService.cs @@ -269,11 +269,13 @@ public sealed class StoryIntelligenceService( return state.CurrentReviewStage switch { + StoryIntelligencePipelineStages.KnowledgeReview => "Waiting for Knowledge Review", 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 StoryIntelligenceReadyForKnowledge(state) => "Waiting for Knowledge Review", _ when StoryIntelligenceReadyForRelationships(state) => "Waiting for Relationship Review", _ when StoryIntelligenceReadyForAssets(state) => "Waiting for Asset Review", _ when StoryIntelligenceReadyForLocations(state) => "Waiting for Location Review", @@ -288,33 +290,42 @@ public sealed class StoryIntelligenceService( ? "All currently available Story Intelligence stages are complete." : state.CurrentReviewStage switch { + StoryIntelligencePipelineStages.KnowledgeReview => "Review detected knowledge changes and decide what to create or link.", 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 StoryIntelligenceReadyForKnowledge(state) => "Review detected knowledge changes and decide what to create or link.", _ 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 StoryIntelligenceReadyForKnowledge(StoryIntelligenceBookPipelineState state) + => string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.RelationshipImport, StringComparison.OrdinalIgnoreCase) + || state.CurrentStage is StoryIntelligencePipelineStages.KnowledgeReview or StoryIntelligencePipelineStages.KnowledgeImport; + private static bool StoryIntelligenceReadyForRelationships(StoryIntelligenceBookPipelineState state) => string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.AssetImport, StringComparison.OrdinalIgnoreCase) - || state.CurrentStage is StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport; + || state.CurrentStage is StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport + or StoryIntelligencePipelineStages.KnowledgeReview or StoryIntelligencePipelineStages.KnowledgeImport; 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 + or StoryIntelligencePipelineStages.KnowledgeReview or StoryIntelligencePipelineStages.KnowledgeImport || (string.Equals(state.CurrentStage, StoryIntelligencePipelineStages.Complete, StringComparison.OrdinalIgnoreCase) - && !string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.RelationshipImport, StringComparison.OrdinalIgnoreCase)); + && !string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.KnowledgeImport, StringComparison.OrdinalIgnoreCase)); private static bool StoryIntelligenceReadyForLocations(StoryIntelligenceBookPipelineState state) => string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.CharacterImport, StringComparison.OrdinalIgnoreCase) || state.CurrentStage is StoryIntelligencePipelineStages.LocationReview or StoryIntelligencePipelineStages.LocationImport or StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport - or StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport; + or StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport + or StoryIntelligencePipelineStages.KnowledgeReview or StoryIntelligencePipelineStages.KnowledgeImport; private static string FormatElapsed(TimeSpan elapsed) => elapsed.TotalMinutes < 1 diff --git a/PlotLine/Sql/133_Phase20AU_KnowledgeReviewPipelineStages.sql b/PlotLine/Sql/133_Phase20AU_KnowledgeReviewPipelineStages.sql new file mode 100644 index 0000000..90bc6a4 --- /dev/null +++ b/PlotLine/Sql/133_Phase20AU_KnowledgeReviewPipelineStages.sql @@ -0,0 +1,53 @@ +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'KnowledgeReview', + N'KnowledgeImport', + N'Complete' + )); +END; +GO + +IF OBJECT_ID(N'dbo.StoryIntelligenceBookPipelines', N'U') IS NOT NULL +BEGIN + UPDATE dbo.StoryIntelligenceBookPipelines + SET CurrentStage = N'KnowledgeReview', + CurrentReviewStage = N'KnowledgeReview', + Status = N'NeedsReview', + CompletedUtc = NULL, + UpdatedUtc = SYSUTCDATETIME() + WHERE CurrentStage = N'Complete' + AND LastCompletedStage = N'RelationshipImport'; +END; +GO diff --git a/PlotLine/ViewModels/OnboardingViewModels.cs b/PlotLine/ViewModels/OnboardingViewModels.cs index cbe79ec..14e0d94 100644 --- a/PlotLine/ViewModels/OnboardingViewModels.cs +++ b/PlotLine/ViewModels/OnboardingViewModels.cs @@ -147,6 +147,7 @@ public sealed class StoryIntelligenceProgressViewModel public StoryIntelligenceLocationReviewViewModel LocationReview { get; init; } = new(); public StoryIntelligenceAssetReviewViewModel AssetReview { get; init; } = new(); public StoryIntelligenceRelationshipReviewViewModel RelationshipReview { get; init; } = new(); + public StoryIntelligenceKnowledgeReviewViewModel KnowledgeReview { get; init; } = new(); public StoryIntelligencePipelineDashboardViewModel PipelineDashboard { get; init; } = new(); public int ChapterCount => Chapters.Count; public int CompletedChapterCount => Chapters.Count(chapter => chapter.IsRunComplete); @@ -165,6 +166,7 @@ public sealed class StoryIntelligenceProgressViewModel public bool HasLocationsToReview => LocationReview.Candidates.Count > 0; public bool HasAssetsToReview => AssetReview.Candidates.Count > 0; public bool HasRelationshipsToReview => RelationshipReview.Candidates.Count > 0; + public bool HasKnowledgeToReview => KnowledgeReview.Candidates.Count > 0; } public sealed class StoryIntelligencePipelineDashboardViewModel @@ -183,6 +185,9 @@ public sealed class StoryIntelligencePipelineDashboardViewModel public int RelationshipsIdentified { get; init; } public int RelationshipsCreatedOrLinked { get; init; } public bool RelationshipStageComplete { get; init; } + public int KnowledgeIdentified { get; init; } + public int KnowledgeCreatedOrLinked { get; init; } + public bool KnowledgeStageComplete { get; init; } } public sealed class StoryIntelligenceCharacterReviewViewModel @@ -379,6 +384,55 @@ public static class StoryIntelligenceRelationshipImportActions public const string Alias = "Alias"; } +public sealed class StoryIntelligenceKnowledgeReviewViewModel +{ + public bool CanImport { get; init; } + public bool HasCommittedScenes { get; init; } + public int AlreadyLinkedCount { get; init; } + public bool IsComplete { get; init; } + public IReadOnlyList KnowledgeStateOptions { get; init; } = []; + public IReadOnlyList Candidates { get; init; } = []; +} + +public sealed class StoryIntelligenceKnowledgeReviewCandidateViewModel +{ + public string Key { get; init; } = string.Empty; + public string CharacterName { get; init; } = string.Empty; + public string KnowledgeStatement { get; init; } = string.Empty; + public int KnowledgeStateID { get; init; } + public string KnowledgeStateName { get; init; } = "Knows"; + public int AppearsInScenes { get; init; } + public string Confidence { get; init; } = "Unknown"; + public string? FirstScene { get; init; } + public string? ExampleScene { get; init; } + public string? Evidence { get; init; } + public int? ExistingKnowledgeID { get; init; } + public string? ExistingKnowledgeLabel { get; init; } + public bool IsExistingMatch => ExistingKnowledgeID.HasValue; +} + +public sealed class StoryIntelligenceKnowledgeImportForm +{ + public Guid BatchID { get; set; } + public List Knowledge { get; set; } = []; +} + +public sealed class StoryIntelligenceKnowledgeImportChoiceForm +{ + public string Key { get; set; } = string.Empty; + public string Action { get; set; } = StoryIntelligenceKnowledgeImportActions.CreateNew; + public int? KnowledgeStateID { get; set; } + public string? AliasTargetKey { get; set; } +} + +public static class StoryIntelligenceKnowledgeImportActions +{ + 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(); @@ -450,6 +504,17 @@ public sealed class StoryIntelligenceRelationshipImportResultViewModel public int RelationshipEventsCreated { get; init; } } +public sealed class StoryIntelligenceKnowledgeImportResultViewModel +{ + public Guid BatchID { get; init; } + public int ProjectID { get; init; } + public int BookID { get; init; } + public int KnowledgeCreated { get; init; } + public int KnowledgeLinked { get; init; } + public int KnowledgeMerged { get; init; } + public int KnowledgeIgnored { get; init; } +} + public sealed class StoryIntelligenceOnboardingChapterViewModel { public string TemporaryChapterKey { get; init; } = string.Empty; diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceComplete.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceComplete.cshtml index f9dbc99..0c993ed 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceComplete.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceComplete.cshtml @@ -25,7 +25,7 @@
  • Locations
  • Assets
  • Relationships
  • -
  • KnowledgeComing Soon
  • +
  • Knowledge
  • Preparing continuity...
  • Generating story indexes...
  • Finalising project...
  • diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceKnowledge.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceKnowledge.cshtml new file mode 100644 index 0000000..dbf8237 --- /dev/null +++ b/PlotLine/Views/Onboarding/StoryIntelligenceKnowledge.cshtml @@ -0,0 +1,236 @@ +@model StoryIntelligenceProgressViewModel +@{ + ViewData["Title"] = "Review knowledge"; +} + +
    +
    + +
    +

    Phase 3

    +

    Review Story Intelligence

    +

    Stage 6 of 6

    +

    Review Knowledge

    +

    Approve the facts, discoveries, beliefs and suspicions PlotDirector should add to the existing Knowledge Explorer.

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

    Knowledge is already up to date, or there were no knowledge changes ready to import from the stored scene analysis.

    +
    +
    + @FutureStage("Review Continuity") + @FutureStage("Timeline Events") +
    +
    + Back to relationships +
    + + +
    +
    + } + else + { +
    + + + + + +
    + +
    + +
    + @for (var i = 0; i < Model.KnowledgeReview.Candidates.Count; i++) + { + var candidate = Model.KnowledgeReview.Candidates[i]; +
    + + + @candidate.CharacterName + @candidate.KnowledgeStateName - @candidate.KnowledgeStatement + + @candidate.Confidence + + + + +
    +
    +
    Character
    @candidate.CharacterName
    +
    Knowledge statement
    @candidate.KnowledgeStatement
    +
    First scene
    @Display(candidate.FirstScene)
    +
    Existing match
    @(candidate.ExistingKnowledgeLabel ?? "None")
    +
    + + @if (!string.IsNullOrWhiteSpace(candidate.ExampleScene)) + { +
    + Example scene +

    @candidate.ExampleScene

    +
    + } + + @if (!string.IsNullOrWhiteSpace(candidate.Evidence)) + { +
    + Evidence +

    @candidate.Evidence

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

    This fact may already exist in PlotDirector. Choose whether to link it or create a separate knowledge record.

    +
    + } + +
    + Decision + + + + +
    + +
    + + +
    + + +
    +
    + } +
    + +
    + Back to relationships + +
    +
    + } +
    +
    +
    + +@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/StoryIntelligenceKnowledgeComplete.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceKnowledgeComplete.cshtml new file mode 100644 index 0000000..2378537 --- /dev/null +++ b/PlotLine/Views/Onboarding/StoryIntelligenceKnowledgeComplete.cshtml @@ -0,0 +1,47 @@ +@model StoryIntelligenceKnowledgeImportResultViewModel +@{ + ViewData["Title"] = "Knowledge created"; +} + +
    +
    + +
    +

    Build Story Database

    +
    + +

    Knowledge created

    +
    +

    PlotDirector updated the existing Knowledge Explorer from the approved Story Intelligence decisions.

    +
    + +
    +
    + Knowledge created + @Model.KnowledgeCreated.ToString("N0") +
    +
    + Knowledge linked + @Model.KnowledgeLinked.ToString("N0") +
    +
    + Knowledge merged + @Model.KnowledgeMerged.ToString("N0") +
    +
    + Knowledge ignored + @Model.KnowledgeIgnored.ToString("N0") +
    +
    + +
    +
    Review ContinuityComing Soon
    +
    Timeline EventsComing Soon
    +
    + + +
    +
    diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceRelationshipComplete.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceRelationshipComplete.cshtml index 62e9669..66d47b6 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceRelationshipComplete.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceRelationshipComplete.cshtml @@ -12,7 +12,7 @@

    Relationships created

    -

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

    +

    PlotDirector updated your story database from the approved relationship decisions. Next, review the knowledge changes detected in the imported scenes.

    @@ -39,14 +39,13 @@
    -
    Review KnowledgeComing Soon
    Review ContinuityComing Soon
    Timeline EventsComing Soon
    diff --git a/PlotLine/Views/Onboarding/_StoryIntelligencePipelineHeader.cshtml b/PlotLine/Views/Onboarding/_StoryIntelligencePipelineHeader.cshtml index a641a7c..263d6e4 100644 --- a/PlotLine/Views/Onboarding/_StoryIntelligencePipelineHeader.cshtml +++ b/PlotLine/Views/Onboarding/_StoryIntelligencePipelineHeader.cshtml @@ -7,7 +7,7 @@ new PipelineStage("Review Locations", "Locations", true), new PipelineStage("Review Assets", "Assets", true), new PipelineStage("Review Relationships", "Relationships", true), - new PipelineStage("Review Knowledge", "Knowledge", false) + new PipelineStage("Review Knowledge", "Knowledge", true) }; var implementedReviewStages = reviewStages.Where(stage => stage.IsImplemented).ToList(); var currentReviewIndex = implementedReviewStages.FindIndex(stage => string.Equals(stage.StageName, Model.CurrentStage, StringComparison.OrdinalIgnoreCase)); diff --git a/PlotLine/Views/Onboarding/_StoryIntelligencePipelineSummary.cshtml b/PlotLine/Views/Onboarding/_StoryIntelligencePipelineSummary.cshtml index affba8b..9b4d7de 100644 --- a/PlotLine/Views/Onboarding/_StoryIntelligencePipelineSummary.cshtml +++ b/PlotLine/Views/Onboarding/_StoryIntelligencePipelineSummary.cshtml @@ -42,7 +42,11 @@ @(Model.RelationshipStageComplete ? $"Done: {Model.RelationshipsCreatedOrLinked:N0}" : "In review")
    - Knowledge - Coming next + Knowledge identified + @Model.KnowledgeIdentified.ToString("N0") +
    +
    + Knowledge created / linked + @(Model.KnowledgeStageComplete ? $"Done: {Model.KnowledgeCreatedOrLinked:N0}" : "In review")