Phase 20AN – Location Intelligence Review and Import
This commit is contained in:
parent
f4897e5d5f
commit
ef51765eb4
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>();
|
||||||
|
|||||||
@ -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";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
696
PlotLine/Services/StoryIntelligenceLocationImportService.cs
Normal file
696
PlotLine/Services/StoryIntelligenceLocationImportService.cs
Normal 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);
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
34
PlotLine/Sql/129_Phase20AN_LocationReviewPipelineStages.sql
Normal file
34
PlotLine/Sql/129_Phase20AN_LocationReviewPipelineStages.sql
Normal 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
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user