Phase 20AN – Location Intelligence Review and Import

This commit is contained in:
Nick Beckley 2026-07-09 20:13:06 +01:00
parent f4897e5d5f
commit ef51765eb4
10 changed files with 952 additions and 7 deletions

View File

@ -75,6 +75,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return target.Route switch return target.Route switch
{ {
StoryIntelligenceResumeRoutes.Complete => RedirectToAction(nameof(StoryIntelligenceComplete), new { batchId = target.BatchID }), 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 }), StoryIntelligenceResumeRoutes.Characters => RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId = target.BatchID }),
_ => RedirectToAction(nameof(StoryIntelligenceReview), new { batchId = target.BatchID }) _ => RedirectToAction(nameof(StoryIntelligenceReview), new { batchId = target.BatchID })
}; };
@ -156,6 +157,30 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
: View(model); : View(model);
} }
[HttpGet("story-intelligence/locations")]
public async Task<IActionResult> 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")] [HttpPost("story-intelligence/cancel")]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> CancelStoryIntelligence(Guid batchId) public async Task<IActionResult> CancelStoryIntelligence(Guid batchId)
@ -229,6 +254,29 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return model is null ? NotFound() : View(model); return model is null ? NotFound() : View(model);
} }
[HttpPost("story-intelligence/locations")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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<IActionResult> StoryIntelligenceLocationComplete(Guid batchId)
{
var model = await storyIntelligence.GetLocationImportResultAsync(batchId);
return model is null ? NotFound() : View(model);
}
[HttpGet("story-intelligence/complete")] [HttpGet("story-intelligence/complete")]
public async Task<IActionResult> StoryIntelligenceComplete(Guid batchId) public async Task<IActionResult> StoryIntelligenceComplete(Guid batchId)
{ {
@ -243,6 +291,11 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId }); return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId });
} }
if (!progress.PipelineDashboard.LocationStageComplete)
{
return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId });
}
var model = await storyIntelligence.GetCompletionAsync(batchId); var model = await storyIntelligence.GetCompletionAsync(batchId);
return model is null ? NotFound() : View(model); return model is null ? NotFound() : View(model);
} }

View File

@ -28,6 +28,8 @@ public static class StoryIntelligencePipelineStages
public const string SceneImport = "SceneImport"; public const string SceneImport = "SceneImport";
public const string CharacterReview = "CharacterReview"; public const string CharacterReview = "CharacterReview";
public const string CharacterImport = "CharacterImport"; public const string CharacterImport = "CharacterImport";
public const string LocationReview = "LocationReview";
public const string LocationImport = "LocationImport";
public const string Complete = "Complete"; public const string Complete = "Complete";
} }

View File

@ -208,6 +208,7 @@ public class Program
builder.Services.AddScoped<IStoryIntelligenceFullChapterTestService, StoryIntelligenceFullChapterTestService>(); builder.Services.AddScoped<IStoryIntelligenceFullChapterTestService, StoryIntelligenceFullChapterTestService>();
builder.Services.AddScoped<IStoryIntelligenceImportCommitService, StoryIntelligenceImportCommitService>(); builder.Services.AddScoped<IStoryIntelligenceImportCommitService, StoryIntelligenceImportCommitService>();
builder.Services.AddScoped<IStoryIntelligenceCharacterImportService, StoryIntelligenceCharacterImportService>(); builder.Services.AddScoped<IStoryIntelligenceCharacterImportService, StoryIntelligenceCharacterImportService>();
builder.Services.AddScoped<IStoryIntelligenceLocationImportService, StoryIntelligenceLocationImportService>();
builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>(); builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>();
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>(); builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>(); builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();

View File

