From 11de81abeddf9e227f1736ffb615d69120af105c Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Thu, 9 Jul 2026 21:02:41 +0100 Subject: [PATCH] =?UTF-8?q?Phase=2020AP=20=E2=80=93=20Asset=20Intelligence?= =?UTF-8?q?=20Review=20and=20Import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PlotLine.Tests/Program.cs | 50 +- PlotLine/Controllers/OnboardingController.cs | 58 ++ ...StoryIntelligence_Phase20AP_AssetImport.md | 88 ++ .../StoryIntelligencePersistenceModels.cs | 2 + PlotLine/Program.cs | 1 + .../OnboardingStoryIntelligenceService.cs | 79 +- .../StoryIntelligenceAssetImportService.cs | 843 ++++++++++++++++++ .../StoryIntelligenceLocationImportService.cs | 4 +- .../StoryIntelligencePipelineStateService.cs | 18 +- ...30_Phase20AP_AssetReviewPipelineStages.sql | 36 + PlotLine/ViewModels/OnboardingViewModels.cs | 69 ++ .../StoryIntelligenceAssetComplete.cshtml | 57 ++ .../Onboarding/StoryIntelligenceAssets.cshtml | 236 +++++ .../StoryIntelligenceComplete.cshtml | 12 +- .../StoryIntelligenceLocationComplete.cshtml | 4 +- .../StoryIntelligenceLocations.cshtml | 4 +- .../_StoryIntelligencePipelineHeader.cshtml | 2 +- .../_StoryIntelligencePipelineSummary.cshtml | 8 +- 18 files changed, 1556 insertions(+), 15 deletions(-) create mode 100644 PlotLine/Docs/StoryIntelligence_Phase20AP_AssetImport.md create mode 100644 PlotLine/Services/StoryIntelligenceAssetImportService.cs create mode 100644 PlotLine/Sql/130_Phase20AP_AssetReviewPipelineStages.sql create mode 100644 PlotLine/Views/Onboarding/StoryIntelligenceAssetComplete.cshtml create mode 100644 PlotLine/Views/Onboarding/StoryIntelligenceAssets.cshtml diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index f9b3cf1..401efd1 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -15,7 +15,10 @@ var tests = new (string Name, Action Test)[] ("Character filtering preserves titled names", CharacterFilteringPreservesTitledNames), ("Location filtering rejects merged phrases", LocationFilteringRejectsMergedPhrases), ("Location filtering preserves story locations", LocationFilteringPreservesStoryLocations), - ("Location canonical keys merge trivial variants", LocationCanonicalKeysMergeTrivialVariants) + ("Location canonical keys merge trivial variants", LocationCanonicalKeysMergeTrivialVariants), + ("Asset filtering rejects generic objects", AssetFilteringRejectsGenericObjects), + ("Asset filtering preserves story assets", AssetFilteringPreservesStoryAssets), + ("Asset canonical keys merge trivial variants", AssetCanonicalKeysMergeTrivialVariants) }; foreach (var test in tests) @@ -198,6 +201,51 @@ static bool InvokePrivateLocationFilter(string methodName, string name) return method!.Invoke(null, [name]) is true; } +static void AssetFilteringRejectsGenericObjects() +{ + Assert(!IsAssetNameCandidate("chair"), "Chair should not be treated as a story asset."); + Assert(!IsAssetNameCandidate("table"), "Table should not be treated as a story asset."); + Assert(!IsAssetNameCandidate("street"), "Street should not be treated as a story asset."); + Assert(!IsAssetNameCandidate("car park"), "Car park should not be treated as a story asset."); +} + +static void AssetFilteringPreservesStoryAssets() +{ + Assert(IsAssetNameCandidate("TR6"), "TR6 should be treated as a story asset."); + Assert(IsAssetNameCandidate("red notebook"), "Red notebook should be treated as a story asset."); + Assert(IsAssetNameCandidate("passport"), "Passport should be treated as a story asset."); + Assert(IsAssetNameCandidate("car keys"), "Car keys should be treated as a story asset."); + Assert(IsAssetNameCandidate("driving licence"), "Driving licence should be treated as a story asset."); +} + +static void AssetCanonicalKeysMergeTrivialVariants() +{ + Assert(AssetCanonicalKey("red notebook") == AssetCanonicalKey("the notebook"), "Notebook variants should merge."); + Assert(AssetCanonicalKey("photo") == AssetCanonicalKey("photograph"), "Photo should fold into photograph."); + Assert(AssetCanonicalKey("driving license") == AssetCanonicalKey("driving licence"), "License/licence spelling should merge."); +} + +static bool IsAssetNameCandidate(string name) + => InvokePrivateAssetFilter("IsAssetNameCandidate", name); + +static string AssetCanonicalKey(string name) +{ + var method = typeof(StoryIntelligenceAssetImportService).GetMethod( + "CanonicalAssetKey", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + Assert(method is not null, "CanonicalAssetKey was not found."); + return (string)method!.Invoke(null, [name])!; +} + +static bool InvokePrivateAssetFilter(string methodName, string name) +{ + var method = typeof(StoryIntelligenceAssetImportService).GetMethod( + methodName, + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + Assert(method is not null, $"{methodName} was not found."); + return method!.Invoke(null, [name]) is true; +} + static JsonSerializerOptions JsonOptions() => new() { diff --git a/PlotLine/Controllers/OnboardingController.cs b/PlotLine/Controllers/OnboardingController.cs index 9b96053..482e1df 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.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 }), _ => RedirectToAction(nameof(StoryIntelligenceReview), new { batchId = target.BatchID }) @@ -181,6 +182,35 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard : View(model); } + [HttpGet("story-intelligence/assets")] + public async Task StoryIntelligenceAssets(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 }); + } + + return !model.PipelineDashboard.LocationStageComplete + ? RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId }) + : View(model); + } + [HttpPost("story-intelligence/cancel")] [ValidateAntiForgeryToken] public async Task CancelStoryIntelligence(Guid batchId) @@ -277,6 +307,29 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard return model is null ? NotFound() : View(model); } + [HttpPost("story-intelligence/assets")] + [ValidateAntiForgeryToken] + public async Task ImportStoryIntelligenceAssets(StoryIntelligenceAssetImportForm form) + { + var (progress, result) = await storyIntelligence.ImportAssetsAsync(form.BatchID, form); + if (progress is null) + { + return NotFound(); + } + + TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; + return result.Success + ? RedirectToAction(nameof(StoryIntelligenceAssetComplete), new { batchId = form.BatchID }) + : RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId = form.BatchID }); + } + + [HttpGet("story-intelligence/assets/complete")] + public async Task StoryIntelligenceAssetComplete(Guid batchId) + { + var model = await storyIntelligence.GetAssetImportResultAsync(batchId); + return model is null ? NotFound() : View(model); + } + [HttpGet("story-intelligence/complete")] public async Task StoryIntelligenceComplete(Guid batchId) { @@ -296,6 +349,11 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId }); } + if (!progress.PipelineDashboard.AssetStageComplete) + { + return RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId }); + } + var model = await storyIntelligence.GetCompletionAsync(batchId); return model is null ? NotFound() : View(model); } diff --git a/PlotLine/Docs/StoryIntelligence_Phase20AP_AssetImport.md b/PlotLine/Docs/StoryIntelligence_Phase20AP_AssetImport.md new file mode 100644 index 0000000..5d70a9d --- /dev/null +++ b/PlotLine/Docs/StoryIntelligence_Phase20AP_AssetImport.md @@ -0,0 +1,88 @@ +# Phase 20AP - Asset Intelligence Review and Import + +Phase 20AP adds the first implemented Asset Intelligence import stage to the Story Intelligence pipeline. + +The stage uses only stored Scene Intelligence JSON from completed chapter runs. It does not make additional OpenAI calls and does not change the Scene Intelligence prompts. + +## Implemented Asset Stage + +After Character Review/Import and Location Review/Import, the pipeline now continues to: + +- Review Assets +- Create Assets +- Complete + +Resume support routes books with completed characters and locations, but incomplete assets, directly to Asset Review. + +## Source Data + +Asset candidates are built from: + +- `assets[]` in stored Scene Intelligence results +- entity-mappable observations whose subject or object is an Asset/Object + +The import does not reread the manuscript and does not send raw text back to AI. + +## Candidate Filtering + +The asset resolver keeps story-significant objects such as: + +- TR6 +- rucksack +- note +- letter +- necklace +- driving licence +- car keys +- passport +- envelope +- photograph +- map +- diary +- notebook + +It suppresses generic scenery, furniture, broad locations and incidental items unless there is repeated story significance or clear asset signal. + +## Duplicate and Alias Handling + +Asset variants are folded into one review candidate where safe. + +Examples: + +- `red notebook`, `the notebook`, `old notebook` -> `Notebook` +- `photo` -> `Photograph` +- `driving license` -> `Driving licence` + +The review screen supports: + +- create new asset +- link to existing asset +- create separate asset +- alias/variant of another reviewed asset +- ignore + +## Imported Fields + +Approved assets create or link: + +- `StoryAssets` +- `AssetAliases` +- `AssetEvents` +- `AssetCustodyEvents` only when owner/holder text resolves to an existing character + +Asset kind is mapped to existing lookup values such as Document, Evidence, Vehicle or Physical Object. New lookup values are not created. + +Scene appearances are imported as Asset Events so they appear in the Scene Inspector World panel and on Asset pages/timelines. + +## Not Yet Implemented + +The following remain future Story Intelligence stages: + +- relationships +- knowledge +- continuity +- real timeline events +- full asset dependency modelling +- unresolved owner creation + +No placeholder characters or owners are created by this phase. diff --git a/PlotLine/Models/StoryIntelligencePersistenceModels.cs b/PlotLine/Models/StoryIntelligencePersistenceModels.cs index a8731cb..b36447b 100644 --- a/PlotLine/Models/StoryIntelligencePersistenceModels.cs +++ b/PlotLine/Models/StoryIntelligencePersistenceModels.cs @@ -30,6 +30,8 @@ public static class StoryIntelligencePipelineStages public const string CharacterImport = "CharacterImport"; public const string LocationReview = "LocationReview"; public const string LocationImport = "LocationImport"; + public const string AssetReview = "AssetReview"; + public const string AssetImport = "AssetImport"; public const string Complete = "Complete"; } diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index d100176..421d899 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -209,6 +209,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 563f996..d498e6b 100644 --- a/PlotLine/Services/OnboardingStoryIntelligenceService.cs +++ b/PlotLine/Services/OnboardingStoryIntelligenceService.cs @@ -18,6 +18,8 @@ public interface IOnboardingStoryIntelligenceService Task GetCharacterImportResultAsync(Guid batchId); Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportLocationsAsync(Guid batchId, StoryIntelligenceLocationImportForm form); Task GetLocationImportResultAsync(Guid batchId); + Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportAssetsAsync(Guid batchId, StoryIntelligenceAssetImportForm form); + Task GetAssetImportResultAsync(Guid batchId); Task GetCompletionAsync(Guid batchId); Task ResumeBookAsync(int bookId); } @@ -30,6 +32,7 @@ public sealed class OnboardingStoryIntelligenceService( IStoryIntelligenceImportCommitService commits, IStoryIntelligenceCharacterImportService characterImport, IStoryIntelligenceLocationImportService locationImport, + IStoryIntelligenceAssetImportService assetImport, IStoryIntelligencePipelineStateService pipelineState, IStoryIntelligenceClient client, IOnboardingStoryIntelligenceBatchStore batchStore, @@ -263,6 +266,7 @@ public sealed class OnboardingStoryIntelligenceService( var characterReview = await characterImport.BuildReviewAsync(batch); var locationReview = await locationImport.BuildReviewAsync(batch); + var assetReview = await assetImport.BuildReviewAsync(batch); return new StoryIntelligenceProgressViewModel { BatchID = batch.BatchID, @@ -274,6 +278,7 @@ public sealed class OnboardingStoryIntelligenceService( Chapters = chapters, CharacterReview = characterReview, LocationReview = locationReview, + AssetReview = assetReview, PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel { ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete), @@ -283,7 +288,10 @@ public sealed class OnboardingStoryIntelligenceService( CharacterStageComplete = batch.CharacterStageComplete || characterReview.IsComplete, LocationsIdentified = locationReview.Candidates.Count + batch.LocationDecisions.Count, LocationsCreatedOrLinked = batch.LocationDecisions.Count(decision => decision.CreatedOrLinked), - LocationStageComplete = batch.LocationStageComplete || locationReview.IsComplete + LocationStageComplete = batch.LocationStageComplete || locationReview.IsComplete, + AssetsIdentified = assetReview.Candidates.Count + batch.AssetDecisions.Count, + AssetsCreatedOrLinked = batch.AssetDecisions.Count(decision => decision.CreatedOrLinked), + AssetStageComplete = batch.AssetStageComplete || assetReview.IsComplete } }; } @@ -418,6 +426,40 @@ public sealed class OnboardingStoryIntelligenceService( }; } + public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportAssetsAsync(Guid batchId, StoryIntelligenceAssetImportForm 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 assetImport.ImportAsync(batch, form); + return (await GetProgressAsync(batchId), result); + } + + public async Task GetAssetImportResultAsync(Guid batchId) + { + var batch = await batchStore.GetAsync(RequireUserId(), batchId); + if (batch is null) + { + return null; + } + + return new StoryIntelligenceAssetImportResultViewModel + { + BatchID = batch.BatchID, + ProjectID = batch.ProjectID, + BookID = batch.BookID, + AssetsCreated = batch.LastAssetImportResult?.AssetsCreated ?? 0, + AssetsLinked = batch.LastAssetImportResult?.AssetsLinked ?? 0, + AssetAliasesAdded = batch.LastAssetImportResult?.AssetAliasesAdded ?? 0, + AssetsIgnored = batch.LastAssetImportResult?.AssetsIgnored ?? 0, + SceneAssetEventsCreated = batch.LastAssetImportResult?.SceneAssetEventsCreated ?? 0, + OwnershipLinksCreated = batch.LastAssetImportResult?.OwnershipLinksCreated ?? 0 + }; + } + public async Task GetCompletionAsync(Guid batchId) { var progress = await GetProgressAsync(batchId); @@ -439,7 +481,9 @@ public sealed class OnboardingStoryIntelligenceService( CharactersCreatedOrLinked = progress.PipelineDashboard.CharactersCreatedOrLinked, CharacterStageComplete = progress.PipelineDashboard.CharacterStageComplete, LocationsCreatedOrLinked = progress.PipelineDashboard.LocationsCreatedOrLinked, - LocationStageComplete = progress.PipelineDashboard.LocationStageComplete + LocationStageComplete = progress.PipelineDashboard.LocationStageComplete, + AssetsCreatedOrLinked = progress.PipelineDashboard.AssetsCreatedOrLinked, + AssetStageComplete = progress.PipelineDashboard.AssetStageComplete }; } @@ -482,6 +526,12 @@ public sealed class OnboardingStoryIntelligenceService( }; if (state.IsComplete) + { + batch.CharacterStageComplete = true; + batch.LocationStageComplete = true; + batch.AssetStageComplete = true; + } + else if (state.CurrentStage is StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport) { batch.CharacterStageComplete = true; batch.LocationStageComplete = true; @@ -496,6 +546,8 @@ public sealed class OnboardingStoryIntelligenceService( var route = state.CurrentStage switch { StoryIntelligencePipelineStages.Complete => StoryIntelligenceResumeRoutes.Complete, + StoryIntelligencePipelineStages.AssetReview => StoryIntelligenceResumeRoutes.Assets, + StoryIntelligencePipelineStages.AssetImport => StoryIntelligenceResumeRoutes.Assets, StoryIntelligencePipelineStages.LocationReview => StoryIntelligenceResumeRoutes.Locations, StoryIntelligencePipelineStages.LocationImport => StoryIntelligenceResumeRoutes.Locations, StoryIntelligencePipelineStages.CharacterReview => StoryIntelligenceResumeRoutes.Characters, @@ -737,10 +789,13 @@ public sealed class OnboardingStoryIntelligenceBatch public IReadOnlyList Items { get; init; } = []; public List CharacterDecisions { get; } = []; public List LocationDecisions { get; } = []; + public List AssetDecisions { get; } = []; public StoryIntelligenceCharacterImportBatchResult? LastCharacterImportResult { get; set; } public StoryIntelligenceLocationImportBatchResult? LastLocationImportResult { get; set; } + public StoryIntelligenceAssetImportBatchResult? LastAssetImportResult { get; set; } public bool CharacterStageComplete { get; set; } public bool LocationStageComplete { get; set; } + public bool AssetStageComplete { get; set; } } public sealed class OnboardingStoryIntelligenceBatchItem @@ -770,6 +825,15 @@ public sealed class OnboardingStoryIntelligenceLocationDecision public bool CreatedOrLinked { get; init; } } +public sealed class OnboardingStoryIntelligenceAssetDecision +{ + public string Key { get; init; } = string.Empty; + public string Action { get; init; } = string.Empty; + public string? AssetName { get; init; } + public int? StoryAssetID { get; init; } + public bool CreatedOrLinked { get; init; } +} + public sealed class StoryIntelligenceCharacterImportBatchResult { public int CharactersCreated { get; init; } @@ -789,11 +853,22 @@ public sealed class StoryIntelligenceLocationImportBatchResult public int SceneLocationsUpdated { get; init; } } +public sealed class StoryIntelligenceAssetImportBatchResult +{ + public int AssetsCreated { get; init; } + public int AssetsLinked { get; init; } + public int AssetAliasesAdded { get; init; } + public int AssetsIgnored { get; init; } + public int SceneAssetEventsCreated { get; init; } + public int OwnershipLinksCreated { 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 Complete = "Complete"; } diff --git a/PlotLine/Services/StoryIntelligenceAssetImportService.cs b/PlotLine/Services/StoryIntelligenceAssetImportService.cs new file mode 100644 index 0000000..d086b7c --- /dev/null +++ b/PlotLine/Services/StoryIntelligenceAssetImportService.cs @@ -0,0 +1,843 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using PlotLine.Data; +using PlotLine.Models; +using PlotLine.ViewModels; + +namespace PlotLine.Services; + +public interface IStoryIntelligenceAssetImportService +{ + Task BuildReviewAsync(OnboardingStoryIntelligenceBatch batch); + Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceAssetImportForm form); +} + +public sealed class StoryIntelligenceAssetImportService( + IStoryIntelligenceResultRepository runs, + IAssetRepository assets, + ICharacterRepository characters, + ISceneRepository scenes, + IStoryIntelligencePipelineStateService pipelineState, + ILogger logger) : IStoryIntelligenceAssetImportService +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + + private static readonly HashSet GenericObjects = CreateSet( + "chair", "chairs", "table", "tables", "wall", "walls", "floor", "floors", "window", "windows", + "door", "doors", "tree", "trees", "grass", "lamp", "lamps", "cup", "cups", "plate", "plates", + "knife and fork", "people", "person", "building", "car park", "street", "road", "room", "desk", + "bed", "sofa", "sink", "towel", "mug", "glass", "bottle", "phone", "telephone", "key", "keys", + "car", "vehicle", "coat", "dress", "shoes", "shirt", "trousers", "weather", "rain", "food", "drink"); + + private static readonly HashSet AcceptedAssetNames = CreateSet( + "tr6", "passport", "photograph", "photo", "key", "keys", "car keys", "letter", "envelope", "note", + "notebook", "red notebook", "old notebook", "knife", "gun", "suitcase", "rucksack", "wedding ring", + "ring", "necklace", "cassette tape", "camera", "map", "diary", "watch", "driving licence", + "driving license", "licence", "license", "document", "folder", "wallet", "bank book", "scrap of paper"); + + private static readonly HashSet StorySignals = CreateSet( + "lost", "found", "hidden", "stolen", "missing", "taken", "given", "carried", "used", "opened", + "revealed", "evidence", "clue", "important", "sought", "passport", "licence", "license", "letter", + "note", "photograph", "photo", "map", "diary", "notebook", "keys", "necklace", "ring"); + + private static readonly HashSet AdjectivesToFold = CreateSet( + "red", "old", "new", "small", "large", "little", "anonymous", "folded", "sealed", "missing"); + + public async Task BuildReviewAsync(OnboardingStoryIntelligenceBatch batch) + { + var data = await BuildCandidateDataAsync(batch); + var decidedKeys = batch.AssetDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); + var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList(); + return new StoryIntelligenceAssetReviewViewModel + { + HasCommittedScenes = data.HasCommittedScenes, + CanImport = visibleCandidates.Count > 0, + AlreadyLinkedCount = data.AlreadyLinkedCount, + IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0, + Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceAssetReviewCandidateViewModel + { + Key = candidate.Key, + AssetName = candidate.DisplayName, + ImportName = candidate.DisplayName, + Category = candidate.Category, + AppearsInScenes = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count(), + Confidence = ConfidenceBand(candidate.Confidence), + PossibleOwner = candidate.OwnerOrHolder, + ExampleFirstAppearance = candidate.FirstAppearanceNote, + ExampleContext = candidate.Description, + PossibleAliases = candidate.Aliases.OrderBy(value => value).ToList(), + ExistingAssetID = candidate.ExistingAssetID, + ExistingAssetName = candidate.ExistingAssetName + }).ToList() + }; + } + + public async Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceAssetImportForm form) + { + var data = await BuildCandidateDataAsync(batch); + if (!data.HasCommittedScenes) + { + return new StoryIntelligenceImportCommitResult { Success = false, Message = "Create scenes before importing assets." }; + } + + var choices = form.Assets + .Where(choice => !string.IsNullOrWhiteSpace(choice.Key)) + .ToDictionary(choice => choice.Key, choice => choice, StringComparer.OrdinalIgnoreCase); + if (choices.Count == 0) + { + if (data.Candidates.Count == 0) + { + batch.AssetStageComplete = true; + batch.LastAssetImportResult = new StoryIntelligenceAssetImportBatchResult(); + await pipelineState.RecordAssetImportAsync(batch.ProjectID, batch.BookID); + return new StoryIntelligenceImportCommitResult + { + Success = true, + Message = "Asset review completed. No asset decisions were waiting." + }; + } + + return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose at least one asset to create, link or ignore." }; + } + + var existingIndex = await BuildAssetIndexAsync(batch.ProjectID); + var characterIndex = await BuildCharacterIndexAsync(batch.ProjectID); + var lookupData = await assets.GetLookupsAsync(batch.ProjectID); + var created = 0; + var linkedExisting = 0; + var ignored = 0; + var aliasesAdded = 0; + var sceneEventsCreated = 0; + var ownershipLinksCreated = 0; + var resolvedCandidateIds = new Dictionary(StringComparer.OrdinalIgnoreCase); + var candidateByKey = 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, StoryIntelligenceAssetImportActions.Ignore, StringComparison.OrdinalIgnoreCase)) + { + ignored++; + AddDecision(batch, candidate.Key, StoryIntelligenceAssetImportActions.Ignore, candidate.DisplayName, null, false); + continue; + } + + if (string.Equals(choice.Action, StoryIntelligenceAssetImportActions.Alias, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var requestedName = Clean(choice.ImportName); + var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.DisplayName : requestedName; + var forceCreateSeparate = string.Equals(choice.Action, StoryIntelligenceAssetImportActions.CreateSeparate, StringComparison.OrdinalIgnoreCase); + var matchedExistingId = forceCreateSeparate + ? null + : candidate.ExistingAssetID + ?? existingIndex.Find(importName)?.StoryAssetID + ?? existingIndex.Find(candidate.DisplayName)?.StoryAssetID; + var assetId = matchedExistingId; + + if (!assetId.HasValue) + { + assetId = await assets.SaveAssetAsync(new StoryAsset + { + ProjectID = batch.ProjectID, + AssetName = importName, + AssetKindID = MatchAssetKind(lookupData.AssetKinds, candidate), + Description = BuildAssetDescription(candidate), + Importance = 5, + CurrentStateID = MatchAssetState(lookupData.AssetStates, "Known To Exist") + }); + created++; + } + else + { + linkedExisting++; + } + + resolvedCandidateIds[candidate.Key] = assetId.Value; + AddDecision(batch, candidate.Key, choice.Action, importName, assetId.Value, true); + + foreach (var alias in candidate.Aliases) + { + if (await TryAddAliasAsync(assetId.Value, alias, importName)) + { + aliasesAdded++; + } + } + } + + foreach (var candidate in data.Candidates) + { + if (!choices.TryGetValue(candidate.Key, out var choice) + || !string.Equals(choice.Action, StoryIntelligenceAssetImportActions.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, StoryIntelligenceAssetImportActions.Ignore, StringComparison.OrdinalIgnoreCase) + || string.Equals(targetChoice.Action, StoryIntelligenceAssetImportActions.Alias, StringComparison.OrdinalIgnoreCase) + || !candidateByKey.TryGetValue(targetKey, out var targetCandidate) + || !resolvedCandidateIds.TryGetValue(targetKey, out var targetAssetId)) + { + logger.LogWarning( + "Story Intelligence asset alias candidate {CandidateKey} could not be imported because target {TargetKey} was not a resolved create/link target.", + candidate.Key, + targetKey); + continue; + } + + var targetName = targetCandidate.ExistingAssetName ?? targetChoice.ImportName ?? targetCandidate.DisplayName; + if (await TryAddAliasAsync(targetAssetId, candidate.DisplayName, targetName)) + { + aliasesAdded++; + } + + foreach (var alias in candidate.Aliases) + { + if (await TryAddAliasAsync(targetAssetId, alias, targetName)) + { + aliasesAdded++; + } + } + + resolvedCandidateIds[candidate.Key] = targetAssetId; + AddDecision(batch, candidate.Key, StoryIntelligenceAssetImportActions.Alias, candidate.DisplayName, targetAssetId, true); + } + + foreach (var candidate in data.Candidates) + { + if (!choices.TryGetValue(candidate.Key, out var choice) + || string.Equals(choice.Action, StoryIntelligenceAssetImportActions.Ignore, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var assetId = string.Equals(choice.Action, StoryIntelligenceAssetImportActions.Alias, StringComparison.OrdinalIgnoreCase) + ? resolvedCandidateIds.GetValueOrDefault(Clean(choice.AliasTargetKey)) + : resolvedCandidateIds.GetValueOrDefault(candidate.Key); + if (assetId <= 0) + { + continue; + } + + foreach (var appearance in candidate.Appearances + .Where(appearance => !appearance.AlreadyLinked) + .GroupBy(appearance => appearance.SceneID) + .Select(group => group.OrderByDescending(appearance => appearance.Confidence ?? 0m).First())) + { + await assets.SaveEventAsync(new AssetEvent + { + StoryAssetID = assetId, + SceneID = appearance.SceneID, + AssetEventTypeID = MatchAssetEventType(lookupData.AssetEventTypes, appearance.EventType), + ToStateID = MatchAssetState(lookupData.AssetStates, StateFromEventType(appearance.EventType)), + EventTitle = appearance.EventType, + EventDescription = appearance.Notes + }); + sceneEventsCreated++; + + var owner = Clean(appearance.OwnerOrHolder ?? candidate.OwnerOrHolder); + if (!string.IsNullOrWhiteSpace(owner) + && characterIndex.Find(owner) is CharacterMatch ownerMatch + && lookupData.AssetCustodyEventTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Has", StringComparison.OrdinalIgnoreCase)) is { } custodyType + && lookupData.CustodyRoles.FirstOrDefault(role => string.Equals(role.RoleName, "Has Custody", StringComparison.OrdinalIgnoreCase)) is { } role) + { + await assets.SaveCustodyEventAsync( + new AssetCustodyEvent + { + StoryAssetID = assetId, + SceneID = appearance.SceneID, + AssetCustodyEventTypeID = custodyType.AssetCustodyEventTypeID, + Description = $"Initial owner/holder from Story Intelligence: {owner}" + }, + owner, + [ownerMatch.CharacterID], + role.CustodyRoleID); + ownershipLinksCreated++; + } + } + } + + batch.AssetStageComplete = true; + batch.LastAssetImportResult = new StoryIntelligenceAssetImportBatchResult + { + AssetsCreated = created, + AssetsLinked = linkedExisting, + AssetAliasesAdded = aliasesAdded, + AssetsIgnored = ignored, + SceneAssetEventsCreated = sceneEventsCreated, + OwnershipLinksCreated = ownershipLinksCreated + }; + await pipelineState.RecordAssetImportAsync(batch.ProjectID, batch.BookID); + + logger.LogInformation( + "Imported Story Intelligence assets for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliases={Aliases} Ignored={Ignored} SceneEvents={SceneEvents} Ownership={Ownership}", + batch.BatchID, + created, + linkedExisting, + aliasesAdded, + ignored, + sceneEventsCreated, + ownershipLinksCreated); + + return new StoryIntelligenceImportCommitResult + { + Success = true, + ScenesCreated = sceneEventsCreated, + Message = $"Assets imported. {created:N0} created, {linkedExisting:N0} linked, {aliasesAdded:N0} alias/variant(s) added, {ignored:N0} ignored, {sceneEventsCreated:N0} scene asset event(s) created." + }; + } + + private async Task BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch) + { + var existingIndex = await BuildAssetIndexAsync(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 existingEvents = await assets.ListEventsBySceneAsync(importedScene.SceneID); + alreadyLinked += existingEvents.Select(assetEvent => assetEvent.StoryAssetID).Distinct().Count(); + AddSceneAssets(groups, existingIndex, importedScene, parsed, existingEvents); + } + } + + var candidates = groups.Values + .Where(candidate => candidate.Appearances.Any(appearance => !appearance.AlreadyLinked)) + .Where(IsVisibleAssetCandidate) + .OrderByDescending(candidate => candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count()) + .ThenBy(candidate => candidate.DisplayName) + .ToList(); + + return new AssetCandidateData(hasCommittedScenes, alreadyLinked, candidates); + } + + private static void AddSceneAssets( + Dictionary groups, + AssetIndex existingIndex, + Scene importedScene, + SceneIntelligenceScene parsed, + IReadOnlyList existingEvents) + { + foreach (var asset in parsed.Assets ?? []) + { + AddAsset(groups, existingIndex, importedScene, parsed, asset, existingEvents); + } + + foreach (var observation in parsed.Observations ?? []) + { + if (IsAssetEntity(observation.SubjectEntityType)) + { + AddAsset(groups, existingIndex, importedScene, parsed, new SceneIntelligenceAsset + { + Name = observation.SubjectName, + AssetType = observation.ObservationType, + Status = observation.Predicate, + Confidence = observation.Confidence, + Notes = observation.Description + }, existingEvents); + } + + if (IsAssetEntity(observation.ObjectEntityType)) + { + AddAsset(groups, existingIndex, importedScene, parsed, new SceneIntelligenceAsset + { + Name = observation.ObjectName, + AssetType = observation.ObservationType, + Status = observation.Predicate, + MentionedOnly = true, + Confidence = observation.Confidence, + Notes = observation.Description + }, existingEvents); + } + } + } + + private static void AddAsset( + Dictionary groups, + AssetIndex existingIndex, + Scene importedScene, + SceneIntelligenceScene parsed, + SceneIntelligenceAsset asset, + IReadOnlyList existingEvents) + { + var cleanName = CleanAssetName(asset.Name); + if (!IsAssetNameCandidate(cleanName)) + { + return; + } + + var key = ResolveCandidateKey(groups, existingIndex, cleanName); + if (!groups.TryGetValue(key, out var candidate)) + { + var match = existingIndex.Find(cleanName); + candidate = new AssetCandidate(key, DisplayAssetName(cleanName), match?.StoryAssetID, match?.AssetName) + { + Category = Categorise(cleanName, asset.AssetType) + }; + groups[key] = candidate; + } + else if (!string.Equals(candidate.DisplayName, cleanName, StringComparison.OrdinalIgnoreCase)) + { + candidate.Aliases.Add(cleanName); + } + + candidate.Confidence = Max(candidate.Confidence, asset.Confidence); + candidate.Description ??= Clean(asset.Notes); + candidate.OwnerOrHolder ??= Clean(asset.OwnerOrHolder); + candidate.FirstAppearanceNote ??= BuildFirstAppearance(importedScene, parsed, cleanName); + candidate.Appearances.Add(new AssetAppearanceImport( + importedScene.SceneID, + importedScene.SceneNumber, + importedScene.SceneTitle, + EventTypeFromAsset(asset), + asset.OwnerOrHolder, + asset.Notes, + asset.Confidence, + candidate.ExistingAssetID.HasValue && existingEvents.Any(existing => existing.StoryAssetID == candidate.ExistingAssetID.Value))); + } + + 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 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 TryAddAliasAsync(int storyAssetId, string alias, string primaryName) + { + var cleanAlias = Clean(alias); + if (string.IsNullOrWhiteSpace(cleanAlias) || string.Equals(cleanAlias, primaryName, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var existingAliases = await assets.ListAliasesAsync(storyAssetId); + if (existingAliases.Any(item => string.Equals(Clean(item.Alias), cleanAlias, StringComparison.OrdinalIgnoreCase))) + { + return false; + } + + await assets.AddAliasAsync(storyAssetId, cleanAlias, null); + return true; + } + + 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 int MatchAssetKind(IReadOnlyList kinds, AssetCandidate candidate) + => kinds.FirstOrDefault(kind => + candidate.Category.Contains(kind.KindName, StringComparison.OrdinalIgnoreCase) + || kind.KindName.Contains(candidate.Category, StringComparison.OrdinalIgnoreCase))?.AssetKindID + ?? kinds.FirstOrDefault(kind => string.Equals(kind.KindName, "Physical Object", StringComparison.OrdinalIgnoreCase))?.AssetKindID + ?? kinds.First().AssetKindID; + + private static int MatchAssetEventType(IReadOnlyList eventTypes, string eventType) + => eventTypes.FirstOrDefault(type => string.Equals(type.TypeName, eventType, StringComparison.OrdinalIgnoreCase))?.AssetEventTypeID + ?? eventTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Mentioned", StringComparison.OrdinalIgnoreCase))?.AssetEventTypeID + ?? eventTypes.First().AssetEventTypeID; + + private static int? MatchAssetState(IReadOnlyList states, string? stateName) + => string.IsNullOrWhiteSpace(stateName) + ? null + : states.FirstOrDefault(state => string.Equals(state.StateName, stateName, StringComparison.OrdinalIgnoreCase))?.AssetStateID; + + private static string StateFromEventType(string eventType) + => eventType switch + { + "First Mentioned" => "Known To Exist", + "Mentioned" => "Mentioned", + "Found" => "Found", + "Given" => "Possessed", + "Taken" => "Possessed", + "Lost" => "Lost", + "Hidden" => "Hidden", + "Used" => "Used", + "Opened" => "Opened", + "Revealed" => "Revealed", + "Investigated" => "Investigated", + _ => "Known To Exist" + }; + + private static string BuildAssetDescription(AssetCandidate candidate) + { + var lines = new List(); + if (!string.IsNullOrWhiteSpace(candidate.OwnerOrHolder)) + { + lines.Add($"Possible owner/holder from Story Intelligence: {candidate.OwnerOrHolder}"); + } + + if (!string.IsNullOrWhiteSpace(candidate.Description)) + { + lines.Add(candidate.Description); + } + + return string.Join(Environment.NewLine, lines); + } + + private static bool IsVisibleAssetCandidate(AssetCandidate candidate) + { + if (IsAcceptedStoryAssetName(candidate.DisplayName)) + { + return true; + } + + var sceneCount = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count(); + var signalText = $"{candidate.DisplayName} {candidate.Category} {candidate.Description} {candidate.OwnerOrHolder} {string.Join(' ', candidate.Appearances.Select(a => a.Notes))}"; + return sceneCount >= 2 && ContainsStoryAssetSignal(signalText); + } + + private static bool IsAssetNameCandidate(string? name) + { + var clean = CleanAssetName(name); + if (string.IsNullOrWhiteSpace(clean) || clean.Length < 2 || !clean.Any(char.IsLetter)) + { + return false; + } + + if (IsAcceptedStoryAssetName(clean)) + { + return true; + } + + var normalised = Normalise(clean); + return !GenericObjects.Contains(normalised) && !GenericObjects.Contains(RemoveLeadingArticle(normalised)); + } + + private static bool IsAcceptedStoryAssetName(string? name) + { + var normalised = Normalise(CleanAssetName(name)); + var withoutArticle = RemoveLeadingArticle(normalised); + return AcceptedAssetNames.Contains(normalised) + || AcceptedAssetNames.Contains(withoutArticle) + || normalised.Contains("tr6", StringComparison.OrdinalIgnoreCase) + || normalised.Contains("'s car", StringComparison.OrdinalIgnoreCase) + || normalised.Contains("’s car", StringComparison.OrdinalIgnoreCase); + } + + private static bool ContainsStoryAssetSignal(string value) + => StorySignals.Any(signal => value.Contains(signal, StringComparison.OrdinalIgnoreCase)); + + private static string EventTypeFromAsset(SceneIntelligenceAsset asset) + { + var value = $"{asset.Status} {asset.Notes}".ToLowerInvariant(); + if (value.Contains("found")) return "Found"; + if (value.Contains("lost")) return "Lost"; + if (value.Contains("given") || value.Contains("gives")) return "Given"; + if (value.Contains("taken") || value.Contains("takes")) return "Taken"; + if (value.Contains("stolen")) return "Stolen"; + if (value.Contains("hidden") || value.Contains("hides")) return "Hidden"; + if (value.Contains("opened")) return "Opened"; + if (value.Contains("used") || value.Contains("uses")) return "Used"; + if (value.Contains("revealed") || value.Contains("reveals")) return "Revealed"; + if (value.Contains("investigated")) return "Investigated"; + return asset.MentionedOnly == true ? "Mentioned" : "First Mentioned"; + } + + private static string Categorise(string name, string? assetType) + { + var value = $"{name} {assetType}".ToLowerInvariant(); + if (value.Contains("passport") || value.Contains("licence") || value.Contains("license") || value.Contains("letter") || value.Contains("note") || value.Contains("map") || value.Contains("diary") || value.Contains("notebook") || value.Contains("document")) + { + return "Document"; + } + + if (value.Contains("photo") || value.Contains("evidence") || value.Contains("clue")) + { + return "Evidence"; + } + + if (value.Contains("car") || value.Contains("tr6") || value.Contains("vehicle")) + { + return "Vehicle"; + } + + if (value.Contains("necklace") || value.Contains("ring") || value.Contains("watch")) + { + return "Physical Object"; + } + + return string.IsNullOrWhiteSpace(assetType) ? "Physical Object" : Clean(assetType); + } + + private static string ResolveCandidateKey( + IReadOnlyDictionary groups, + AssetIndex existingIndex, + string name) + { + var existingMatch = existingIndex.Find(name); + if (existingMatch is not null) + { + return CanonicalAssetKey(existingMatch.AssetName); + } + + var canonical = CanonicalAssetKey(name); + return groups.ContainsKey(canonical) ? canonical : canonical; + } + + private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed, string assetName) + { + var summary = Clean(parsed.Summary?.Short); + return string.IsNullOrWhiteSpace(summary) + ? $"Scene {scene.SceneNumber:g}: {assetName}" + : $"Scene {scene.SceneNumber:g}: {summary}"; + } + + private static void AddDecision(OnboardingStoryIntelligenceBatch batch, string key, string action, string? assetName, int? storyAssetId, bool createdOrLinked) + { + batch.AssetDecisions.RemoveAll(decision => string.Equals(decision.Key, key, StringComparison.OrdinalIgnoreCase)); + batch.AssetDecisions.Add(new OnboardingStoryIntelligenceAssetDecision + { + Key = key, + Action = action, + AssetName = assetName, + StoryAssetID = storyAssetId, + CreatedOrLinked = createdOrLinked + }); + } + + private static bool IsAssetEntity(string? entityType) + => string.Equals(Clean(entityType), "Asset", StringComparison.OrdinalIgnoreCase) + || string.Equals(Clean(entityType), "Object", StringComparison.OrdinalIgnoreCase); + + 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 DisplayAssetName(string name) + { + var clean = CleanAssetName(name); + return string.IsNullOrWhiteSpace(clean) ? clean : char.ToUpperInvariant(clean[0]) + clean[1..]; + } + + private static string CanonicalAssetKey(string name) + { + var clean = RemoveLeadingArticle(Normalise(CleanAssetName(name))); + var words = clean.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList(); + while (words.Count > 1 && AdjectivesToFold.Contains(words[0])) + { + words.RemoveAt(0); + } + + if (words.Count == 2 && words[1].Equals("keys", StringComparison.OrdinalIgnoreCase)) + { + return "keys"; + } + + if (clean is "photo") + { + return "photograph"; + } + + if (clean is "driving license") + { + return "driving licence"; + } + + return string.Join(' ', words); + } + + private static string CleanAssetName(string? value) + => Clean(value).Trim(' ', '.', ',', ';', ':', '!', '?', '"', '\''); + + private static string RemoveLeadingArticle(string value) + => value.StartsWith("the ", StringComparison.OrdinalIgnoreCase) + ? value[4..] + : value.StartsWith("a ", StringComparison.OrdinalIgnoreCase) + ? value[2..] + : value.StartsWith("an ", StringComparison.OrdinalIgnoreCase) + ? value[3..] + : value; + + private static string Normalise(string? value) + => Clean(value).ToLowerInvariant(); + + private static string Clean(string? value) + => string.Join(' ', (value ?? string.Empty).Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + + private static HashSet CreateSet(params string[] values) + => values.Select(Normalise).ToHashSet(StringComparer.OrdinalIgnoreCase); + + private sealed class AssetIndex + { + private readonly Dictionary byName = new(StringComparer.OrdinalIgnoreCase); + + public void Add(string? name, int storyAssetId, string assetName) + { + var key = CanonicalAssetKey(name ?? string.Empty); + if (!string.IsNullOrWhiteSpace(key)) + { + byName.TryAdd(key, new AssetMatch(storyAssetId, assetName)); + } + } + + public AssetMatch? Find(string name) + => byName.TryGetValue(CanonicalAssetKey(name), out var match) ? match : null; + } + + private sealed class CharacterIndex + { + private readonly Dictionary byName = new(StringComparer.OrdinalIgnoreCase); + + public void Add(string? name, int characterId, string characterName) + { + var key = Normalise(name); + if (!string.IsNullOrWhiteSpace(key)) + { + byName.TryAdd(key, new CharacterMatch(characterId, characterName)); + } + } + + public CharacterMatch? Find(string name) + => byName.TryGetValue(Normalise(name), out var match) ? match : null; + } + + private sealed record AssetMatch(int StoryAssetID, string AssetName); + private sealed record CharacterMatch(int CharacterID, string CharacterName); + + private sealed class AssetCandidate(string key, string displayName, int? existingAssetId, string? existingAssetName) + { + public string Key { get; } = key; + public string DisplayName { get; } = displayName; + public int? ExistingAssetID { get; } = existingAssetId; + public string? ExistingAssetName { get; } = existingAssetName; + public string Category { get; set; } = "Physical Object"; + public string? OwnerOrHolder { get; set; } + public HashSet Aliases { get; } = new(StringComparer.OrdinalIgnoreCase); + public List Appearances { get; } = []; + public decimal? Confidence { get; set; } + public string? Description { get; set; } + public string? FirstAppearanceNote { get; set; } + } + + private sealed record AssetAppearanceImport( + int SceneID, + decimal SceneNumber, + string SceneTitle, + string EventType, + string? OwnerOrHolder, + string? Notes, + decimal? Confidence, + bool AlreadyLinked); + + private sealed record AssetCandidateData( + bool HasCommittedScenes, + int AlreadyLinkedCount, + IReadOnlyList Candidates); +} diff --git a/PlotLine/Services/StoryIntelligenceLocationImportService.cs b/PlotLine/Services/StoryIntelligenceLocationImportService.cs index 1ee34dc..d5777a0 100644 --- a/PlotLine/Services/StoryIntelligenceLocationImportService.cs +++ b/PlotLine/Services/StoryIntelligenceLocationImportService.cs @@ -47,11 +47,11 @@ public sealed class StoryIntelligenceLocationImportService( private static readonly HashSet StoryLocationSignals = CreateSet( "trust", "church", "school", "university", "police", "headquarters", "hq", "house", "flat", "pub", - "cafe", "shop", "factory", "centre", "center", "hospital", "station", "hotel", "estate"); + "cafe", "shop", "factory", "centre", "center", "hospital", "station", "hotel", "estate", "doweries"); private static readonly HashSet ConjunctionLocationWords = CreateSet( "shop", "staff room", "house", "garden", "road", "bridge", "kitchen", "hallway", "front garden", - "rear garden", "room", "office", "garage", "bathroom", "bedroom", "corridor", "street", "car park"); + "rear garden", "front", "rear", "room", "office", "garage", "bathroom", "bedroom", "corridor", "street", "car park"); private static readonly Regex AddressPattern = new(@"^\d+\s+\p{L}", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex NamedRoadPattern = new(@"\b(street|road|lane|avenue|drive|way|close|crescent|square|place)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); diff --git a/PlotLine/Services/StoryIntelligencePipelineStateService.cs b/PlotLine/Services/StoryIntelligencePipelineStateService.cs index 5aae11f..84b8b17 100644 --- a/PlotLine/Services/StoryIntelligencePipelineStateService.cs +++ b/PlotLine/Services/StoryIntelligencePipelineStateService.cs @@ -11,6 +11,7 @@ public interface IStoryIntelligencePipelineStateService Task RecordSceneImportAsync(int projectId, int bookId, int runId); Task RecordCharacterImportAsync(int projectId, int bookId); Task RecordLocationImportAsync(int projectId, int bookId); + Task RecordAssetImportAsync(int projectId, int bookId); Task> ListCommittedRunsByBookAsync(int bookId, int userId); } @@ -96,8 +97,23 @@ public sealed class StoryIntelligencePipelineStateService( { ProjectID = projectId, BookID = bookId, - CurrentStage = StoryIntelligencePipelineStages.Complete, + CurrentStage = StoryIntelligencePipelineStages.AssetReview, LastCompletedStage = StoryIntelligencePipelineStages.LocationImport, + CurrentReviewStage = StoryIntelligencePipelineStages.AssetReview, + Status = StoryIntelligencePipelineStatuses.NeedsReview, + CompletedUtc = null, + LastRunID = null + }); + } + + public async Task RecordAssetImportAsync(int projectId, int bookId) + { + await pipelines.UpsertAsync(new StoryIntelligenceBookPipelineSaveRequest + { + ProjectID = projectId, + BookID = bookId, + CurrentStage = StoryIntelligencePipelineStages.Complete, + LastCompletedStage = StoryIntelligencePipelineStages.AssetImport, CurrentReviewStage = null, Status = StoryIntelligencePipelineStatuses.Complete, CompletedUtc = DateTime.UtcNow, diff --git a/PlotLine/Sql/130_Phase20AP_AssetReviewPipelineStages.sql b/PlotLine/Sql/130_Phase20AP_AssetReviewPipelineStages.sql new file mode 100644 index 0000000..064765d --- /dev/null +++ b/PlotLine/Sql/130_Phase20AP_AssetReviewPipelineStages.sql @@ -0,0 +1,36 @@ +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'Complete' + )); +END; +GO diff --git a/PlotLine/ViewModels/OnboardingViewModels.cs b/PlotLine/ViewModels/OnboardingViewModels.cs index b4a2951..6d9b037 100644 --- a/PlotLine/ViewModels/OnboardingViewModels.cs +++ b/PlotLine/ViewModels/OnboardingViewModels.cs @@ -144,6 +144,7 @@ public sealed class StoryIntelligenceProgressViewModel public IReadOnlyList Chapters { get; init; } = []; public StoryIntelligenceCharacterReviewViewModel CharacterReview { get; init; } = new(); public StoryIntelligenceLocationReviewViewModel LocationReview { get; init; } = new(); + public StoryIntelligenceAssetReviewViewModel AssetReview { get; init; } = new(); public StoryIntelligencePipelineDashboardViewModel PipelineDashboard { get; init; } = new(); public int ChapterCount => Chapters.Count; public int CompletedChapterCount => Chapters.Count(chapter => chapter.IsRunComplete); @@ -160,6 +161,7 @@ public sealed class StoryIntelligenceProgressViewModel public bool HasReadyScenesToCreate => Chapters.Any(chapter => chapter.CanCommit); public bool HasCharactersToReview => CharacterReview.Candidates.Count > 0; public bool HasLocationsToReview => LocationReview.Candidates.Count > 0; + public bool HasAssetsToReview => AssetReview.Candidates.Count > 0; } public sealed class StoryIntelligencePipelineDashboardViewModel @@ -172,6 +174,9 @@ public sealed class StoryIntelligencePipelineDashboardViewModel public int LocationsIdentified { get; init; } public int LocationsCreatedOrLinked { get; init; } public bool LocationStageComplete { get; init; } + public int AssetsIdentified { get; init; } + public int AssetsCreatedOrLinked { get; init; } + public bool AssetStageComplete { get; init; } } public sealed class StoryIntelligenceCharacterReviewViewModel @@ -268,6 +273,55 @@ public static class StoryIntelligenceLocationImportActions public const string Alias = "Alias"; } +public sealed class StoryIntelligenceAssetReviewViewModel +{ + 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 StoryIntelligenceAssetReviewCandidateViewModel +{ + public string Key { get; init; } = string.Empty; + public string AssetName { get; init; } = string.Empty; + public string ImportName { get; init; } = string.Empty; + public string Category { get; init; } = "Story asset"; + public int AppearsInScenes { get; init; } + public string Confidence { get; init; } = "Unknown"; + public string? PossibleOwner { get; init; } + public string? ExampleFirstAppearance { get; init; } + public string? ExampleContext { get; init; } + public IReadOnlyList PossibleAliases { get; init; } = []; + public int? ExistingAssetID { get; init; } + public string? ExistingAssetName { get; init; } + public bool IsExistingMatch => ExistingAssetID.HasValue; + public string ActionLabel => IsExistingMatch ? "Link existing" : "Create"; +} + +public sealed class StoryIntelligenceAssetImportForm +{ + public Guid BatchID { get; set; } + public List Assets { get; set; } = []; +} + +public sealed class StoryIntelligenceAssetImportChoiceForm +{ + public string Key { get; set; } = string.Empty; + public string Action { get; set; } = StoryIntelligenceAssetImportActions.Approve; + public string? ImportName { get; set; } + public string? AliasTargetKey { get; set; } +} + +public static class StoryIntelligenceAssetImportActions +{ + public const string Approve = "Approve"; + public const string Ignore = "Ignore"; + public const string CreateSeparate = "CreateSeparate"; + public const string Alias = "Alias"; +} + public sealed class StoryIntelligenceCompletionViewModel { public StoryIntelligenceJobProgress Job { get; init; } = new(); @@ -283,6 +337,8 @@ public sealed class StoryIntelligenceCompletionViewModel public bool CharacterStageComplete { get; init; } public int LocationsCreatedOrLinked { get; init; } public bool LocationStageComplete { get; init; } + public int AssetsCreatedOrLinked { get; init; } + public bool AssetStageComplete { get; init; } } public sealed class StoryIntelligenceCharacterImportResultViewModel @@ -310,6 +366,19 @@ public sealed class StoryIntelligenceLocationImportResultViewModel public int SceneLocationsUpdated { get; init; } } +public sealed class StoryIntelligenceAssetImportResultViewModel +{ + public Guid BatchID { get; init; } + public int ProjectID { get; init; } + public int BookID { get; init; } + public int AssetsCreated { get; init; } + public int AssetsLinked { get; init; } + public int AssetAliasesAdded { get; init; } + public int AssetsIgnored { get; init; } + public int SceneAssetEventsCreated { get; init; } + public int OwnershipLinksCreated { get; init; } +} + public sealed class StoryIntelligenceOnboardingChapterViewModel { public string TemporaryChapterKey { get; init; } = string.Empty; diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceAssetComplete.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceAssetComplete.cshtml new file mode 100644 index 0000000..0a4eb12 --- /dev/null +++ b/PlotLine/Views/Onboarding/StoryIntelligenceAssetComplete.cshtml @@ -0,0 +1,57 @@ +@model StoryIntelligenceAssetImportResultViewModel +@{ + ViewData["Title"] = "Assets created"; +} + +
+
+ +
+

