From ef51765eb474a5c94b4c6f08340bdedd1a6208ab Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Thu, 9 Jul 2026 20:13:06 +0100 Subject: [PATCH] =?UTF-8?q?Phase=2020AN=20=E2=80=93=20Location=20Intellige?= =?UTF-8?q?nce=20Review=20and=20Import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PlotLine/Controllers/OnboardingController.cs | 53 ++ .../StoryIntelligencePersistenceModels.cs | 2 + PlotLine/Program.cs | 1 + .../OnboardingStoryIntelligenceService.cs | 76 +- .../StoryIntelligenceLocationImportService.cs | 696 ++++++++++++++++++ .../StoryIntelligencePipelineStateService.cs | 18 +- ...Phase20AN_LocationReviewPipelineStages.sql | 34 + PlotLine/ViewModels/OnboardingViewModels.cs | 67 ++ .../StoryIntelligenceCharacterComplete.cshtml | 4 +- .../_StoryIntelligencePipelineSummary.cshtml | 8 +- 10 files changed, 952 insertions(+), 7 deletions(-) create mode 100644 PlotLine/Services/StoryIntelligenceLocationImportService.cs create mode 100644 PlotLine/Sql/129_Phase20AN_LocationReviewPipelineStages.sql diff --git a/PlotLine/Controllers/OnboardingController.cs b/PlotLine/Controllers/OnboardingController.cs index e4df73b..9b96053 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.Locations => RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = target.BatchID }), StoryIntelligenceResumeRoutes.Characters => RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId = target.BatchID }), _ => RedirectToAction(nameof(StoryIntelligenceReview), new { batchId = target.BatchID }) }; @@ -156,6 +157,30 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard : View(model); } + [HttpGet("story-intelligence/locations")] + public async Task StoryIntelligenceLocations(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 }); + } + + return !model.PipelineDashboard.CharacterStageComplete + ? RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId }) + : View(model); + } + [HttpPost("story-intelligence/cancel")] [ValidateAntiForgeryToken] public async Task CancelStoryIntelligence(Guid batchId) @@ -229,6 +254,29 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard return model is null ? NotFound() : View(model); } + [HttpPost("story-intelligence/locations")] + [ValidateAntiForgeryToken] + public async Task ImportStoryIntelligenceLocations(StoryIntelligenceLocationImportForm form) + { + var (progress, result) = await storyIntelligence.ImportLocationsAsync(form.BatchID, form); + if (progress is null) + { + return NotFound(); + } + + TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; + return result.Success + ? RedirectToAction(nameof(StoryIntelligenceLocationComplete), new { batchId = form.BatchID }) + : RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = form.BatchID }); + } + + [HttpGet("story-intelligence/locations/complete")] + public async Task StoryIntelligenceLocationComplete(Guid batchId) + { + var model = await storyIntelligence.GetLocationImportResultAsync(batchId); + return model is null ? NotFound() : View(model); + } + [HttpGet("story-intelligence/complete")] public async Task StoryIntelligenceComplete(Guid batchId) { @@ -243,6 +291,11 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId }); } + if (!progress.PipelineDashboard.LocationStageComplete) + { + return RedirectToAction(nameof(StoryIntelligenceLocations), 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 1cbf31d..a8731cb 100644 --- a/PlotLine/Models/StoryIntelligencePersistenceModels.cs +++ b/PlotLine/Models/StoryIntelligencePersistenceModels.cs @@ -28,6 +28,8 @@ public static class StoryIntelligencePipelineStages public const string SceneImport = "SceneImport"; public const string CharacterReview = "CharacterReview"; public const string CharacterImport = "CharacterImport"; + public const string LocationReview = "LocationReview"; + public const string LocationImport = "LocationImport"; public const string Complete = "Complete"; } diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index f5eef2b..d100176 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -208,6 +208,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 fe3f55b..563f996 100644 --- a/PlotLine/Services/OnboardingStoryIntelligenceService.cs +++ b/PlotLine/Services/OnboardingStoryIntelligenceService.cs @@ -16,6 +16,8 @@ public interface IOnboardingStoryIntelligenceService Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAllAsync(Guid batchId); Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportCharactersAsync(Guid batchId, StoryIntelligenceCharacterImportForm form); Task GetCharacterImportResultAsync(Guid batchId); + Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportLocationsAsync(Guid batchId, StoryIntelligenceLocationImportForm form); + Task GetLocationImportResultAsync(Guid batchId); Task GetCompletionAsync(Guid batchId); Task ResumeBookAsync(int bookId); } @@ -27,6 +29,7 @@ public sealed class OnboardingStoryIntelligenceService( IStoryIntelligenceResultRepository runs, IStoryIntelligenceImportCommitService commits, IStoryIntelligenceCharacterImportService characterImport, + IStoryIntelligenceLocationImportService locationImport, IStoryIntelligencePipelineStateService pipelineState, IStoryIntelligenceClient client, IOnboardingStoryIntelligenceBatchStore batchStore, @@ -259,6 +262,7 @@ public sealed class OnboardingStoryIntelligenceService( } var characterReview = await characterImport.BuildReviewAsync(batch); + var locationReview = await locationImport.BuildReviewAsync(batch); return new StoryIntelligenceProgressViewModel { BatchID = batch.BatchID, @@ -269,13 +273,17 @@ public sealed class OnboardingStoryIntelligenceService( BookTitle = batch.BookTitle, Chapters = chapters, CharacterReview = characterReview, + LocationReview = locationReview, PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel { ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete), ScenesImported = chapters.Sum(chapter => chapter.ScenesCreated), CharactersIdentified = characterReview.Candidates.Count + batch.CharacterDecisions.Count, CharactersCreatedOrLinked = batch.CharacterDecisions.Count(decision => decision.CreatedOrLinked), - CharacterStageComplete = batch.CharacterStageComplete || characterReview.IsComplete + CharacterStageComplete = batch.CharacterStageComplete || characterReview.IsComplete, + LocationsIdentified = locationReview.Candidates.Count + batch.LocationDecisions.Count, + LocationsCreatedOrLinked = batch.LocationDecisions.Count(decision => decision.CreatedOrLinked), + LocationStageComplete = batch.LocationStageComplete || locationReview.IsComplete } }; } @@ -377,6 +385,39 @@ public sealed class OnboardingStoryIntelligenceService( }; } + public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportLocationsAsync(Guid batchId, StoryIntelligenceLocationImportForm 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 locationImport.ImportAsync(batch, form); + return (await GetProgressAsync(batchId), result); + } + + public async Task GetLocationImportResultAsync(Guid batchId) + { + var batch = await batchStore.GetAsync(RequireUserId(), batchId); + if (batch is null) + { + return null; + } + + return new StoryIntelligenceLocationImportResultViewModel + { + BatchID = batch.BatchID, + ProjectID = batch.ProjectID, + BookID = batch.BookID, + LocationsCreated = batch.LastLocationImportResult?.LocationsCreated ?? 0, + LocationsLinked = batch.LastLocationImportResult?.LocationsLinked ?? 0, + LocationAliasesAdded = batch.LastLocationImportResult?.LocationAliasesAdded ?? 0, + LocationsIgnored = batch.LastLocationImportResult?.LocationsIgnored ?? 0, + SceneLocationsUpdated = batch.LastLocationImportResult?.SceneLocationsUpdated ?? 0 + }; + } + public async Task GetCompletionAsync(Guid batchId) { var progress = await GetProgressAsync(batchId); @@ -396,7 +437,9 @@ public sealed class OnboardingStoryIntelligenceService( ChaptersCommitted = progress.CommittedChapterCount, ScenesCreated = progress.Chapters.Sum(chapter => chapter.ScenesCreated), CharactersCreatedOrLinked = progress.PipelineDashboard.CharactersCreatedOrLinked, - CharacterStageComplete = progress.PipelineDashboard.CharacterStageComplete + CharacterStageComplete = progress.PipelineDashboard.CharacterStageComplete, + LocationsCreatedOrLinked = progress.PipelineDashboard.LocationsCreatedOrLinked, + LocationStageComplete = progress.PipelineDashboard.LocationStageComplete }; } @@ -439,6 +482,11 @@ public sealed class OnboardingStoryIntelligenceService( }; if (state.IsComplete) + { + batch.CharacterStageComplete = true; + batch.LocationStageComplete = true; + } + else if (state.CurrentStage is StoryIntelligencePipelineStages.LocationReview or StoryIntelligencePipelineStages.LocationImport) { batch.CharacterStageComplete = true; } @@ -448,6 +496,8 @@ public sealed class OnboardingStoryIntelligenceService( var route = state.CurrentStage switch { StoryIntelligencePipelineStages.Complete => StoryIntelligenceResumeRoutes.Complete, + StoryIntelligencePipelineStages.LocationReview => StoryIntelligenceResumeRoutes.Locations, + StoryIntelligencePipelineStages.LocationImport => StoryIntelligenceResumeRoutes.Locations, StoryIntelligencePipelineStages.CharacterReview => StoryIntelligenceResumeRoutes.Characters, StoryIntelligencePipelineStages.CharacterImport => StoryIntelligenceResumeRoutes.Characters, StoryIntelligencePipelineStages.SceneReview => StoryIntelligenceResumeRoutes.SceneReview, @@ -686,8 +736,11 @@ public sealed class OnboardingStoryIntelligenceBatch public DateTime CreatedUtc { get; init; } = DateTime.UtcNow; public IReadOnlyList Items { get; init; } = []; public List CharacterDecisions { get; } = []; + public List LocationDecisions { get; } = []; public StoryIntelligenceCharacterImportBatchResult? LastCharacterImportResult { get; set; } + public StoryIntelligenceLocationImportBatchResult? LastLocationImportResult { get; set; } public bool CharacterStageComplete { get; set; } + public bool LocationStageComplete { get; set; } } public sealed class OnboardingStoryIntelligenceBatchItem @@ -708,6 +761,15 @@ public sealed class OnboardingStoryIntelligenceCharacterDecision public bool CreatedOrLinked { get; init; } } +public sealed class OnboardingStoryIntelligenceLocationDecision +{ + public string Key { get; init; } = string.Empty; + public string Action { get; init; } = string.Empty; + public string? LocationName { get; init; } + public int? LocationID { get; init; } + public bool CreatedOrLinked { get; init; } +} + public sealed class StoryIntelligenceCharacterImportBatchResult { public int CharactersCreated { get; init; } @@ -718,10 +780,20 @@ public sealed class StoryIntelligenceCharacterImportBatchResult public int ScenePeoplePanelsUpdated { get; init; } } +public sealed class StoryIntelligenceLocationImportBatchResult +{ + public int LocationsCreated { get; init; } + public int LocationsLinked { get; init; } + public int LocationAliasesAdded { get; init; } + public int LocationsIgnored { get; init; } + public int SceneLocationsUpdated { get; init; } +} + public static class StoryIntelligenceResumeRoutes { public const string SceneReview = "SceneReview"; public const string Characters = "Characters"; + public const string Locations = "Locations"; public const string Complete = "Complete"; } diff --git a/PlotLine/Services/StoryIntelligenceLocationImportService.cs b/PlotLine/Services/StoryIntelligenceLocationImportService.cs new file mode 100644 index 0000000..c5a7ec1 --- /dev/null +++ b/PlotLine/Services/StoryIntelligenceLocationImportService.cs @@ -0,0 +1,696 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using PlotLine.Data; +using PlotLine.Models; +using PlotLine.ViewModels; + +namespace PlotLine.Services; + +public interface IStoryIntelligenceLocationImportService +{ + Task BuildReviewAsync(OnboardingStoryIntelligenceBatch batch); + Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceLocationImportForm form); +} + +public sealed class StoryIntelligenceLocationImportService( + IStoryIntelligenceResultRepository runs, + ILocationRepository locations, + ISceneRepository scenes, + IStoryIntelligencePipelineStateService pipelineState, + ILogger logger) : IStoryIntelligenceLocationImportService +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + + private static readonly HashSet GenericLocations = CreateSet( + "room", "place", "area", "building", "street", "road", "door", "doorway", "wall", "floor", + "pavement", "car", "car park", "parking lot", "stairs", "stairwell", "landing", "kitchen", + "bathroom", "hallway", "corridor", "lobby", "reception", "desk", "pew", "altar", "outside", + "inside", "nearby", "somewhere", "home", "house", "flat"); + + private static readonly HashSet InternalLocationWords = CreateSet( + "room", "kitchen", "bathroom", "hallway", "corridor", "lobby", "reception", "stairwell", "stairs", + "landing", "doorway", "pew", "altar", "floor"); + + 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); + + public async Task BuildReviewAsync(OnboardingStoryIntelligenceBatch batch) + { + var data = await BuildCandidateDataAsync(batch); + var decidedKeys = batch.LocationDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); + var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList(); + return new StoryIntelligenceLocationReviewViewModel + { + HasCommittedScenes = data.HasCommittedScenes, + CanImport = visibleCandidates.Count > 0, + AlreadyLinkedCount = data.AlreadyLinkedCount, + IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0, + Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceLocationReviewCandidateViewModel + { + Key = candidate.Key, + LocationName = candidate.DisplayName, + ImportName = candidate.DisplayName, + Category = candidate.Category, + AppearsInScenes = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count(), + Confidence = ConfidenceBand(candidate.Confidence), + ParentLocationHint = candidate.ParentLocationHint, + ExampleFirstAppearance = candidate.FirstAppearanceNote, + ExampleContext = candidate.Description, + ExistingLocationID = candidate.ExistingLocationID, + ExistingLocationName = candidate.ExistingLocationName + }).ToList() + }; + } + + public async Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceLocationImportForm form) + { + var data = await BuildCandidateDataAsync(batch); + if (!data.HasCommittedScenes) + { + return new StoryIntelligenceImportCommitResult { Success = false, Message = "Create scenes before importing locations." }; + } + + var choices = form.Locations + .Where(choice => !string.IsNullOrWhiteSpace(choice.Key)) + .ToDictionary(choice => choice.Key, choice => choice, StringComparer.OrdinalIgnoreCase); + if (choices.Count == 0) + { + return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose at least one location to create, link or ignore." }; + } + + var existingIndex = await BuildLocationIndexAsync(batch.ProjectID); + var lookupData = await locations.GetLookupsAsync(); + var created = 0; + var linkedExisting = 0; + var ignored = 0; + var aliasesAdded = 0; + var sceneLocationsUpdated = 0; + var resolvedNames = new Dictionary(StringComparer.OrdinalIgnoreCase); + 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, StoryIntelligenceLocationImportActions.Ignore, StringComparison.OrdinalIgnoreCase)) + { + ignored++; + AddDecision(batch, candidate.Key, StoryIntelligenceLocationImportActions.Ignore, candidate.DisplayName, null, false); + continue; + } + + if (string.Equals(choice.Action, StoryIntelligenceLocationImportActions.Alias, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var requestedName = Clean(choice.ImportName); + var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.DisplayName : requestedName; + var forceCreateSeparate = string.Equals(choice.Action, StoryIntelligenceLocationImportActions.CreateSeparate, StringComparison.OrdinalIgnoreCase); + var matchedExistingId = forceCreateSeparate + ? null + : candidate.ExistingLocationID + ?? existingIndex.Find(importName)?.LocationID + ?? existingIndex.Find(candidate.DisplayName)?.LocationID; + var locationId = matchedExistingId; + + if (!locationId.HasValue) + { + var parentId = ResolveParentLocationId(candidate.ParentLocationHint, existingIndex); + locationId = await locations.SaveAsync(new LocationItem + { + ProjectID = batch.ProjectID, + ParentLocationID = parentId, + LocationName = importName, + LocationTypeID = MatchLocationType(lookupData.LocationTypes, candidate), + Description = BuildLocationDescription(candidate), + DetectionPriority = 50 + }); + created++; + } + else + { + linkedExisting++; + } + + AddResolvedName(resolvedNames, candidate.DisplayName, locationId.Value); + AddResolvedName(resolvedNames, importName, locationId.Value); + resolvedCandidateIds[candidate.Key] = locationId.Value; + AddDecision(batch, candidate.Key, choice.Action, importName, locationId.Value, true); + } + + foreach (var candidate in data.Candidates) + { + if (!choices.TryGetValue(candidate.Key, out var choice) + || !string.Equals(choice.Action, StoryIntelligenceLocationImportActions.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, StoryIntelligenceLocationImportActions.Ignore, StringComparison.OrdinalIgnoreCase) + || string.Equals(targetChoice.Action, StoryIntelligenceLocationImportActions.Alias, StringComparison.OrdinalIgnoreCase) + || !candidateByKey.TryGetValue(targetKey, out var targetCandidate) + || !resolvedCandidateIds.TryGetValue(targetKey, out var targetLocationId)) + { + logger.LogWarning( + "Story Intelligence location alias candidate {CandidateKey} could not be imported because target {TargetKey} was not a resolved create/link target.", + candidate.Key, + targetKey); + continue; + } + + var targetName = targetCandidate.ExistingLocationName ?? targetChoice.ImportName ?? targetCandidate.DisplayName; + if (await TryAddAliasAsync(targetLocationId, candidate.DisplayName, targetName)) + { + aliasesAdded++; + } + + AddResolvedName(resolvedNames, candidate.DisplayName, targetLocationId); + AddDecision(batch, candidate.Key, StoryIntelligenceLocationImportActions.Alias, candidate.DisplayName, targetLocationId, true); + } + + foreach (var candidate in data.Candidates) + { + if (!choices.TryGetValue(candidate.Key, out var choice) + || string.Equals(choice.Action, StoryIntelligenceLocationImportActions.Ignore, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var locationId = string.Equals(choice.Action, StoryIntelligenceLocationImportActions.Alias, StringComparison.OrdinalIgnoreCase) + ? resolvedCandidateIds.GetValueOrDefault(Clean(choice.AliasTargetKey)) + : resolvedCandidateIds.GetValueOrDefault(candidate.Key); + if (locationId <= 0) + { + continue; + } + + foreach (var appearance in candidate.Appearances.Where(appearance => appearance.PresentInScene && !appearance.AlreadyLinked)) + { + var scene = await scenes.GetAsync(appearance.SceneID); + if (scene is null || scene.PrimaryLocationID.HasValue) + { + continue; + } + + scene.PrimaryLocationID = locationId; + await scenes.SaveAsync(scene); + sceneLocationsUpdated++; + } + } + + batch.LocationStageComplete = true; + batch.LastLocationImportResult = new StoryIntelligenceLocationImportBatchResult + { + LocationsCreated = created, + LocationsLinked = linkedExisting, + LocationAliasesAdded = aliasesAdded, + LocationsIgnored = ignored, + SceneLocationsUpdated = sceneLocationsUpdated + }; + await pipelineState.RecordLocationImportAsync(batch.ProjectID, batch.BookID); + + logger.LogInformation( + "Imported Story Intelligence locations for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliases={Aliases} Ignored={Ignored} SceneLocationsUpdated={SceneLocationsUpdated}", + batch.BatchID, + created, + linkedExisting, + aliasesAdded, + ignored, + sceneLocationsUpdated); + + return new StoryIntelligenceImportCommitResult + { + Success = true, + ScenesCreated = sceneLocationsUpdated, + Message = $"Locations imported. {created:N0} created, {linkedExisting:N0} linked, {aliasesAdded:N0} alias/variant(s) added, {ignored:N0} ignored, {sceneLocationsUpdated:N0} scene location(s) updated." + }; + } + + private async Task BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch) + { + var existingIndex = await BuildLocationIndexAsync(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; + } + + if (importedScene.PrimaryLocationID.HasValue) + { + alreadyLinked++; + } + + AddSceneLocations(groups, existingIndex, importedScene, parsed); + } + } + + var decidedKeys = batch.LocationDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); + var candidates = groups.Values + .Where(candidate => candidate.Appearances.Any(appearance => !appearance.AlreadyLinked)) + .Where(candidate => IsVisibleLocationCandidate(candidate)) + .OrderByDescending(candidate => candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count()) + .ThenBy(candidate => candidate.DisplayName) + .ToList(); + + return new LocationCandidateData(hasCommittedScenes, alreadyLinked, candidates, decidedKeys); + } + + private static void AddSceneLocations( + Dictionary groups, + LocationIndex existingIndex, + Scene importedScene, + SceneIntelligenceScene parsed) + { + var setting = parsed.Setting; + var settingName = FirstConfigured(setting?.LocationName, setting?.GenericRoomType); + AddLocation(groups, existingIndex, importedScene, parsed, settingName, setting?.LocationType, setting?.GenericRoomType, setting?.ParentLocationHint, true, false, setting?.Confidence, "Scene setting"); + + foreach (var location in parsed.Locations ?? []) + { + AddLocation(groups, existingIndex, importedScene, parsed, location.Name, location.LocationType, location.GenericRoomType, location.ParentLocationHint, location.PresentInScene == true, location.MentionedOnly == true, location.Confidence, location.Notes); + } + + foreach (var observation in parsed.Observations ?? []) + { + if (IsLocationEntity(observation.SubjectEntityType)) + { + AddLocation(groups, existingIndex, importedScene, parsed, observation.SubjectName, null, null, null, IsPresentPredicate(observation.Predicate), !IsPresentPredicate(observation.Predicate), observation.Confidence, observation.Description); + } + + if (IsLocationEntity(observation.ObjectEntityType)) + { + AddLocation(groups, existingIndex, importedScene, parsed, observation.ObjectName, null, null, null, IsPresentPredicate(observation.Predicate), !IsPresentPredicate(observation.Predicate), observation.Confidence, observation.Description); + } + } + } + + private static void AddLocation( + Dictionary groups, + LocationIndex existingIndex, + Scene importedScene, + SceneIntelligenceScene parsed, + string? name, + string? locationType, + string? genericRoomType, + string? parentLocationHint, + bool presentInScene, + bool mentionedOnly, + decimal? confidence, + string? notes) + { + var cleanName = CleanLocationName(name); + if (!IsLocationNameCandidate(cleanName)) + { + return; + } + + var key = ResolveCandidateKey(groups, existingIndex, cleanName); + if (!groups.TryGetValue(key, out var candidate)) + { + var match = existingIndex.Find(cleanName); + candidate = new LocationCandidate(key, cleanName, match?.LocationID, match?.LocationName) + { + Category = Categorise(cleanName, locationType, genericRoomType), + ParentLocationHint = Clean(parentLocationHint) + }; + groups[key] = candidate; + } + else if (!string.Equals(candidate.DisplayName, cleanName, StringComparison.OrdinalIgnoreCase)) + { + candidate.Aliases.Add(cleanName); + } + + candidate.Confidence = Max(candidate.Confidence, confidence); + candidate.Description ??= Clean(notes); + candidate.FirstAppearanceNote ??= BuildFirstAppearance(importedScene, parsed, cleanName); + candidate.ParentLocationHint ??= Clean(parentLocationHint); + candidate.Appearances.Add(new LocationAppearanceImport( + importedScene.SceneID, + importedScene.SceneNumber, + importedScene.SceneTitle, + presentInScene && !mentionedOnly, + mentionedOnly, + notes, + confidence, + importedScene.PrimaryLocationID.HasValue && candidate.ExistingLocationID == importedScene.PrimaryLocationID)); + } + + private async Task BuildLocationIndexAsync(int projectId) + { + var index = new LocationIndex(); + foreach (var location in await locations.ListByProjectAsync(projectId)) + { + index.Add(location.LocationName, location.LocationID, location.LocationName); + index.Add(location.LocationPath, location.LocationID, location.LocationName); + foreach (var alias in await locations.ListAliasesAsync(location.LocationID)) + { + index.Add(alias.Alias, location.LocationID, location.LocationName); + } + } + + return index; + } + + private async Task TryAddAliasAsync(int locationId, string alias, string primaryName) + { + var cleanAlias = Clean(alias); + if (string.IsNullOrWhiteSpace(cleanAlias) || string.Equals(cleanAlias, primaryName, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var existingAliases = await locations.ListAliasesAsync(locationId); + if (existingAliases.Any(item => string.Equals(Clean(item.Alias), cleanAlias, StringComparison.OrdinalIgnoreCase))) + { + return false; + } + + await locations.AddAliasAsync(locationId, 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? MatchLocationType(IReadOnlyList types, LocationCandidate candidate) + => types.FirstOrDefault(type => + candidate.Category.Contains(type.TypeName, StringComparison.OrdinalIgnoreCase) + || type.TypeName.Contains(candidate.Category, StringComparison.OrdinalIgnoreCase))?.LocationTypeID; + + private static int? ResolveParentLocationId(string? parentHint, LocationIndex index) + => string.IsNullOrWhiteSpace(parentHint) ? null : index.Find(parentHint)?.LocationID; + + private static string BuildLocationDescription(LocationCandidate candidate) + { + var lines = new List(); + if (!string.IsNullOrWhiteSpace(candidate.ParentLocationHint)) + { + lines.Add($"Parent/location hint from Story Intelligence: {candidate.ParentLocationHint}"); + } + + if (!string.IsNullOrWhiteSpace(candidate.Description)) + { + lines.Add(candidate.Description); + } + + return string.Join(Environment.NewLine, lines); + } + + private static bool IsVisibleLocationCandidate(LocationCandidate candidate) + { + if (IsNamedLocation(candidate.DisplayName)) + { + return true; + } + + if (!string.IsNullOrWhiteSpace(candidate.ParentLocationHint)) + { + return candidate.Appearances.Count(appearance => appearance.PresentInScene) > 0; + } + + if (IsGenericLocation(candidate.DisplayName)) + { + return candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count() >= 2; + } + + return true; + } + + private static bool IsLocationNameCandidate(string? name) + { + var clean = CleanLocationName(name); + return !string.IsNullOrWhiteSpace(clean) && clean.Length > 1 && clean.Any(char.IsLetter); + } + + private static bool IsGenericLocation(string name) + => GenericLocations.Contains(Normalise(name)) || InternalLocationWords.Contains(Normalise(name)); + + private static bool IsNamedLocation(string name) + => AddressPattern.IsMatch(name) + || IsNamedRoad(name) + || name.Any(char.IsUpper) + || name.Contains('\'') + || name.Contains('’'); + + private static bool IsNamedRoad(string name) + => NamedRoadPattern.IsMatch(name) && !GenericLocations.Contains(Normalise(name)); + + private static string Categorise(string name, string? locationType, string? genericRoomType) + { + if (AddressPattern.IsMatch(name)) + { + return "Address"; + } + + if (IsNamedRoad(name)) + { + return "Road / street"; + } + + if (Normalise(locationType).Contains("generic", StringComparison.OrdinalIgnoreCase) || !string.IsNullOrWhiteSpace(genericRoomType)) + { + return "Room / interior area"; + } + + if (Normalise(name).Contains("car", StringComparison.OrdinalIgnoreCase)) + { + return "Vehicle-as-location"; + } + + return IsNamedLocation(name) ? "Named location" : "Ambiguous location"; + } + + private static bool IsLocationEntity(string? entityType) + => string.Equals(Clean(entityType), "Location", StringComparison.OrdinalIgnoreCase); + + private static bool IsPresentPredicate(string? predicate) + { + var value = Clean(predicate); + return value.Contains("isPresentAt", StringComparison.OrdinalIgnoreCase) + || value.Contains("travelsTo", StringComparison.OrdinalIgnoreCase) + || value.Contains("occursAt", StringComparison.OrdinalIgnoreCase); + } + + private static string ResolveCandidateKey( + IReadOnlyDictionary groups, + LocationIndex existingIndex, + string name) + { + var existingMatch = existingIndex.Find(name); + if (existingMatch is not null) + { + var existingKey = CanonicalLocationKey(existingMatch.LocationName); + if (groups.ContainsKey(existingKey)) + { + return existingKey; + } + + return existingKey; + } + + var canonical = CanonicalLocationKey(name); + return groups.ContainsKey(canonical) ? canonical : canonical; + } + + private static void AddResolvedName(Dictionary map, string name, int locationId) + { + var clean = Clean(name); + if (!string.IsNullOrWhiteSpace(clean)) + { + map[clean] = locationId; + } + } + + private static void AddDecision(OnboardingStoryIntelligenceBatch batch, string key, string action, string? locationName, int? locationId, bool createdOrLinked) + { + batch.LocationDecisions.RemoveAll(decision => string.Equals(decision.Key, key, StringComparison.OrdinalIgnoreCase)); + batch.LocationDecisions.Add(new OnboardingStoryIntelligenceLocationDecision + { + Key = key, + Action = action, + LocationName = locationName, + LocationID = locationId, + CreatedOrLinked = createdOrLinked + }); + } + + private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed, string locationName) + { + var summary = Clean(parsed.Summary?.Short); + return string.IsNullOrWhiteSpace(summary) + ? $"Scene {scene.SceneNumber:g}: {locationName}" + : $"Scene {scene.SceneNumber:g}: {summary}"; + } + + private static string FirstConfigured(params string?[] values) + => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty; + + 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 CanonicalLocationKey(string name) + => Normalise(CleanLocationName(name) + .Replace("the ", string.Empty, StringComparison.OrdinalIgnoreCase)); + + private static string CleanLocationName(string? value) + { + var clean = Clean(value).Trim(' ', '.', ',', ';', ':', '!', '?', '"', '\''); + if (clean.EndsWith("'s", StringComparison.OrdinalIgnoreCase) || clean.EndsWith("’s", StringComparison.OrdinalIgnoreCase)) + { + return clean; + } + + return clean; + } + + 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 LocationIndex + { + private readonly Dictionary byName = new(StringComparer.OrdinalIgnoreCase); + + public void Add(string? name, int locationId, string locationName) + { + var key = CanonicalLocationKey(name ?? string.Empty); + if (!string.IsNullOrWhiteSpace(key)) + { + byName.TryAdd(key, new LocationMatch(locationId, locationName)); + } + } + + public LocationMatch? Find(string name) + => byName.TryGetValue(CanonicalLocationKey(name), out var match) ? match : null; + } + + private sealed record LocationMatch(int LocationID, string LocationName); + + private sealed class LocationCandidate(string key, string displayName, int? existingLocationId, string? existingLocationName) + { + public string Key { get; } = key; + public string DisplayName { get; } = displayName; + public int? ExistingLocationID { get; } = existingLocationId; + public string? ExistingLocationName { get; } = existingLocationName; + public string Category { get; set; } = "Ambiguous location"; + public string? ParentLocationHint { 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 LocationAppearanceImport( + int SceneID, + decimal SceneNumber, + string SceneTitle, + bool PresentInScene, + bool MentionedOnly, + string? Notes, + decimal? Confidence, + bool AlreadyLinked); + + private sealed record LocationCandidateData( + bool HasCommittedScenes, + int AlreadyLinkedCount, + IReadOnlyList Candidates, + IReadOnlySet DecidedKeys); +} diff --git a/PlotLine/Services/StoryIntelligencePipelineStateService.cs b/PlotLine/Services/StoryIntelligencePipelineStateService.cs index 9ec1961..5aae11f 100644 --- a/PlotLine/Services/StoryIntelligencePipelineStateService.cs +++ b/PlotLine/Services/StoryIntelligencePipelineStateService.cs @@ -10,6 +10,7 @@ public interface IStoryIntelligencePipelineStateService Task EnsureForBookAsync(int bookId, int userId); Task RecordSceneImportAsync(int projectId, int bookId, int runId); Task RecordCharacterImportAsync(int projectId, int bookId); + Task RecordLocationImportAsync(int projectId, int bookId); Task> ListCommittedRunsByBookAsync(int bookId, int userId); } @@ -80,8 +81,23 @@ public sealed class StoryIntelligencePipelineStateService( { ProjectID = projectId, BookID = bookId, - CurrentStage = StoryIntelligencePipelineStages.Complete, + CurrentStage = StoryIntelligencePipelineStages.LocationReview, LastCompletedStage = StoryIntelligencePipelineStages.CharacterImport, + CurrentReviewStage = StoryIntelligencePipelineStages.LocationReview, + Status = StoryIntelligencePipelineStatuses.NeedsReview, + CompletedUtc = null, + LastRunID = null + }); + } + + public async Task RecordLocationImportAsync(int projectId, int bookId) + { + await pipelines.UpsertAsync(new StoryIntelligenceBookPipelineSaveRequest + { + ProjectID = projectId, + BookID = bookId, + CurrentStage = StoryIntelligencePipelineStages.Complete, + LastCompletedStage = StoryIntelligencePipelineStages.LocationImport, CurrentReviewStage = null, Status = StoryIntelligencePipelineStatuses.Complete, CompletedUtc = DateTime.UtcNow, diff --git a/PlotLine/Sql/129_Phase20AN_LocationReviewPipelineStages.sql b/PlotLine/Sql/129_Phase20AN_LocationReviewPipelineStages.sql new file mode 100644 index 0000000..b8893e9 --- /dev/null +++ b/PlotLine/Sql/129_Phase20AN_LocationReviewPipelineStages.sql @@ -0,0 +1,34 @@ +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'Complete' + )); +END; +GO diff --git a/PlotLine/ViewModels/OnboardingViewModels.cs b/PlotLine/ViewModels/OnboardingViewModels.cs index e513efc..b4a2951 100644 --- a/PlotLine/ViewModels/OnboardingViewModels.cs +++ b/PlotLine/ViewModels/OnboardingViewModels.cs @@ -143,6 +143,7 @@ public sealed class StoryIntelligenceProgressViewModel public string? BookTitle { get; init; } public IReadOnlyList Chapters { get; init; } = []; public StoryIntelligenceCharacterReviewViewModel CharacterReview { get; init; } = new(); + public StoryIntelligenceLocationReviewViewModel LocationReview { get; init; } = new(); public StoryIntelligencePipelineDashboardViewModel PipelineDashboard { get; init; } = new(); public int ChapterCount => Chapters.Count; public int CompletedChapterCount => Chapters.Count(chapter => chapter.IsRunComplete); @@ -158,6 +159,7 @@ public sealed class StoryIntelligenceProgressViewModel public bool AllCommitted => Chapters.Count > 0 && Chapters.All(chapter => chapter.HasCompletedCommit); public bool HasReadyScenesToCreate => Chapters.Any(chapter => chapter.CanCommit); public bool HasCharactersToReview => CharacterReview.Candidates.Count > 0; + public bool HasLocationsToReview => LocationReview.Candidates.Count > 0; } public sealed class StoryIntelligencePipelineDashboardViewModel @@ -167,6 +169,9 @@ public sealed class StoryIntelligencePipelineDashboardViewModel public int CharactersIdentified { get; init; } public int CharactersCreatedOrLinked { get; init; } public bool CharacterStageComplete { get; init; } + public int LocationsIdentified { get; init; } + public int LocationsCreatedOrLinked { get; init; } + public bool LocationStageComplete { get; init; } } public sealed class StoryIntelligenceCharacterReviewViewModel @@ -215,6 +220,54 @@ public static class StoryIntelligenceCharacterImportActions public const string Alias = "Alias"; } +public sealed class StoryIntelligenceLocationReviewViewModel +{ + 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 StoryIntelligenceLocationReviewCandidateViewModel +{ + public string Key { get; init; } = string.Empty; + public string LocationName { get; init; } = string.Empty; + public string ImportName { get; init; } = string.Empty; + public string Category { get; init; } = "Ambiguous location"; + public int AppearsInScenes { get; init; } + public string Confidence { get; init; } = "Unknown"; + public string? ParentLocationHint { get; init; } + public string? ExampleFirstAppearance { get; init; } + public string? ExampleContext { get; init; } + public int? ExistingLocationID { get; init; } + public string? ExistingLocationName { get; init; } + public bool IsExistingMatch => ExistingLocationID.HasValue; + public string ActionLabel => IsExistingMatch ? "Link existing" : "Create"; +} + +public sealed class StoryIntelligenceLocationImportForm +{ + public Guid BatchID { get; set; } + public List Locations { get; set; } = []; +} + +public sealed class StoryIntelligenceLocationImportChoiceForm +{ + public string Key { get; set; } = string.Empty; + public string Action { get; set; } = StoryIntelligenceLocationImportActions.Approve; + public string? ImportName { get; set; } + public string? AliasTargetKey { get; set; } +} + +public static class StoryIntelligenceLocationImportActions +{ + 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(); @@ -228,6 +281,8 @@ public sealed class StoryIntelligenceCompletionViewModel public int ScenesCreated { get; init; } public int CharactersCreatedOrLinked { get; init; } public bool CharacterStageComplete { get; init; } + public int LocationsCreatedOrLinked { get; init; } + public bool LocationStageComplete { get; init; } } public sealed class StoryIntelligenceCharacterImportResultViewModel @@ -243,6 +298,18 @@ public sealed class StoryIntelligenceCharacterImportResultViewModel public int ScenePeoplePanelsUpdated { get; init; } } +public sealed class StoryIntelligenceLocationImportResultViewModel +{ + public Guid BatchID { get; init; } + public int ProjectID { get; init; } + public int BookID { get; init; } + public int LocationsCreated { get; init; } + public int LocationsLinked { get; init; } + public int LocationAliasesAdded { get; init; } + public int LocationsIgnored { get; init; } + public int SceneLocationsUpdated { get; init; } +} + public sealed class StoryIntelligenceOnboardingChapterViewModel { public string TemporaryChapterKey { get; init; } = string.Empty; diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceCharacterComplete.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceCharacterComplete.cshtml index f5aea2a..b4f54c7 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceCharacterComplete.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceCharacterComplete.cshtml @@ -43,7 +43,7 @@
-
Review LocationsComing Soon
+
Review LocationsNext
Review AssetsComing Soon
Review RelationshipsComing Soon
Review KnowledgeComing Soon
@@ -51,7 +51,7 @@
diff --git a/PlotLine/Views/Onboarding/_StoryIntelligencePipelineSummary.cshtml b/PlotLine/Views/Onboarding/_StoryIntelligencePipelineSummary.cshtml index c087803..40b7cf9 100644 --- a/PlotLine/Views/Onboarding/_StoryIntelligencePipelineSummary.cshtml +++ b/PlotLine/Views/Onboarding/_StoryIntelligencePipelineSummary.cshtml @@ -18,8 +18,12 @@ @(Model.CharacterStageComplete ? $"Done: {Model.CharactersCreatedOrLinked:N0}" : "In review")
- Locations - Coming next + Locations identified + @Model.LocationsIdentified.ToString("N0") +
+
+ Locations created / linked + @(Model.LocationStageComplete ? $"Done: {Model.LocationsCreatedOrLinked:N0}" : "In review")
Assets