@ -16,6 +16,8 @@ public interface IOnboardingStoryIntelligenceService
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAllAsync(Guid batchId); Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAllAsync(Guid batchId);
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportCharactersAsync(Guid batchId, StoryIntelligenceCharacterImportForm form); Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportCharactersAsync(Guid batchId, StoryIntelligenceCharacterImportForm form);
Task<StoryIntelligenceCharacterImportResultViewModel?> GetCharacterImportResultAsync(Guid batchId); Task<StoryIntelligenceCharacterImportResultViewModel?> GetCharacterImportResultAsync(Guid batchId);
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportLocationsAsync(Guid batchId, StoryIntelligenceLocationImportForm form);
Task<StoryIntelligenceLocationImportResultViewModel?> GetLocationImportResultAsync(Guid batchId);
Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId); Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId);
Task<OnboardingStoryIntelligenceResumeTarget?> ResumeBookAsync(int bookId); Task<OnboardingStoryIntelligenceResumeTarget?> ResumeBookAsync(int bookId);
} }
@ -27,6 +29,7 @@ public sealed class OnboardingStoryIntelligenceService(
IStoryIntelligenceResultRepository runs, IStoryIntelligenceResultRepository runs,
IStoryIntelligenceImportCommitService commits, IStoryIntelligenceImportCommitService commits,
IStoryIntelligenceCharacterImportService characterImport, IStoryIntelligenceCharacterImportService characterImport,
IStoryIntelligenceLocationImportService locationImport,
IStoryIntelligencePipelineStateService pipelineState, IStoryIntelligencePipelineStateService pipelineState,
IStoryIntelligenceClient client, IStoryIntelligenceClient client,
IOnboardingStoryIntelligenceBatchStore batchStore, IOnboardingStoryIntelligenceBatchStore batchStore,
@ -259,6 +262,7 @@ public sealed class OnboardingStoryIntelligenceService(
} }
var characterReview = await characterImport.BuildReviewAsync(batch); var characterReview = await characterImport.BuildReviewAsync(batch);
var locationReview = await locationImport.BuildReviewAsync(batch);
return new StoryIntelligenceProgressViewModel return new StoryIntelligenceProgressViewModel
{ {
BatchID = batch.BatchID, BatchID = batch.BatchID,
@ -269,13 +273,17 @@ public sealed class OnboardingStoryIntelligenceService(
BookTitle = batch.BookTitle, BookTitle = batch.BookTitle,
Chapters = chapters, Chapters = chapters,
CharacterReview = characterReview, CharacterReview = characterReview,
LocationReview = locationReview,
PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel
{ {
ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete), ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete),
ScenesImported = chapters.Sum(chapter => chapter.ScenesCreated), ScenesImported = chapters.Sum(chapter => chapter.ScenesCreated),
CharactersIdentified = characterReview.Candidates.Count + batch.CharacterDecisions.Count, CharactersIdentified = characterReview.Candidates.Count + batch.CharacterDecisions.Count,
CharactersCreatedOrLinked = batch.CharacterDecisions.Count(decision => decision.CreatedOrLinked), 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<StoryIntelligenceLocationImportResultViewModel?> 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<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId) public async Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId)
{ {
var progress = await GetProgressAsync(batchId); var progress = await GetProgressAsync(batchId);
@ -396,7 +437,9 @@ public sealed class OnboardingStoryIntelligenceService(
ChaptersCommitted = progress.CommittedChapterCount, ChaptersCommitted = progress.CommittedChapterCount,
ScenesCreated = progress.Chapters.Sum(chapter => chapter.ScenesCreated), ScenesCreated = progress.Chapters.Sum(chapter => chapter.ScenesCreated),
CharactersCreatedOrLinked = progress.PipelineDashboard.CharactersCreatedOrLinked, 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) if (state.IsComplete)
{
batch.CharacterStageComplete = true;
batch.LocationStageComplete = true;
}
else if (state.CurrentStage is StoryIntelligencePipelineStages.LocationReview or StoryIntelligencePipelineStages.LocationImport)
{ {
batch.CharacterStageComplete = true; batch.CharacterStageComplete = true;
} }
@ -448,6 +496,8 @@ public sealed class OnboardingStoryIntelligenceService(
var route = state.CurrentStage switch var route = state.CurrentStage switch
{ {
StoryIntelligencePipelineStages.Complete => StoryIntelligenceResumeRoutes.Complete, StoryIntelligencePipelineStages.Complete => StoryIntelligenceResumeRoutes.Complete,
StoryIntelligencePipelineStages.LocationReview => StoryIntelligenceResumeRoutes.Locations,
StoryIntelligencePipelineStages.LocationImport => StoryIntelligenceResumeRoutes.Locations,
StoryIntelligencePipelineStages.CharacterReview => StoryIntelligenceResumeRoutes.Characters, StoryIntelligencePipelineStages.CharacterReview => StoryIntelligenceResumeRoutes.Characters,
StoryIntelligencePipelineStages.CharacterImport => StoryIntelligenceResumeRoutes.Characters, StoryIntelligencePipelineStages.CharacterImport => StoryIntelligenceResumeRoutes.Characters,
StoryIntelligencePipelineStages.SceneReview => StoryIntelligenceResumeRoutes.SceneReview, StoryIntelligencePipelineStages.SceneReview => StoryIntelligenceResumeRoutes.SceneReview,
@ -686,8 +736,11 @@ public sealed class OnboardingStoryIntelligenceBatch
public DateTime CreatedUtc { get; init; } = DateTime.UtcNow; public DateTime CreatedUtc { get; init; } = DateTime.UtcNow;
public IReadOnlyList<OnboardingStoryIntelligenceBatchItem> Items { get; init; } = []; public IReadOnlyList<OnboardingStoryIntelligenceBatchItem> Items { get; init; } = [];
public List<OnboardingStoryIntelligenceCharacterDecision> CharacterDecisions { get; } = []; public List<OnboardingStoryIntelligenceCharacterDecision> CharacterDecisions { get; } = [];
public List<OnboardingStoryIntelligenceLocationDecision> LocationDecisions { get; } = [];
public StoryIntelligenceCharacterImportBatchResult? LastCharacterImportResult { get; set; } public StoryIntelligenceCharacterImportBatchResult? LastCharacterImportResult { get; set; }
public StoryIntelligenceLocationImportBatchResult? LastLocationImportResult { get; set; }
public bool CharacterStageComplete { get; set; } public bool CharacterStageComplete { get; set; }
public bool LocationStageComplete { get; set; }
} }
public sealed class OnboardingStoryIntelligenceBatchItem public sealed class OnboardingStoryIntelligenceBatchItem
@ -708,6 +761,15 @@ public sealed class OnboardingStoryIntelligenceCharacterDecision
public bool CreatedOrLinked { get; init; } 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 sealed class StoryIntelligenceCharacterImportBatchResult
{ {
public int CharactersCreated { get; init; } public int CharactersCreated { get; init; }
@ -718,10 +780,20 @@ public sealed class StoryIntelligenceCharacterImportBatchResult
public int ScenePeoplePanelsUpdated { get; init; } 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 static class StoryIntelligenceResumeRoutes
{ {
public const string SceneReview = "SceneReview"; public const string SceneReview = "SceneReview";
public const string Characters = "Characters"; public const string Characters = "Characters";
public const string Locations = "Locations";
public const string Complete = "Complete"; public const string Complete = "Complete";
} }

View File

@ -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<StoryIntelligenceLocationReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch);
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceLocationImportForm form);
}
public sealed class StoryIntelligenceLocationImportService(
IStoryIntelligenceResultRepository runs,
ILocationRepository locations,
ISceneRepository scenes,
IStoryIntelligencePipelineStateService pipelineState,
ILogger<StoryIntelligenceLocationImportService> logger) : IStoryIntelligenceLocationImportService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
private static readonly HashSet<string> 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<string> 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<StoryIntelligenceLocationReviewViewModel> 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<StoryIntelligenceImportCommitResult> 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<string, int>(StringComparer.OrdinalIgnoreCase);
var resolvedCandidateIds = new Dictionary<string, int>(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<LocationCandidateData> BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch)
{
var existingIndex = await BuildLocationIndexAsync(batch.ProjectID);
var groups = new Dictionary<string, LocationCandidate>(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<string, LocationCandidate> 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<string, LocationCandidate> 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<LocationIndex> 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<bool> 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<string, Scene> byRange,
IReadOnlyDictionary<int, Scene> 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<SceneIntelligenceScene>(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<SceneIntelligenceScene>(JsonOptions);
}
}
catch (JsonException)
{
}
return null;
}
private static int? MatchLocationType(IReadOnlyList<LocationType> 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<string>();
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<string, LocationCandidate> 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<string, int> 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<string> CreateSet(params string[] values)
=> values.Select(Normalise).ToHashSet(StringComparer.OrdinalIgnoreCase);
private sealed class LocationIndex
{
private readonly Dictionary<string, LocationMatch> 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<string> Aliases { get; } = new(StringComparer.OrdinalIgnoreCase);
public List<LocationAppearanceImport> 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<LocationCandidate> Candidates,
IReadOnlySet<string> DecidedKeys);
}

View File

@ -10,6 +10,7 @@ public interface IStoryIntelligencePipelineStateService
Task<StoryIntelligenceBookPipelineState?> EnsureForBookAsync(int bookId, int userId); Task<StoryIntelligenceBookPipelineState?> EnsureForBookAsync(int bookId, int userId);
Task RecordSceneImportAsync(int projectId, int bookId, int runId); Task RecordSceneImportAsync(int projectId, int bookId, int runId);
Task RecordCharacterImportAsync(int projectId, int bookId); Task RecordCharacterImportAsync(int projectId, int bookId);
Task RecordLocationImportAsync(int projectId, int bookId);
Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId); Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId);
} }
@ -80,8 +81,23 @@ public sealed class StoryIntelligencePipelineStateService(
{ {
ProjectID = projectId, ProjectID = projectId,
BookID = bookId, BookID = bookId,
CurrentStage = StoryIntelligencePipelineStages.Complete, CurrentStage = StoryIntelligencePipelineStages.LocationReview,
LastCompletedStage = StoryIntelligencePipelineStages.CharacterImport, 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, CurrentReviewStage = null,
Status = StoryIntelligencePipelineStatuses.Complete, Status = StoryIntelligencePipelineStatuses.Complete,
CompletedUtc = DateTime.UtcNow, CompletedUtc = DateTime.UtcNow,

View File

@ -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

View File

@ -143,6 +143,7 @@ public sealed class StoryIntelligenceProgressViewModel
public string? BookTitle { get; init; } public string? BookTitle { get; init; }
public IReadOnlyList<StoryIntelligenceOnboardingChapterViewModel> Chapters { get; init; } = []; public IReadOnlyList<StoryIntelligenceOnboardingChapterViewModel> Chapters { get; init; } = [];
public StoryIntelligenceCharacterReviewViewModel CharacterReview { get; init; } = new(); public StoryIntelligenceCharacterReviewViewModel CharacterReview { get; init; } = new();
public StoryIntelligenceLocationReviewViewModel LocationReview { get; init; } = new();
public StoryIntelligencePipelineDashboardViewModel PipelineDashboard { get; init; } = new(); public StoryIntelligencePipelineDashboardViewModel PipelineDashboard { get; init; } = new();
public int ChapterCount => Chapters.Count; public int ChapterCount => Chapters.Count;
public int CompletedChapterCount => Chapters.Count(chapter => chapter.IsRunComplete); 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 AllCommitted => Chapters.Count > 0 && Chapters.All(chapter => chapter.HasCompletedCommit);
public bool HasReadyScenesToCreate => Chapters.Any(chapter => chapter.CanCommit); public bool HasReadyScenesToCreate => Chapters.Any(chapter => chapter.CanCommit);
public bool HasCharactersToReview => CharacterReview.Candidates.Count > 0; public bool HasCharactersToReview => CharacterReview.Candidates.Count > 0;
public bool HasLocationsToReview => LocationReview.Candidates.Count > 0;
} }
public sealed class StoryIntelligencePipelineDashboardViewModel public sealed class StoryIntelligencePipelineDashboardViewModel
@ -167,6 +169,9 @@ public sealed class StoryIntelligencePipelineDashboardViewModel
public int CharactersIdentified { get; init; } public int CharactersIdentified { get; init; }
public int CharactersCreatedOrLinked { get; init; } public int CharactersCreatedOrLinked { get; init; }
public bool CharacterStageComplete { 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 public sealed class StoryIntelligenceCharacterReviewViewModel
@ -215,6 +220,54 @@ public static class StoryIntelligenceCharacterImportActions
public const string Alias = "Alias"; 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<StoryIntelligenceLocationReviewCandidateViewModel> 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<StoryIntelligenceLocationImportChoiceForm> 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 sealed class StoryIntelligenceCompletionViewModel
{ {
public StoryIntelligenceJobProgress Job { get; init; } = new(); public StoryIntelligenceJobProgress Job { get; init; } = new();
@ -228,6 +281,8 @@ public sealed class StoryIntelligenceCompletionViewModel
public int ScenesCreated { get; init; } public int ScenesCreated { get; init; }
public int CharactersCreatedOrLinked { get; init; } public int CharactersCreatedOrLinked { get; init; }
public bool CharacterStageComplete { get; init; } public bool CharacterStageComplete { get; init; }
public int LocationsCreatedOrLinked { get; init; }
public bool LocationStageComplete { get; init; }
} }
public sealed class StoryIntelligenceCharacterImportResultViewModel public sealed class StoryIntelligenceCharacterImportResultViewModel
@ -243,6 +298,18 @@ public sealed class StoryIntelligenceCharacterImportResultViewModel
public int ScenePeoplePanelsUpdated { get; init; } 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 sealed class StoryIntelligenceOnboardingChapterViewModel
{ {
public string TemporaryChapterKey { get; init; } = string.Empty; public string TemporaryChapterKey { get; init; } = string.Empty;

View File

@ -43,7 +43,7 @@
</div> </div>
<div class="story-future-stage-grid"> <div class="story-future-stage-grid">
<article class="story-future-stage"><strong>Review Locations</strong><span>Coming Soon</span></article> <article class="story-future-stage"><strong>Review Locations</strong><span>Next</span></article>
<article class="story-future-stage"><strong>Review Assets</strong><span>Coming Soon</span></article> <article class="story-future-stage"><strong>Review Assets</strong><span>Coming Soon</span></article>
<article class="story-future-stage"><strong>Review Relationships</strong><span>Coming Soon</span></article> <article class="story-future-stage"><strong>Review Relationships</strong><span>Coming Soon</span></article>
<article class="story-future-stage"><strong>Review Knowledge</strong><span>Coming Soon</span></article> <article class="story-future-stage"><strong>Review Knowledge</strong><span>Coming Soon</span></article>
@ -51,7 +51,7 @@
<div class="onboarding-actions"> <div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceCharacters" asp-route-batchId="@Model.BatchID">Back to characters</a> <a class="btn btn-outline-secondary" asp-action="StoryIntelligenceCharacters" asp-route-batchId="@Model.BatchID">Back to characters</a>
<a class="btn btn-primary" asp-action="StoryIntelligenceComplete" asp-route-batchId="@Model.BatchID">Continue</a> <a class="btn btn-primary" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID">Review locations</a>
</div> </div>
</div> </div>
</section> </section>

View File

@ -18,8 +18,12 @@
<strong>@(Model.CharacterStageComplete ? $"Done: {Model.CharactersCreatedOrLinked:N0}" : "In review")</strong> <strong>@(Model.CharacterStageComplete ? $"Done: {Model.CharactersCreatedOrLinked:N0}" : "In review")</strong>
</div> </div>
<div> <div>
<span>Locations</span> <span>Locations identified</span>
<strong>Coming next</strong> <strong>@Model.LocationsIdentified.ToString("N0")</strong>
</div>
<div>
<span>Locations created / linked</span>
<strong>@(Model.LocationStageComplete ? $"Done: {Model.LocationsCreatedOrLinked:N0}" : "In review")</strong>
</div> </div>
<div> <div>
<span>Assets</span> <span>Assets</span>