Build Story Database

+
+ +

Assets created

+
+

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

+
+ +
+
+ Assets created + @Model.AssetsCreated.ToString("N0") +
+
+ Assets linked + @Model.AssetsLinked.ToString("N0") +
+
+ Aliases added + @Model.AssetAliasesAdded.ToString("N0") +
+
+ Assets ignored + @Model.AssetsIgnored.ToString("N0") +
+
+ Scene World panels updated + @Model.SceneAssetEventsCreated.ToString("N0") +
+
+ Ownership links + @Model.OwnershipLinksCreated.ToString("N0") +
+
+ +
+
Review RelationshipsComing Soon
+
Review KnowledgeComing Soon
+
Review ContinuityComing Soon
+
Timeline EventsComing Soon
+
+ + +
+
diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceAssets.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceAssets.cshtml new file mode 100644 index 0000000..cb7232a --- /dev/null +++ b/PlotLine/Views/Onboarding/StoryIntelligenceAssets.cshtml @@ -0,0 +1,236 @@ +@model StoryIntelligenceProgressViewModel +@{ + ViewData["Title"] = "Review assets"; +} + +
+
+ +
+

Review Story Intelligence

+

Review Assets

+

Approve the important objects 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.AssetReview.Candidates.Count == 0) + { +
+ No asset decisions are waiting. +

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

+
+
+ @FutureStage("Review Relationships") + @FutureStage("Review Knowledge") + @FutureStage("Review Continuity") + @FutureStage("Timeline Events") +
+
+ Back to locations +
+ + +
+
+ } + else + { +
+ + + + + +
+ +
+ +
+ @for (var i = 0; i < Model.AssetReview.Candidates.Count; i++) + { + var candidate = Model.AssetReview.Candidates[i]; +
+ + + + @candidate.AssetName + @candidate.Category · @candidate.AppearsInScenes.ToString("N0") scene@(candidate.AppearsInScenes == 1 ? string.Empty : "s") + + @candidate.Confidence + + + + +
+
+
First appearance
@Display(candidate.ExampleFirstAppearance)
+
Possible owner
@Display(candidate.PossibleOwner)
+
Existing match
@(candidate.ExistingAssetName ?? "None")
+
+ + @if (candidate.PossibleAliases.Any()) + { +
+ Possible aliases +

@string.Join(", ", candidate.PossibleAliases)

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

@candidate.ExampleContext

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

@candidate.AssetName may already be @candidate.ExistingAssetName. Choose whether to link them or create a separate asset.

+
+ } + +
+ Decision + + @if (candidate.IsExistingMatch) + { + + } + + +
+ +
+ + +
+ + +
+
+ } +
+ +
+ Back to locations + +
+
+ } +
+
+
+ +@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/StoryIntelligenceComplete.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceComplete.cshtml index 5c2b4af..06f7491 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceComplete.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceComplete.cshtml @@ -28,6 +28,14 @@ Characters created / linked @Model.CharactersCreatedOrLinked.ToString("N0") +
+ Locations created / linked + @Model.LocationsCreatedOrLinked.ToString("N0") +
+
+ Assets created / linked + @Model.AssetsCreatedOrLinked.ToString("N0") +
Book @Model.BookTitle @@ -35,10 +43,10 @@
-
Review LocationsComing Soon
-
Review AssetsComing Soon
Review RelationshipsComing Soon
Review KnowledgeComing Soon
+
Review ContinuityComing Soon
+
Timeline EventsComing Soon
diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceLocationComplete.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceLocationComplete.cshtml index 1b94ea1..532ba8f 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceLocationComplete.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceLocationComplete.cshtml @@ -39,7 +39,7 @@
-
Review AssetsComing Soon
+
Review AssetsNext
Review RelationshipsComing Soon
Review KnowledgeComing Soon
Review ContinuityComing Soon
@@ -47,7 +47,7 @@
diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceLocations.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceLocations.cshtml index 8fdeceb..27db8d7 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceLocations.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceLocations.cshtml @@ -31,14 +31,14 @@

Locations are already up to date, or there were no location suggestions ready to import.

- @FutureStage("Review Assets") +
Review AssetsNext
@FutureStage("Review Relationships") @FutureStage("Review Knowledge") @FutureStage("Review Continuity")
} else diff --git a/PlotLine/Views/Onboarding/_StoryIntelligencePipelineHeader.cshtml b/PlotLine/Views/Onboarding/_StoryIntelligencePipelineHeader.cshtml index b90ac0e..0d6493c 100644 --- a/PlotLine/Views/Onboarding/_StoryIntelligencePipelineHeader.cshtml +++ b/PlotLine/Views/Onboarding/_StoryIntelligencePipelineHeader.cshtml @@ -5,7 +5,7 @@ new { Name = "Phase 1", Label = "Prepare manuscript", Stages = new[] { "Connect Word Companion", "Scan Manuscript", "Review Chapters" } }, new { Name = "Phase 2", Label = "Analyse manuscript", Stages = new[] { "Analyse Manuscript" } }, new { Name = "Phase 3", Label = "Review Story Intelligence", Stages = new[] { "Review Scenes", "Review Characters", "Review Locations", "Review Assets", "Review Relationships", "Review Knowledge" } }, - new { Name = "Phase 4", Label = "Build Story Database", Stages = new[] { "Create Scenes", "Create Characters", "Complete" } } + new { Name = "Phase 4", Label = "Build Story Database", Stages = new[] { "Create Scenes", "Create Characters", "Create Locations", "Create Assets", "Complete" } } }; var flatStages = phases.SelectMany(phase => phase.Stages).ToList(); var currentIndex = Math.Max(0, flatStages.FindIndex(stage => string.Equals(stage, Model.CurrentStage, StringComparison.OrdinalIgnoreCase))); diff --git a/PlotLine/Views/Onboarding/_StoryIntelligencePipelineSummary.cshtml b/PlotLine/Views/Onboarding/_StoryIntelligencePipelineSummary.cshtml index 40b7cf9..f05cb33 100644 --- a/PlotLine/Views/Onboarding/_StoryIntelligencePipelineSummary.cshtml +++ b/PlotLine/Views/Onboarding/_StoryIntelligencePipelineSummary.cshtml @@ -26,8 +26,12 @@ @(Model.LocationStageComplete ? $"Done: {Model.LocationsCreatedOrLinked:N0}" : "In review")
- Assets - Coming next + Assets identified + @Model.AssetsIdentified.ToString("N0") +
+
+ Assets created / linked + @(Model.AssetStageComplete ? $"Done: {Model.AssetsCreatedOrLinked:N0}" : "In review")
Relationships