Add core import basic location review stage

This commit is contained in:
Nick Beckley 2026-08-31 16:05:21 +00:00
parent bb62762b78
commit 1cf0174466
14 changed files with 1719 additions and 12 deletions

View File

@ -115,6 +115,11 @@ var tests = new (string Name, Action Test)[]
("Structural import persists canonical scene source text", StructuralImportPersistsCanonicalSceneSourceText),
("Structural import adds summaries without extra scene AI calls", StructuralImportAddsSummariesWithoutExtraSceneAiCalls),
("Core import scan stage defers scenes and characters", CoreImportScanStageDefersScenesAndCharacters),
("Core import basic location detector is conservative", CoreImportBasicLocationDetectorIsConservative),
("Core import basic location detector consolidates and skips existing matches", CoreImportBasicLocationDetectorConsolidatesAndSkipsExistingMatches),
("Core import basic location detector supports pending parent candidates", CoreImportBasicLocationDetectorSupportsPendingParentCandidates),
("Core import character discovery protects accepted locations", CoreImportCharacterDiscoveryProtectsAcceptedLocations),
("Core import gates characters behind basic location review", CoreImportGatesCharactersBehindBasicLocationReview),
("Core import character evidence is candidate specific", CoreImportCharacterEvidenceIsCandidateSpecific),
("Core import deterministically maps known characters", CoreImportDeterministicallyMapsKnownCharacters),
("Core import wizard orders scenes before characters", CoreImportWizardOrdersScenesBeforeCharacters),
@ -3696,8 +3701,11 @@ static void StructuralImportPersistsCanonicalSceneSourceText()
Assert(migration.Contains("SceneID int NOT NULL CONSTRAINT PK_SceneManuscriptSources PRIMARY KEY", StringComparison.Ordinal), "Scene source text should be keyed by SceneID.");
Assert(migration.Contains("IX_SceneManuscriptSources_BookChapter", StringComparison.Ordinal), "Scene source text should be indexed for book/chapter retrieval.");
Assert(onboarding.Contains("SaveSceneManuscriptSourcesAsync", StringComparison.Ordinal), "Structural import should persist scene source text before Word sync.");
Assert(onboarding.IndexOf("await SaveSceneManuscriptSourcesAsync(preview, structuralScenes, result)", StringComparison.Ordinal)
< onboarding.IndexOf("await PublishBuildProgress(userId, preview.PreviewID, \"Updating Word document with scene markers", StringComparison.Ordinal), "Scene source text should be persisted before marker sync can complete.");
var saveSceneSourceIndex = onboarding.IndexOf("await SaveSceneManuscriptSourcesAsync(preview, structuralScenes, result)", StringComparison.Ordinal);
var locationReviewIndex = onboarding.IndexOf("coreImportBasicLocations.EnsurePreparedAsync", saveSceneSourceIndex, StringComparison.Ordinal);
var markerProgressIndex = onboarding.IndexOf("Updating Word document with scene markers", locationReviewIndex, StringComparison.Ordinal);
Assert(saveSceneSourceIndex >= 0 && saveSceneSourceIndex < locationReviewIndex, "Scene source text should be persisted before basic location review.");
Assert(locationReviewIndex >= 0 && locationReviewIndex < markerProgressIndex, "Basic location review should be prepared before marker sync can complete.");
Assert(analyzer.Contains("string.Join(Environment.NewLine + Environment.NewLine", StringComparison.Ordinal), "Scene source text should preserve paragraph separation.");
Assert(analyzer.Contains("item.IncludeInSourceText", StringComparison.Ordinal), "Scene source text should exclude non-prose structural paragraphs such as hidden markers/separators.");
}
@ -3749,13 +3757,97 @@ static void CoreImportScanStageDefersScenesAndCharacters()
Assert(scanReview.Contains("<h2>Chapters</h2>", StringComparison.Ordinal), "Review Chapters section heading should not mention scenes.");
Assert(index.Contains("data-onboarding-scan-scenes", StringComparison.Ordinal), "Scan page should display the Scenes counter.");
Assert(presence.Contains("Opening chapter review...", StringComparison.Ordinal), "Successful scan should show the immediate auto-navigation state.");
Assert(onboarding.Contains("DiscoverCoreImportCharacterCandidates(canonicalSceneSources)", StringComparison.Ordinal), "Character candidates should be generated after canonical scene sources exist.");
Assert(onboarding.Contains("EnsurePreparedAsync(", StringComparison.Ordinal), "Core import should prepare basic location review after canonical scene sources exist.");
Assert(onboarding.Contains("PrepareCharacterCandidatesAfterLocationReviewAsync", StringComparison.Ordinal), "Character candidates should be generated only after basic location review is complete or skipped.");
Assert(wordService.Contains("DiscoverCoreImportCharacterCandidates", StringComparison.Ordinal), "Post-scene character discovery should be explicit.");
Assert(wordService.Contains("var evidenceScenes = CandidateSceneEvidence", StringComparison.Ordinal), "Post-scene character candidates should calculate candidate-specific canonical scene evidence.");
Assert(wordService.Contains("EvidenceScenes = evidenceScenes", StringComparison.Ordinal), "Post-scene character candidates should carry candidate-specific canonical scene evidence.");
Assert(!wordService.Contains("Source SceneIDs", StringComparison.Ordinal), "Post-scene character candidates should not dump every canonical SceneID into every candidate reason.");
}
static void CoreImportBasicLocationDetectorIsConservative()
{
var sources = new List<SceneManuscriptSourceRecord>
{
new()
{
SceneID = 101,
ChapterID = 1,
SourceText = "Maggie sat in the bedroom, then walked into the kitchen. In 1983 Rubery Hill changed. Later she met Zoe at The Black Horse Inn on Station Road."
}
};
var result = CoreImportBasicLocationDetector.Discover(sources, []);
var names = result.Candidates.Select(candidate => candidate.ProposedName).ToList();
Assert(names.Contains("The Black Horse Inn"), "Named pub/inn should be detected.");
Assert(names.Contains("Station Road"), "Named road should be detected.");
Assert(!names.Contains("1983 Rubery Hill"), "Year-prefixed phrases should not become locations.");
Assert(!names.Any(name => string.Equals(name, "bedroom", StringComparison.OrdinalIgnoreCase)), "Generic bedrooms should be excluded.");
Assert(!names.Any(name => string.Equals(name, "kitchen", StringComparison.OrdinalIgnoreCase)), "Generic kitchens should be excluded.");
}
static void CoreImportBasicLocationDetectorConsolidatesAndSkipsExistingMatches()
{
var sources = new List<SceneManuscriptSourceRecord>
{
new() { SceneID = 101, ChapterID = 1, SourceText = "They crossed Station Road and stopped outside Maggie's flat." },
new() { SceneID = 102, ChapterID = 1, SourceText = "Maggie's flat looked over Station Road." },
new() { SceneID = 201, ChapterID = 2, SourceText = "They returned to Station Road before dawn." }
};
var result = CoreImportBasicLocationDetector.Discover(sources, [new CoreImportExistingLocationName(55, "Station Road")]);
var names = result.Candidates.Select(candidate => candidate.ProposedName).ToList();
Assert(!names.Contains("Station Road"), "Exact existing canonical location matches should not become pointless review rows.");
var flat = result.Candidates.Single(candidate => candidate.ProposedName == "Maggie's flat");
Assert(flat.ObservationCount == 2, "Repeated residence observations should consolidate into one candidate.");
}
static void CoreImportBasicLocationDetectorSupportsPendingParentCandidates()
{
var sources = new List<SceneManuscriptSourceRecord>
{
new() { SceneID = 101, ChapterID = 1, SourceText = "Rose Cottage on Station Road had a yellow door. They later crossed Station Road." }
};
var result = CoreImportBasicLocationDetector.Discover(sources, []);
var cottage = result.Candidates.Single(candidate => candidate.ProposedName == "Rose Cottage");
Assert(result.Candidates.Any(candidate => candidate.ProposedName == "Station Road"), "The parent candidate should be discoverable in the same pass.");
Assert(cottage.ParentCandidateKey == CoreImportBasicLocationDetector.StableKey("Station Road"), "Pending parent candidates should be captured for materialisation order.");
}
static void CoreImportCharacterDiscoveryProtectsAcceptedLocations()
{
var sources = new List<SceneManuscriptSourceRecord>
{
new() { SceneID = 101, ChapterID = 1, SourceText = "Maggie walked along Station Road. Maggie waved at Beth outside The Black Horse Inn." },
new() { SceneID = 102, ChapterID = 1, SourceText = "Beth told Maggie that Station Road was quiet." }
};
var candidates = WordCompanionService.DiscoverCoreImportCharacterCandidates(sources, ["Station Road", "The Black Horse Inn"]);
var names = candidates.Select(candidate => candidate.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
Assert(names.Contains("Maggie"), "Real character names should survive location protection.");
Assert(names.Contains("Beth"), "Other real character names should survive location protection.");
Assert(!names.Contains("Station Road"), "Accepted locations should not appear as character candidates.");
Assert(!names.Contains("The Black Horse Inn"), "Accepted location aliases should not appear as character candidates.");
}
static void CoreImportGatesCharactersBehindBasicLocationReview()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs"));
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
var sql = File.ReadAllText(Path.Combine(root, "Sql/181_Phase27H_CoreImportBasicLocationReview.sql"));
Assert(onboarding.Contains("Review or skip basic locations before reviewing characters.", StringComparison.Ordinal), "Onboarding service should block character finalisation while basic locations are pending.");
Assert(controller.Contains("RedirectToAction(nameof(ScanLocations)", StringComparison.Ordinal), "Onboarding controller should route pending imports to the location review page.");
Assert(sql.Contains("CoreImportBasicLocationReviews", StringComparison.Ordinal), "Location review state should be persisted in its own core import tables.");
Assert(sql.Contains("CoreImportBasicLocationReview_MarkSkipped", StringComparison.Ordinal), "Basic location review should support explicit skip.");
}
static void CoreImportCharacterEvidenceIsCandidateSpecific()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");

View File

@ -74,11 +74,75 @@ public sealed class OnboardingController(
return RedirectToAction(nameof(Index));
}
if (!await onboarding.IsBasicLocationReviewCompleteAsync(model.PreviewID))
{
TempData["OnboardingReviewMessage"] = "Review or skip basic locations before reviewing characters.";
return RedirectToAction(nameof(ScanLocations), new { previewId = model.PreviewID });
}
await onboarding.EnsureCharacterCandidatesAfterLocationReviewAsync(model.PreviewID);
model = await onboarding.GetScanReviewAsync(model.PreviewID);
if (model is null)
{
return NotFound();
}
ViewData["ScanReviewStep"] = "characters";
ViewData["CharacterReviewPage"] = Math.Max(1, page);
return View("ScanReview", model);
}
[HttpGet("scan-locations")]
public async Task<IActionResult> ScanLocations(Guid previewId)
{
var model = await onboarding.GetBasicLocationReviewAsync(previewId);
if (model is null)
{
TempData["ArchiveError"] = "Import the approved chapters and scenes before reviewing locations.";
await onboarding.SetCurrentStepAsync(OnboardingSteps.NextPathPreview);
return RedirectToAction(nameof(Index));
}
if (model.IsComplete)
{
return RedirectToAction(nameof(ScanCharacters), new { previewId });
}
return View(model);
}
[HttpPost("scan-locations")]
[RequestFormLimits(ValueCountLimit = 20000, KeyLengthLimit = 4096, ValueLengthLimit = 1024 * 1024)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SaveBasicLocations(CoreImportBasicLocationReviewForm form, string intent = "save")
{
try
{
if (string.Equals(intent, "skip", StringComparison.OrdinalIgnoreCase))
{
await onboarding.SkipBasicLocationReviewAsync(form.PreviewID);
TempData["OnboardingReviewMessage"] = "Basic location review skipped. Next: review detected characters.";
return RedirectToAction(nameof(ScanCharacters), new { previewId = form.PreviewID });
}
if (string.Equals(intent, "continue", StringComparison.OrdinalIgnoreCase))
{
await onboarding.CompleteBasicLocationReviewAsync(form);
TempData["OnboardingReviewMessage"] = "Locations saved. Next: review detected characters.";
return RedirectToAction(nameof(ScanCharacters), new { previewId = form.PreviewID });
}
await onboarding.SaveBasicLocationReviewAsync(form);
TempData["OnboardingReviewMessage"] = "Location review choices saved.";
return RedirectToAction(nameof(ScanLocations), new { previewId = form.PreviewID });
}
catch (InvalidOperationException ex)
{
TempData["ArchiveError"] = ex.Message;
return RedirectToAction(nameof(ScanLocations), new { previewId = form.PreviewID });
}
}
[HttpGet("build-complete")]
public async Task<IActionResult> BuildComplete(Guid previewId)
{
@ -684,6 +748,11 @@ public sealed class OnboardingController(
await onboarding.SetCurrentStepAsync(OnboardingSteps.NextPathPreview);
return RedirectToAction(nameof(Index));
}
if (!await onboarding.IsBasicLocationReviewCompleteAsync(form.PreviewID))
{
TempData["OnboardingReviewMessage"] = "Review or skip basic locations before reviewing characters.";
return RedirectToAction(nameof(ScanLocations), new { previewId = form.PreviewID });
}
}
TempData["OnboardingReviewMessage"] = readyToImport
@ -694,7 +763,7 @@ public sealed class OnboardingController(
return readyToImport
? RedirectToAction(nameof(Index))
: continueCharacterReview || backCharacterReview ? RedirectToAction(nameof(ScanCharacters), new { previewId = form.PreviewID, page = nextCharacterPage })
: continueToCharacters ? RedirectToAction(nameof(ScanCharacters), new { previewId = form.PreviewID })
: continueToCharacters ? RedirectToAction(nameof(ScanLocations), new { previewId = form.PreviewID })
: RedirectToAction(nameof(ScanReview), new { previewId = form.PreviewID });
}
catch (InvalidOperationException ex)

View File

@ -0,0 +1,160 @@
using System.Data;
using Dapper;
using PlotLine.Models;
namespace PlotLine.Data;
public interface ICoreImportBasicLocationReviewRepository
{
Task<CoreImportBasicLocationReview?> GetByPreviewAsync(int userId, Guid previewId);
Task<CoreImportBasicLocationReview> CreateAsync(int userId, Guid previewId, int projectId, int bookId, int? buildId);
Task<CoreImportBasicLocationCandidate> SaveCandidateAsync(int reviewId, CoreImportBasicLocationCandidate candidate);
Task DeleteEvidenceAsync(int candidateId);
Task SaveEvidenceAsync(int candidateId, CoreImportBasicLocationEvidence evidence);
Task CompletePreparationAsync(int reviewId, int rawObservationCount, int candidateCount);
Task<(IReadOnlyList<CoreImportBasicLocationCandidate> Candidates, IReadOnlyList<CoreImportBasicLocationEvidence> Evidence)> ListCandidatesAsync(int reviewId);
Task SaveDecisionAsync(int reviewId, CoreImportBasicLocationDecision decision);
Task SetResultAsync(int reviewId, string stableKey, int resultLocationId);
Task MarkCompleteAsync(int reviewId);
Task MarkSkippedAsync(int reviewId);
}
public sealed class CoreImportBasicLocationReviewRepository(ISqlConnectionFactory connectionFactory) : ICoreImportBasicLocationReviewRepository
{
public async Task<CoreImportBasicLocationReview?> GetByPreviewAsync(int userId, Guid previewId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<CoreImportBasicLocationReview>(
"dbo.CoreImportBasicLocationReview_GetByPreview",
new { UserID = userId, PreviewID = previewId },
commandType: CommandType.StoredProcedure);
}
public async Task<CoreImportBasicLocationReview> CreateAsync(int userId, Guid previewId, int projectId, int bookId, int? buildId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<CoreImportBasicLocationReview>(
"dbo.CoreImportBasicLocationReview_Create",
new { UserID = userId, PreviewID = previewId, ProjectID = projectId, BookID = bookId, BuildID = buildId },
commandType: CommandType.StoredProcedure);
}
public async Task<CoreImportBasicLocationCandidate> SaveCandidateAsync(int reviewId, CoreImportBasicLocationCandidate candidate)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<CoreImportBasicLocationCandidate>(
"dbo.CoreImportBasicLocationReview_SaveCandidate",
new
{
CoreImportBasicLocationReviewID = reviewId,
candidate.StableKey,
candidate.ProposedName,
candidate.PreferredName,
candidate.KnownNamesJson,
candidate.ParentLocationID,
candidate.ParentCandidateKey,
candidate.ExistingLocationID,
candidate.FirstChapterNumber,
candidate.FirstSceneNumber,
candidate.FirstOccurrenceIndex,
candidate.ObservationCount,
candidate.SortOrder
},
commandType: CommandType.StoredProcedure);
}
public async Task DeleteEvidenceAsync(int candidateId)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.CoreImportBasicLocationReview_DeleteEvidence",
new { CoreImportBasicLocationCandidateID = candidateId },
commandType: CommandType.StoredProcedure);
}
public async Task SaveEvidenceAsync(int candidateId, CoreImportBasicLocationEvidence evidence)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.CoreImportBasicLocationReview_SaveEvidence",
new
{
CoreImportBasicLocationCandidateID = candidateId,
evidence.SceneID,
evidence.ChapterID,
evidence.ChapterNumber,
evidence.SceneNumber,
evidence.OccurrenceIndex,
evidence.EvidenceText,
evidence.SortOrder
},
commandType: CommandType.StoredProcedure);
}
public async Task CompletePreparationAsync(int reviewId, int rawObservationCount, int candidateCount)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.CoreImportBasicLocationReview_CompletePreparation",
new { CoreImportBasicLocationReviewID = reviewId, RawObservationCount = rawObservationCount, CandidateCount = candidateCount },
commandType: CommandType.StoredProcedure);
}
public async Task<(IReadOnlyList<CoreImportBasicLocationCandidate> Candidates, IReadOnlyList<CoreImportBasicLocationEvidence> Evidence)> ListCandidatesAsync(int reviewId)
{
using var connection = connectionFactory.CreateConnection();
using var rows = await connection.QueryMultipleAsync(
"dbo.CoreImportBasicLocationReview_ListCandidates",
new { CoreImportBasicLocationReviewID = reviewId },
commandType: CommandType.StoredProcedure);
var candidates = (await rows.ReadAsync<CoreImportBasicLocationCandidate>()).ToList();
var evidence = (await rows.ReadAsync<CoreImportBasicLocationEvidence>()).ToList();
return (candidates, evidence);
}
public async Task SaveDecisionAsync(int reviewId, CoreImportBasicLocationDecision decision)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.CoreImportBasicLocationReview_SaveDecision",
new
{
CoreImportBasicLocationReviewID = reviewId,
decision.StableKey,
decision.PreferredName,
decision.Action,
decision.ParentLocationID,
decision.ParentCandidateKey,
decision.SameAsLocationID,
decision.SameAsCandidateKey
},
commandType: CommandType.StoredProcedure);
}
public async Task SetResultAsync(int reviewId, string stableKey, int resultLocationId)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.CoreImportBasicLocationReview_SetResult",
new { CoreImportBasicLocationReviewID = reviewId, StableKey = stableKey, ResultLocationID = resultLocationId },
commandType: CommandType.StoredProcedure);
}
public async Task MarkCompleteAsync(int reviewId)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.CoreImportBasicLocationReview_MarkComplete",
new { CoreImportBasicLocationReviewID = reviewId },
commandType: CommandType.StoredProcedure);
}
public async Task MarkSkippedAsync(int reviewId)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.CoreImportBasicLocationReview_MarkSkipped",
new { CoreImportBasicLocationReviewID = reviewId },
commandType: CommandType.StoredProcedure);
}
}

View File

@ -265,6 +265,7 @@ public interface ILocationRepository
Task<LocationItem?> GetAsync(int locationId);
Task<int> SaveAsync(LocationItem location);
Task<IReadOnlyList<LocationAlias>> ListAliasesAsync(int locationId);
Task<IReadOnlyList<LocationAlias>> ListAliasesByProjectAsync(int projectId);
Task<int> AddAliasAsync(int locationId, string alias, int? sortOrder);
Task UpdateAliasAsync(int locationAliasId, string alias, int? sortOrder);
Task DeleteAliasAsync(int locationAliasId);
@ -539,6 +540,16 @@ public sealed class LocationRepository(ISqlConnectionFactory connectionFactory)
return rows.ToList();
}
public async Task<IReadOnlyList<LocationAlias>> ListAliasesByProjectAsync(int projectId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<LocationAlias>(
"dbo.LocationAlias_ListByProject",
new { ProjectID = projectId },
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<int> AddAliasAsync(int locationId, string alias, int? sortOrder)
{
using var connection = connectionFactory.CreateConnection();

View File

@ -148,6 +148,92 @@ public sealed class ManuscriptScanCharacterEvidenceScene
public string? StructuralSummary { get; init; }
}
public static class CoreImportBasicLocationReviewStatuses
{
public const string Pending = "Pending";
public const string Completed = "Completed";
public const string Skipped = "Skipped";
}
public static class CoreImportBasicLocationCandidateActions
{
public const string Create = "Create";
public const string SameLocation = "SameLocation";
public const string Ignore = "Ignore";
}
public sealed class CoreImportBasicLocationReview
{
public int CoreImportBasicLocationReviewID { get; set; }
public Guid PreviewID { get; set; }
public int UserID { get; set; }
public int ProjectID { get; set; }
public int BookID { get; set; }
public int? BuildID { get; set; }
public string Status { get; set; } = CoreImportBasicLocationReviewStatuses.Pending;
public int RawObservationCount { get; set; }
public int CandidateCount { get; set; }
public DateTime CreatedUtc { get; set; }
public DateTime UpdatedUtc { get; set; }
public DateTime? CompletedUtc { get; set; }
}
public sealed class CoreImportBasicLocationCandidate
{
public int CoreImportBasicLocationCandidateID { get; set; }
public int CoreImportBasicLocationReviewID { get; set; }
public string StableKey { get; set; } = string.Empty;
public string ProposedName { get; set; } = string.Empty;
public string PreferredName { get; set; } = string.Empty;
public string KnownNamesJson { get; set; } = "[]";
public int? ParentLocationID { get; set; }
public string? ParentCandidateKey { get; set; }
public int? ExistingLocationID { get; set; }
public string Action { get; set; } = CoreImportBasicLocationCandidateActions.Create;
public int? SameAsLocationID { get; set; }
public string? SameAsCandidateKey { get; set; }
public int? ResultLocationID { get; set; }
public int FirstChapterNumber { get; set; }
public int FirstSceneNumber { get; set; }
public int FirstOccurrenceIndex { get; set; }
public int ObservationCount { get; set; }
public int SortOrder { get; set; }
public DateTime CreatedUtc { get; set; }
public DateTime UpdatedUtc { get; set; }
public IReadOnlyList<CoreImportBasicLocationEvidence> Evidence { get; set; } = [];
}
public sealed class CoreImportBasicLocationEvidence
{
public int CoreImportBasicLocationEvidenceID { get; set; }
public int CoreImportBasicLocationCandidateID { get; set; }
public int SceneID { get; set; }
public int ChapterID { get; set; }
public int ChapterNumber { get; set; }
public int SceneNumber { get; set; }
public int OccurrenceIndex { get; set; }
public string EvidenceText { get; set; } = string.Empty;
public int SortOrder { get; set; }
}
public sealed class CoreImportBasicLocationDecision
{
public string StableKey { get; set; } = string.Empty;
public string PreferredName { get; set; } = string.Empty;
public string Action { get; set; } = CoreImportBasicLocationCandidateActions.Create;
public int? ParentLocationID { get; set; }
public string? ParentCandidateKey { get; set; }
public int? SameAsLocationID { get; set; }
public string? SameAsCandidateKey { get; set; }
}
public sealed class CoreImportBasicLocationDiscoveryResult
{
public int RawObservationCount { get; init; }
public int ExcludedObservationCount { get; init; }
public IReadOnlyList<CoreImportBasicLocationCandidate> Candidates { get; init; } = [];
}
public sealed class ManuscriptScanState
{
public Guid? ScanID { get; init; }

View File

@ -146,6 +146,7 @@ public class Program
builder.Services.AddScoped<IWordCompanionRepository, WordCompanionRepository>();
builder.Services.AddScoped<IOnboardingRepository, OnboardingRepository>();
builder.Services.AddScoped<IOnboardingBuildRepository, OnboardingBuildRepository>();
builder.Services.AddScoped<ICoreImportBasicLocationReviewRepository, CoreImportBasicLocationReviewRepository>();
builder.Services.AddScoped<IManuscriptTextStatisticsRepository, ManuscriptTextStatisticsRepository>();
builder.Services.AddScoped<IStoryIntelligenceRepository, StoryIntelligenceRepository>();
builder.Services.AddScoped<IStoryIntelligenceResultRepository, StoryIntelligenceResultRepository>();
@ -222,6 +223,7 @@ public class Program
builder.Services.AddScoped<IManuscriptStructureAnalysisService, ManuscriptStructureAnalysisService>();
builder.Services.AddScoped<IManuscriptTextStatisticsService, ManuscriptTextStatisticsService>();
builder.Services.AddScoped<ICoreImportCharacterMappingService, CoreImportCharacterMappingService>();
builder.Services.AddScoped<ICoreImportBasicLocationService, CoreImportBasicLocationService>();
builder.Services.AddScoped<IStoryIntelligenceResultPersistenceService, StoryIntelligenceResultPersistenceService>();
builder.Services.AddScoped<IStoryIntelligenceExistingChapterQueueService, StoryIntelligenceExistingChapterQueueService>();
builder.Services.AddScoped<IStoryIntelligenceFullChapterTestService, StoryIntelligenceFullChapterTestService>();

View File

@ -0,0 +1,291 @@
using System.Globalization;
using System.Text.Json;
using System.Text.RegularExpressions;
using PlotLine.Data;
using PlotLine.Models;
namespace PlotLine.Services;
public sealed record CoreImportExistingLocationName(int LocationID, string Name);
public static partial class CoreImportBasicLocationDetector
{
private static readonly HashSet<string> ExplicitExcludedNames = new(StringComparer.OrdinalIgnoreCase)
{
"bar area", "bathroom", "bedroom", "corridor", "corner", "doorway", "garden",
"hall", "hallway", "inside", "kitchen", "office", "outside", "room", "stairs",
"toilet", "upstairs", "downstairs", "waiting room", "ward"
};
private static readonly HashSet<string> DirectionalStarts = new(StringComparer.OrdinalIgnoreCase)
{
"behind", "beside", "inside", "outside", "near", "under", "upstairs", "downstairs"
};
public static CoreImportBasicLocationDiscoveryResult Discover(
IReadOnlyList<SceneManuscriptSourceRecord> sourceScenes,
IReadOnlyList<CoreImportExistingLocationName> existingNames)
{
var existingIndex = existingNames
.Select(item => new { Key = StableKey(item.Name), item.LocationID })
.Where(item => !string.IsNullOrWhiteSpace(item.Key))
.GroupBy(item => item.Key, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.First().LocationID, StringComparer.Ordinal);
var orderedScenes = sourceScenes
.OrderBy(scene => scene.ChapterID)
.ThenBy(scene => scene.SourceStartParagraph ?? int.MaxValue)
.ThenBy(scene => scene.SceneID)
.ToList();
var chapterNumbers = orderedScenes
.GroupBy(scene => scene.ChapterID)
.Select((group, index) => new { group.Key, ChapterNumber = index + 1 })
.ToDictionary(item => item.Key, item => item.ChapterNumber);
var sceneNumbers = orderedScenes
.GroupBy(scene => scene.ChapterID)
.SelectMany(group => group.Select((scene, index) => new { scene.SceneID, SceneNumber = index + 1 }))
.ToDictionary(item => item.SceneID, item => item.SceneNumber);
var observations = new List<LocationObservation>();
var excluded = 0;
foreach (var scene in orderedScenes)
{
if (string.IsNullOrWhiteSpace(scene.SourceText))
{
continue;
}
var text = scene.SourceText;
foreach (var observation in ExtractObservations(text, scene, chapterNumbers.GetValueOrDefault(scene.ChapterID), sceneNumbers.GetValueOrDefault(scene.SceneID)))
{
if (IsExcluded(observation.Name))
{
excluded++;
continue;
}
observations.Add(observation);
}
}
var grouped = observations
.GroupBy(item => StableKey(item.Name), StringComparer.Ordinal)
.Where(group => !string.IsNullOrWhiteSpace(group.Key))
.Select(group => BuildCandidate(group.Key, group.ToList(), existingIndex))
.Where(candidate => !candidate.ExistingLocationID.HasValue)
.OrderBy(candidate => candidate.FirstChapterNumber)
.ThenBy(candidate => candidate.FirstSceneNumber)
.ThenBy(candidate => candidate.FirstOccurrenceIndex)
.Select((candidate, index) =>
{
candidate.SortOrder = (index + 1) * 10;
return candidate;
})
.ToList();
var keys = grouped.Select(candidate => candidate.StableKey).ToHashSet(StringComparer.Ordinal);
foreach (var candidate in grouped)
{
if (IsLinearLocation(candidate.ProposedName))
{
continue;
}
var parentKey = candidate.Evidence
.Select(evidence => InferParentKey(evidence.EvidenceText, candidate.ProposedName))
.FirstOrDefault(key => !string.IsNullOrWhiteSpace(key) && keys.Contains(key));
if (!string.IsNullOrWhiteSpace(parentKey) && !string.Equals(parentKey, candidate.StableKey, StringComparison.Ordinal))
{
candidate.ParentCandidateKey = parentKey;
}
}
return new CoreImportBasicLocationDiscoveryResult
{
RawObservationCount = observations.Count,
ExcludedObservationCount = excluded,
Candidates = grouped
};
}
public static string StableKey(string? name)
{
var normalized = NormalizeName(name);
if (string.IsNullOrWhiteSpace(normalized))
{
return string.Empty;
}
normalized = Regex.Replace(normalized, @"^the\s+", string.Empty, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
normalized = Regex.Replace(normalized, @"[^\p{L}\p{N}]+", "-", RegexOptions.CultureInvariant).Trim('-').ToLowerInvariant();
return normalized;
}
public static string NormalizeName(string? name)
{
if (string.IsNullOrWhiteSpace(name))
{
return string.Empty;
}
var cleaned = name.Replace('’', '\'').Replace('–', '-').Replace('—', '-');
cleaned = Regex.Replace(cleaned, @"\s+", " ", RegexOptions.CultureInvariant).Trim();
cleaned = cleaned.Trim(' ', '.', ',', ';', ':', '!', '?', '"', '\'');
cleaned = Regex.Replace(cleaned, @"^(?:at|in|into|inside|outside|to|from|near|beside|behind|on)\s+", string.Empty, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
return cleaned.Trim();
}
private static IEnumerable<LocationObservation> ExtractObservations(
string text,
SceneManuscriptSourceRecord scene,
int chapterNumber,
int sceneNumber)
{
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var regex in DetectionRegexes())
{
foreach (Match match in regex.Matches(text))
{
var value = NormalizeName(match.Groups["name"].Success ? match.Groups["name"].Value : match.Value);
var key = $"{match.Index}:{StableKey(value)}";
if (string.IsNullOrWhiteSpace(value) || !seen.Add(key))
{
continue;
}
yield return new LocationObservation(
value,
scene.SceneID,
scene.ChapterID,
chapterNumber,
sceneNumber,
match.Index,
EvidenceWindow(text, match.Index, match.Length));
}
}
}
private static CoreImportBasicLocationCandidate BuildCandidate(
string key,
IReadOnlyList<LocationObservation> observations,
IReadOnlyDictionary<string, int> existingIndex)
{
var ordered = observations
.OrderBy(item => item.ChapterNumber)
.ThenBy(item => item.SceneNumber)
.ThenBy(item => item.OccurrenceIndex)
.ToList();
var names = ordered
.Select(item => NormalizeName(item.Name))
.Where(name => !string.IsNullOrWhiteSpace(name))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderByDescending(name => ordered.Count(item => string.Equals(NormalizeName(item.Name), name, StringComparison.OrdinalIgnoreCase)))
.ThenBy(name => name, StringComparer.OrdinalIgnoreCase)
.ToList();
var preferred = names.FirstOrDefault() ?? ordered[0].Name;
var candidate = new CoreImportBasicLocationCandidate
{
StableKey = key,
ProposedName = preferred,
PreferredName = preferred,
KnownNamesJson = JsonSerializer.Serialize(names),
ExistingLocationID = existingIndex.GetValueOrDefault(key) == 0 ? null : existingIndex[key],
FirstChapterNumber = ordered[0].ChapterNumber,
FirstSceneNumber = ordered[0].SceneNumber,
FirstOccurrenceIndex = ordered[0].OccurrenceIndex,
ObservationCount = ordered.Count,
Evidence = ordered
.Take(5)
.Select((item, index) => new CoreImportBasicLocationEvidence
{
SceneID = item.SceneID,
ChapterID = item.ChapterID,
ChapterNumber = item.ChapterNumber,
SceneNumber = item.SceneNumber,
OccurrenceIndex = item.OccurrenceIndex,
EvidenceText = item.EvidenceText,
SortOrder = (index + 1) * 10
})
.ToList()
};
return candidate;
}
private static bool IsExcluded(string name)
{
var normalized = NormalizeName(name);
var key = StableKey(normalized).Replace('-', ' ');
if (string.IsNullOrWhiteSpace(key) || ExplicitExcludedNames.Contains(key))
{
return true;
}
var first = key.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
if (first is not null && DirectionalStarts.Contains(first))
{
return true;
}
if (Regex.IsMatch(normalized, @"^(?:1[5-9]\d{2}|20\d{2}|21\d{2})\s+", RegexOptions.CultureInvariant))
{
return true;
}
return key.Length < 3 || Regex.IsMatch(normalized, @"^(?:Chapter|Scene)\s+\d+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
}
private static bool IsLinearLocation(string name)
=> Regex.IsMatch(name, @"\b(?:Road|Street|Lane|Avenue|Drive|Way|Close|Crescent|Square|Place|Hill|Hills)\b$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
private static string? InferParentKey(string evidenceText, string childName)
{
var child = Regex.Escape(childName);
var road = @"(?<parent>(?:The\s+)?[A-Z][\p{L}'-]+(?:\s+[A-Z][\p{L}'-]+){0,4}\s+(?:Road|Street|Lane|Avenue|Drive|Way|Close|Crescent|Square|Place|Hill|Hills))";
var pattern = $@"{child}.{{0,60}}\b(?:on|in|at)\s+{road}|{road}.{{0,60}}{child}";
var match = Regex.Match(evidenceText, pattern, RegexOptions.CultureInvariant);
return match.Success ? StableKey(match.Groups["parent"].Value) : null;
}
private static string EvidenceWindow(string text, int start, int length)
{
var left = Math.Max(0, start - 100);
var right = Math.Min(text.Length, start + length + 140);
var snippet = text[left..right];
snippet = Regex.Replace(snippet, @"\s+", " ", RegexOptions.CultureInvariant).Trim();
return snippet.Length <= 360 ? snippet : snippet[..360].Trim();
}
private static IReadOnlyList<Regex> DetectionRegexes()
=>
[
AddressRegex(),
PossessiveResidenceRegex(),
NamedRoadRegex(),
NamedVenueRegex(),
ThePlaceRegex()
];
[GeneratedRegex(@"\b(?<name>\d+\s+(?:The\s+)?[A-Z][\p{L}'-]+(?:\s+[A-Z][\p{L}'-]+){0,4}\s+(?:Road|Street|Lane|Avenue|Drive|Way|Close|Crescent|Square|Place|Hill|Hills))\b", RegexOptions.CultureInvariant)]
private static partial Regex AddressRegex();
[GeneratedRegex(@"\b(?<name>[A-Z][\p{L}'-]+(?:\s+[A-Z][\p{L}'-]+){0,2}['’]s\s+(?:house|home|flat|apartment|cottage))\b", RegexOptions.CultureInvariant)]
private static partial Regex PossessiveResidenceRegex();
[GeneratedRegex(@"\b(?<name>(?:The\s+)?[A-Z][\p{L}'-]+(?:\s+[A-Z][\p{L}'-]+){0,4}\s+(?:Road|Street|Lane|Avenue|Drive|Way|Close|Crescent|Square|Place|Hill|Hills))\b", RegexOptions.CultureInvariant)]
private static partial Regex NamedRoadRegex();
[GeneratedRegex(@"\b(?<name>(?:The\s+)?[A-Z][\p{L}'-]+(?:\s+[A-Z][\p{L}'-]+){0,4}\s+(?:Inn|Pub|Hotel|School|Hospital|Station|Park|Church|Cathedral|University|College|Library|Shop|Store|Cafe|Café|Restaurant|Club|Arcade|Centre|Center|House|Cottage|Farm|Hall|Manor|Estate|Gardens|Common|Bridge|Junction|Airport|Museum|Theatre|Theater))\b", RegexOptions.CultureInvariant)]
private static partial Regex NamedVenueRegex();
[GeneratedRegex(@"\b(?<name>The\s+[A-Z][\p{L}'-]+(?:ies|s))\b", RegexOptions.CultureInvariant)]
private static partial Regex ThePlaceRegex();
private sealed record LocationObservation(
string Name,
int SceneID,
int ChapterID,
int ChapterNumber,
int SceneNumber,
int OccurrenceIndex,
string EvidenceText);
}

View File

@ -0,0 +1,310 @@
using System.Text.Json;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface ICoreImportBasicLocationService
{
Task<CoreImportBasicLocationReview?> EnsurePreparedAsync(int userId, Guid previewId, int projectId, int bookId, int? buildId, IReadOnlyList<SceneManuscriptSourceRecord> sceneSources);
Task<CoreImportBasicLocationReviewViewModel?> GetReviewAsync(int userId, Guid previewId, string documentTitle, string projectName, string bookTitle);
Task<bool> IsCompleteAsync(int userId, Guid previewId);
Task SaveDecisionsAsync(int userId, CoreImportBasicLocationReviewForm form);
Task CompleteAsync(int userId, Guid previewId);
Task SkipAsync(int userId, Guid previewId);
Task<IReadOnlySet<string>> GetProtectedLocationNamesAsync(int userId, Guid previewId);
}
public sealed class CoreImportBasicLocationService(
ICoreImportBasicLocationReviewRepository reviews,
ILocationRepository locations) : ICoreImportBasicLocationService
{
public async Task<CoreImportBasicLocationReview?> EnsurePreparedAsync(
int userId,
Guid previewId,
int projectId,
int bookId,
int? buildId,
IReadOnlyList<SceneManuscriptSourceRecord> sceneSources)
{
var review = await reviews.CreateAsync(userId, previewId, projectId, bookId, buildId);
var existing = await reviews.ListCandidatesAsync(review.CoreImportBasicLocationReviewID);
if (existing.Candidates.Count > 0 || IsComplete(review.Status))
{
return review;
}
var existingLocations = await locations.ListByProjectAsync(projectId);
var existingAliases = await locations.ListAliasesByProjectAsync(projectId);
var existingNames = existingLocations
.Select(location => new CoreImportExistingLocationName(location.LocationID, location.LocationName))
.Concat(existingAliases.Select(alias => new CoreImportExistingLocationName(alias.LocationID, alias.Alias)))
.ToList();
var discovery = CoreImportBasicLocationDetector.Discover(sceneSources, existingNames);
foreach (var candidate in discovery.Candidates)
{
var saved = await reviews.SaveCandidateAsync(review.CoreImportBasicLocationReviewID, candidate);
await reviews.DeleteEvidenceAsync(saved.CoreImportBasicLocationCandidateID);
foreach (var evidence in candidate.Evidence)
{
await reviews.SaveEvidenceAsync(saved.CoreImportBasicLocationCandidateID, evidence);
}
}
await reviews.CompletePreparationAsync(review.CoreImportBasicLocationReviewID, discovery.RawObservationCount, discovery.Candidates.Count);
return await reviews.GetByPreviewAsync(userId, previewId);
}
public async Task<CoreImportBasicLocationReviewViewModel?> GetReviewAsync(int userId, Guid previewId, string documentTitle, string projectName, string bookTitle)
{
var review = await reviews.GetByPreviewAsync(userId, previewId);
if (review is null)
{
return null;
}
var (candidates, evidenceRows) = await reviews.ListCandidatesAsync(review.CoreImportBasicLocationReviewID);
var evidenceLookup = evidenceRows
.GroupBy(evidence => evidence.CoreImportBasicLocationCandidateID)
.ToDictionary(group => group.Key, group => group.OrderBy(item => item.SortOrder).ToList());
var existingLocations = await locations.ListByProjectAsync(review.ProjectID);
return new CoreImportBasicLocationReviewViewModel
{
PreviewID = previewId,
ReviewID = review.CoreImportBasicLocationReviewID,
Status = review.Status,
DocumentTitle = documentTitle,
SelectedProjectName = projectName,
SelectedBookTitle = bookTitle,
RawObservationCount = review.RawObservationCount,
CandidateCount = review.CandidateCount,
ExistingLocationOptions = existingLocations
.OrderBy(location => location.LocationPath)
.ThenBy(location => location.LocationName)
.Select(location => new CoreImportLocationOptionViewModel
{
LocationID = location.LocationID,
LocationName = location.LocationName,
LocationPath = location.LocationPath
})
.ToList(),
Candidates = candidates.Select(candidate => new CoreImportBasicLocationCandidateViewModel
{
StableKey = candidate.StableKey,
ProposedName = candidate.ProposedName,
PreferredName = candidate.PreferredName,
KnownNames = ReadKnownNames(candidate),
Action = candidate.Action,
ParentLocationID = candidate.ParentLocationID,
ParentCandidateKey = candidate.ParentCandidateKey,
SameAsLocationID = candidate.SameAsLocationID,
SameAsCandidateKey = candidate.SameAsCandidateKey,
ObservationCount = candidate.ObservationCount,
FirstChapterNumber = candidate.FirstChapterNumber,
FirstSceneNumber = candidate.FirstSceneNumber,
Evidence = evidenceLookup.GetValueOrDefault(candidate.CoreImportBasicLocationCandidateID, [])
.Select(evidence => new CoreImportBasicLocationEvidenceViewModel
{
ChapterNumber = evidence.ChapterNumber,
SceneNumber = evidence.SceneNumber,
EvidenceText = evidence.EvidenceText
})
.ToList()
}).ToList()
};
}
public async Task<bool> IsCompleteAsync(int userId, Guid previewId)
{
var review = await reviews.GetByPreviewAsync(userId, previewId);
return review is not null && IsComplete(review.Status);
}
public async Task SaveDecisionsAsync(int userId, CoreImportBasicLocationReviewForm form)
{
var review = await reviews.GetByPreviewAsync(userId, form.PreviewID)
?? throw new InvalidOperationException("The basic location review could not be found.");
foreach (var item in form.Locations)
{
var action = NormalizeAction(item.Action);
var decision = new CoreImportBasicLocationDecision
{
StableKey = item.StableKey,
PreferredName = Clean(item.PreferredName),
Action = action,
ParentLocationID = action == CoreImportBasicLocationCandidateActions.Ignore ? null : item.ParentLocationID,
ParentCandidateKey = action == CoreImportBasicLocationCandidateActions.Ignore ? null : CleanOptional(item.ParentCandidateKey),
SameAsLocationID = action == CoreImportBasicLocationCandidateActions.SameLocation ? item.SameAsLocationID : null,
SameAsCandidateKey = action == CoreImportBasicLocationCandidateActions.SameLocation ? CleanOptional(item.SameAsCandidateKey) : null
};
await reviews.SaveDecisionAsync(review.CoreImportBasicLocationReviewID, decision);
}
}
public async Task CompleteAsync(int userId, Guid previewId)
{
var review = await reviews.GetByPreviewAsync(userId, previewId)
?? throw new InvalidOperationException("The basic location review could not be found.");
var (candidates, _) = await reviews.ListCandidatesAsync(review.CoreImportBasicLocationReviewID);
var lookup = candidates.ToDictionary(candidate => candidate.StableKey, StringComparer.Ordinal);
var resolved = new Dictionary<string, int>(StringComparer.Ordinal);
var resolving = new HashSet<string>(StringComparer.Ordinal);
foreach (var candidate in candidates.OrderBy(candidate => candidate.SortOrder))
{
if (string.Equals(candidate.Action, CoreImportBasicLocationCandidateActions.Ignore, StringComparison.OrdinalIgnoreCase))
{
continue;
}
await ResolveCandidateAsync(candidate.StableKey);
}
await reviews.MarkCompleteAsync(review.CoreImportBasicLocationReviewID);
async Task<int?> ResolveCandidateAsync(string stableKey)
{
if (resolved.TryGetValue(stableKey, out var existingResolved))
{
return existingResolved;
}
if (!lookup.TryGetValue(stableKey, out var candidate)
|| string.Equals(candidate.Action, CoreImportBasicLocationCandidateActions.Ignore, StringComparison.OrdinalIgnoreCase))
{
return null;
}
if (!resolving.Add(stableKey))
{
throw new InvalidOperationException("Location candidate grouping contains a cycle. Review the 'Same location as' choices and try again.");
}
try
{
int locationId;
if (candidate.SameAsLocationID.HasValue)
{
locationId = candidate.SameAsLocationID.Value;
}
else if (!string.IsNullOrWhiteSpace(candidate.SameAsCandidateKey)
&& !string.Equals(candidate.SameAsCandidateKey, stableKey, StringComparison.Ordinal))
{
var sameAsLocation = await ResolveCandidateAsync(candidate.SameAsCandidateKey);
if (!sameAsLocation.HasValue)
{
throw new InvalidOperationException($"The location '{candidate.PreferredName}' points at a candidate that will not be created.");
}
locationId = sameAsLocation.Value;
}
else if (candidate.ExistingLocationID.HasValue)
{
locationId = candidate.ExistingLocationID.Value;
}
else
{
int? parentLocationId = candidate.ParentLocationID;
if (!parentLocationId.HasValue && !string.IsNullOrWhiteSpace(candidate.ParentCandidateKey))
{
parentLocationId = await ResolveCandidateAsync(candidate.ParentCandidateKey);
}
locationId = await locations.SaveAsync(new LocationItem
{
ProjectID = review.ProjectID,
ParentLocationID = parentLocationId,
LocationName = Clean(candidate.PreferredName),
ShowInQuickAddBar = false,
ExcludeFromCompanionDetection = false,
DetectionPriority = 50
});
}
await SyncAliasesAsync(locationId, ReadKnownNames(candidate).Append(candidate.PreferredName));
await reviews.SetResultAsync(review.CoreImportBasicLocationReviewID, stableKey, locationId);
resolved[stableKey] = locationId;
return locationId;
}
finally
{
resolving.Remove(stableKey);
}
}
}
public async Task SkipAsync(int userId, Guid previewId)
{
var review = await reviews.GetByPreviewAsync(userId, previewId)
?? throw new InvalidOperationException("The basic location review could not be found.");
await reviews.MarkSkippedAsync(review.CoreImportBasicLocationReviewID);
}
public async Task<IReadOnlySet<string>> GetProtectedLocationNamesAsync(int userId, Guid previewId)
{
var review = await reviews.GetByPreviewAsync(userId, previewId);
if (review is null || !IsComplete(review.Status))
{
return new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}
var projectLocations = await locations.ListByProjectAsync(review.ProjectID);
var aliases = await locations.ListAliasesByProjectAsync(review.ProjectID);
var names = projectLocations
.Select(location => location.LocationName)
.Concat(aliases.Select(alias => alias.Alias))
.Select(CoreImportBasicLocationDetector.NormalizeName)
.Where(name => !string.IsNullOrWhiteSpace(name))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
return names;
}
private async Task SyncAliasesAsync(int locationId, IEnumerable<string> names)
{
var existing = await locations.ListAliasesAsync(locationId);
var existingNames = existing.Select(alias => alias.Alias).ToHashSet(StringComparer.OrdinalIgnoreCase);
var sortOrder = existing.Select(alias => alias.SortOrder).DefaultIfEmpty(0).Max() + 10;
foreach (var name in names.Select(Clean).Where(name => !string.IsNullOrWhiteSpace(name)).Distinct(StringComparer.OrdinalIgnoreCase))
{
if (existingNames.Contains(name))
{
continue;
}
await locations.AddAliasAsync(locationId, name, sortOrder);
sortOrder += 10;
}
}
private static IReadOnlyList<string> ReadKnownNames(CoreImportBasicLocationCandidate candidate)
{
try
{
return JsonSerializer.Deserialize<List<string>>(candidate.KnownNamesJson) ?? [];
}
catch (JsonException)
{
return [candidate.ProposedName];
}
}
private static bool IsComplete(string status)
=> string.Equals(status, CoreImportBasicLocationReviewStatuses.Completed, StringComparison.OrdinalIgnoreCase)
|| string.Equals(status, CoreImportBasicLocationReviewStatuses.Skipped, StringComparison.OrdinalIgnoreCase);
private static string NormalizeAction(string? action)
=> string.Equals(action, CoreImportBasicLocationCandidateActions.SameLocation, StringComparison.OrdinalIgnoreCase)
? CoreImportBasicLocationCandidateActions.SameLocation
: string.Equals(action, CoreImportBasicLocationCandidateActions.Ignore, StringComparison.OrdinalIgnoreCase)
? CoreImportBasicLocationCandidateActions.Ignore
: CoreImportBasicLocationCandidateActions.Create;
private static string Clean(string? value)
=> string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim();
private static string? CleanOptional(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}

View File

@ -12,6 +12,12 @@ public interface IOnboardingService
Task<OnboardingWizardViewModel> GetWizardAsync();
Task<ManuscriptScanReviewViewModel?> GetScanReviewAsync(Guid? previewId = null);
Task<ManuscriptScanReviewViewModel?> SaveScanReviewAsync(ManuscriptScanReviewForm form, bool readyToImport);
Task<CoreImportBasicLocationReviewViewModel?> GetBasicLocationReviewAsync(Guid previewId);
Task SaveBasicLocationReviewAsync(CoreImportBasicLocationReviewForm form);
Task CompleteBasicLocationReviewAsync(CoreImportBasicLocationReviewForm form);
Task SkipBasicLocationReviewAsync(Guid previewId);
Task<bool> IsBasicLocationReviewCompleteAsync(Guid previewId);
Task EnsureCharacterCandidatesAfterLocationReviewAsync(Guid previewId);
Task<OnboardingManuscriptBuildResult?> FinaliseCharacterReviewAsync(ManuscriptScanReviewForm form);
Task<OnboardingManuscriptBuildResult?> BuildApprovedStructureAsync(Guid previewId, bool generateSceneTitles = true, Func<OnboardingBuildProgress, Task>? progress = null);
Task<OnboardingManuscriptBuildResult?> GetBuildResultAsync(Guid previewId);
@ -45,6 +51,7 @@ public sealed class OnboardingService(
IManuscriptStructureAnalysisService structureAnalysis,
IManuscriptTextStatisticsService manuscriptTextStatistics,
ICoreImportCharacterMappingService coreImportCharacterMapping,
ICoreImportBasicLocationService coreImportBasicLocations,
ICurrentUserService currentUser) : IOnboardingService
{
private static readonly HashSet<string> WritingJourneys = new(StringComparer.Ordinal)
@ -231,6 +238,73 @@ public sealed class OnboardingService(
return saved is null ? null : BuildScanReviewViewModel((userId, preview, saved, project, book, existingCharacters, build));
}
public async Task<CoreImportBasicLocationReviewViewModel?> GetBasicLocationReviewAsync(Guid previewId)
{
var context = await GetScanReviewContextAsync(previewId);
if (context is null)
{
return null;
}
var (userId, preview, _, project, book, _, contextBuild) = context.Value;
var build = contextBuild
?? await scanPreviews.GetBuildResultAsync(userId, preview.PreviewID)
?? await builds.GetByPreviewAsync(userId, preview.PreviewID);
if (build is null || build.SceneMappings.Count == 0)
{
return null;
}
var sceneSources = await builds.ListSceneManuscriptSourcesByBookAsync(preview.BookID);
await coreImportBasicLocations.EnsurePreparedAsync(userId, preview.PreviewID, preview.ProjectID, preview.BookID, build.BuildID, sceneSources);
return await coreImportBasicLocations.GetReviewAsync(userId, preview.PreviewID, preview.DocumentTitle ?? "Word manuscript", project.ProjectName, BookOptionTitle(book));
}
public async Task SaveBasicLocationReviewAsync(CoreImportBasicLocationReviewForm form)
{
await coreImportBasicLocations.SaveDecisionsAsync(RequireUserId(), form);
}
public async Task CompleteBasicLocationReviewAsync(CoreImportBasicLocationReviewForm form)
{
var userId = RequireUserId();
await coreImportBasicLocations.SaveDecisionsAsync(userId, form);
await coreImportBasicLocations.CompleteAsync(userId, form.PreviewID);
await EnsureCharacterCandidatesAfterLocationReviewAsync(form.PreviewID);
}
public async Task SkipBasicLocationReviewAsync(Guid previewId)
{
var userId = RequireUserId();
await coreImportBasicLocations.SkipAsync(userId, previewId);
await EnsureCharacterCandidatesAfterLocationReviewAsync(previewId);
}
public async Task<bool> IsBasicLocationReviewCompleteAsync(Guid previewId)
=> await coreImportBasicLocations.IsCompleteAsync(RequireUserId(), previewId);
public async Task EnsureCharacterCandidatesAfterLocationReviewAsync(Guid previewId)
{
var context = await GetScanReviewContextAsync(previewId)
?? throw new InvalidOperationException("The scan review could not be opened. Please scan the manuscript again.");
var (userId, preview, _, _, _, _, contextBuild) = context;
var build = contextBuild
?? await scanPreviews.GetBuildResultAsync(userId, preview.PreviewID)
?? await builds.GetByPreviewAsync(userId, preview.PreviewID);
if (build is null || build.SceneMappings.Count == 0)
{
throw new InvalidOperationException("Import the approved chapters and scenes before reviewing characters.");
}
if (!await coreImportBasicLocations.IsCompleteAsync(userId, preview.PreviewID))
{
throw new InvalidOperationException("Review or skip basic locations before reviewing characters.");
}
var sceneSources = await builds.ListSceneManuscriptSourcesByBookAsync(preview.BookID);
await PrepareCharacterCandidatesAfterLocationReviewAsync(userId, preview, sceneSources);
}
public async Task<OnboardingManuscriptBuildResult?> BuildApprovedStructureAsync(Guid previewId, bool generateSceneTitles = true, Func<OnboardingBuildProgress, Task>? progress = null)
{
var context = await GetScanReviewContextAsync(previewId);
@ -290,7 +364,9 @@ public sealed class OnboardingService(
preview.PreviewID,
repairedSceneSources
? "Scene source text recovered. Updating Word markers."
: "Resuming Word marker sync. No scene detection was rerun.",
: await coreImportBasicLocations.IsCompleteAsync(userId, preview.PreviewID)
? "Resuming Word marker sync. No scene detection was rerun."
: "Basic location review is ready. Review locations before character candidates.",
90,
progress,
resumed);
@ -363,10 +439,27 @@ public sealed class OnboardingService(
await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID);
await ApplyStructuralPovAsync(preview, structuralScenes, result);
var canonicalSceneSources = await builds.ListSceneManuscriptSourcesByBookAsync(preview.BookID);
var characterCandidates = WordCompanionService.DiscoverCoreImportCharacterCandidates(canonicalSceneSources);
await scanPreviews.SaveCharacterCandidatesAsync(userId, preview.PreviewID, characterCandidates);
var locationReview = await coreImportBasicLocations.EnsurePreparedAsync(
userId,
preview.PreviewID,
preview.ProjectID,
preview.BookID,
result.BuildID,
canonicalSceneSources);
if (locationReview is not null
&& await coreImportBasicLocations.IsCompleteAsync(userId, preview.PreviewID))
{
await PrepareCharacterCandidatesAfterLocationReviewAsync(userId, preview, canonicalSceneSources);
}
await scanPreviews.SaveBuildResultAsync(userId, result);
await PublishBuildProgress(userId, preview.PreviewID, "Updating Word document with scene markers. Keep Word open while the document is saved.", 90, progress);
await PublishBuildProgress(
userId,
preview.PreviewID,
locationReview is not null && !await coreImportBasicLocations.IsCompleteAsync(userId, preview.PreviewID)
? "Basic location review is ready. Review locations before character candidates."
: "Updating Word document with scene markers. Keep Word open while the document is saved.",
90,
progress);
return result;
}
@ -657,8 +750,11 @@ public sealed class OnboardingService(
OnboardingManuscriptBuildResult existingBuild)
{
var sceneSources = await builds.ListSceneManuscriptSourcesByBookAsync(preview.BookID);
var characterCandidates = WordCompanionService.DiscoverCoreImportCharacterCandidates(sceneSources);
await scanPreviews.SaveCharacterCandidatesAsync(userId, preview.PreviewID, characterCandidates);
await coreImportBasicLocations.EnsurePreparedAsync(userId, preview.PreviewID, preview.ProjectID, preview.BookID, existingBuild.BuildID, sceneSources);
if (await coreImportBasicLocations.IsCompleteAsync(userId, preview.PreviewID))
{
await PrepareCharacterCandidatesAfterLocationReviewAsync(userId, preview, sceneSources);
}
return new OnboardingManuscriptBuildResult
{
@ -686,6 +782,16 @@ public sealed class OnboardingService(
};
}
private async Task PrepareCharacterCandidatesAfterLocationReviewAsync(
int userId,
ManuscriptScanPreview preview,
IReadOnlyList<SceneManuscriptSourceRecord> sceneSources)
{
var protectedLocationNames = await coreImportBasicLocations.GetProtectedLocationNamesAsync(userId, preview.PreviewID);
var characterCandidates = WordCompanionService.DiscoverCoreImportCharacterCandidates(sceneSources, protectedLocationNames);
await scanPreviews.SaveCharacterCandidatesAsync(userId, preview.PreviewID, characterCandidates);
}
public async Task<OnboardingManuscriptBuildResult?> GetBuildResultAsync(Guid previewId)
{
var userId = RequireUserId();
@ -709,6 +815,10 @@ public sealed class OnboardingService(
{
throw new InvalidOperationException("Import the approved chapters and scenes before reviewing characters.");
}
if (!await coreImportBasicLocations.IsCompleteAsync(userId, preview.PreviewID))
{
throw new InvalidOperationException("Review or skip basic locations before reviewing characters.");
}
var candidateLookup = preview.CharacterCandidates.ToDictionary(candidate => candidate.TemporaryCharacterKey, StringComparer.Ordinal);
var existingCharacterIds = existingCharacters.Select(character => character.CharacterID).ToHashSet();

View File

@ -724,8 +724,13 @@ public sealed class WordCompanionService(
}
public static IReadOnlyList<ManuscriptScanCharacterCandidatePreview> DiscoverCoreImportCharacterCandidates(
IReadOnlyList<SceneManuscriptSourceRecord> sourceScenes)
IReadOnlyList<SceneManuscriptSourceRecord> sourceScenes,
IEnumerable<string>? excludedNames = null)
{
var excluded = (excludedNames ?? [])
.Select(CoreImportBasicLocationDetector.StableKey)
.Where(key => !string.IsNullOrWhiteSpace(key))
.ToHashSet(StringComparer.Ordinal);
var orderedScenes = sourceScenes
.OrderBy(scene => scene.ChapterID)
.ThenBy(scene => scene.SourceStartParagraph ?? int.MaxValue)
@ -750,6 +755,7 @@ public sealed class WordCompanionService(
return DiscoverCharacterCandidates(manuscriptText)
.Where(IsAutoSelectedScanCharacterCandidate)
.Where(candidate => !excluded.Contains(CoreImportBasicLocationDetector.StableKey(candidate.Text)))
.Select((candidate, index) =>
{
var evidenceScenes = CandidateSceneEvidence(candidate.Text, orderedScenes, chapterNumbers, sceneNumbers);

View File

@ -0,0 +1,375 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF OBJECT_ID(N'dbo.CoreImportBasicLocationReviews', N'U') IS NULL
BEGIN
CREATE TABLE dbo.CoreImportBasicLocationReviews
(
CoreImportBasicLocationReviewID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_CoreImportBasicLocationReviews PRIMARY KEY,
PreviewID uniqueidentifier NOT NULL,
UserID int NOT NULL,
ProjectID int NOT NULL,
BookID int NOT NULL,
BuildID int NULL,
Status nvarchar(40) NOT NULL CONSTRAINT DF_CoreImportBasicLocationReviews_Status DEFAULT N'Pending',
RawObservationCount int NOT NULL CONSTRAINT DF_CoreImportBasicLocationReviews_RawObservationCount DEFAULT 0,
CandidateCount int NOT NULL CONSTRAINT DF_CoreImportBasicLocationReviews_CandidateCount DEFAULT 0,
CreatedUtc datetime2(0) NOT NULL CONSTRAINT DF_CoreImportBasicLocationReviews_CreatedUtc DEFAULT SYSUTCDATETIME(),
UpdatedUtc datetime2(0) NOT NULL CONSTRAINT DF_CoreImportBasicLocationReviews_UpdatedUtc DEFAULT SYSUTCDATETIME(),
CompletedUtc datetime2(0) NULL,
CONSTRAINT FK_CoreImportBasicLocationReviews_Book FOREIGN KEY (BookID) REFERENCES dbo.Books(BookID)
);
END;
GO
IF OBJECT_ID(N'dbo.CoreImportBasicLocationCandidates', N'U') IS NULL
BEGIN
CREATE TABLE dbo.CoreImportBasicLocationCandidates
(
CoreImportBasicLocationCandidateID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_CoreImportBasicLocationCandidates PRIMARY KEY,
CoreImportBasicLocationReviewID int NOT NULL,
StableKey nvarchar(200) NOT NULL,
ProposedName nvarchar(200) NOT NULL,
PreferredName nvarchar(200) NOT NULL,
KnownNamesJson nvarchar(max) NOT NULL,
ParentLocationID int NULL,
ParentCandidateKey nvarchar(200) NULL,
ExistingLocationID int NULL,
Action nvarchar(40) NOT NULL CONSTRAINT DF_CoreImportBasicLocationCandidates_Action DEFAULT N'Create',
SameAsLocationID int NULL,
SameAsCandidateKey nvarchar(200) NULL,
ResultLocationID int NULL,
FirstChapterNumber int NOT NULL,
FirstSceneNumber int NOT NULL,
FirstOccurrenceIndex int NOT NULL,
ObservationCount int NOT NULL,
SortOrder int NOT NULL,
CreatedUtc datetime2(0) NOT NULL CONSTRAINT DF_CoreImportBasicLocationCandidates_CreatedUtc DEFAULT SYSUTCDATETIME(),
UpdatedUtc datetime2(0) NOT NULL CONSTRAINT DF_CoreImportBasicLocationCandidates_UpdatedUtc DEFAULT SYSUTCDATETIME(),
CONSTRAINT FK_CoreImportBasicLocationCandidates_LocationParent FOREIGN KEY (ParentLocationID) REFERENCES dbo.Locations(LocationID),
CONSTRAINT FK_CoreImportBasicLocationCandidates_ExistingLocation FOREIGN KEY (ExistingLocationID) REFERENCES dbo.Locations(LocationID),
CONSTRAINT FK_CoreImportBasicLocationCandidates_SameAsLocation FOREIGN KEY (SameAsLocationID) REFERENCES dbo.Locations(LocationID),
CONSTRAINT FK_CoreImportBasicLocationCandidates_ResultLocation FOREIGN KEY (ResultLocationID) REFERENCES dbo.Locations(LocationID)
);
END;
GO
IF OBJECT_ID(N'dbo.CoreImportBasicLocationEvidence', N'U') IS NULL
BEGIN
CREATE TABLE dbo.CoreImportBasicLocationEvidence
(
CoreImportBasicLocationEvidenceID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_CoreImportBasicLocationEvidence PRIMARY KEY,
CoreImportBasicLocationCandidateID int NOT NULL,
SceneID int NOT NULL,
ChapterID int NOT NULL,
ChapterNumber int NOT NULL,
SceneNumber int NOT NULL,
OccurrenceIndex int NOT NULL,
EvidenceText nvarchar(400) NOT NULL,
SortOrder int NOT NULL,
CONSTRAINT FK_CoreImportBasicLocationEvidence_Candidate FOREIGN KEY (CoreImportBasicLocationCandidateID)
REFERENCES dbo.CoreImportBasicLocationCandidates(CoreImportBasicLocationCandidateID) ON DELETE CASCADE
);
END;
GO
IF OBJECT_ID(N'dbo.CoreImportBasicLocationCandidates', N'U') IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_CoreImportBasicLocationCandidates_Review')
BEGIN
ALTER TABLE dbo.CoreImportBasicLocationCandidates
ADD CONSTRAINT FK_CoreImportBasicLocationCandidates_Review FOREIGN KEY (CoreImportBasicLocationReviewID)
REFERENCES dbo.CoreImportBasicLocationReviews(CoreImportBasicLocationReviewID) ON DELETE CASCADE;
END;
GO
IF OBJECT_ID(N'dbo.CoreImportBasicLocationEvidence', N'U') IS NOT NULL
AND OBJECT_ID(N'dbo.CoreImportBasicLocationCandidates', N'U') IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_CoreImportBasicLocationCandidates_Review_Key')
CREATE UNIQUE INDEX UX_CoreImportBasicLocationCandidates_Review_Key
ON dbo.CoreImportBasicLocationCandidates(CoreImportBasicLocationReviewID, StableKey);
GO
IF OBJECT_ID(N'dbo.CoreImportBasicLocationReviews', N'U') IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_CoreImportBasicLocationReviews_Preview')
CREATE UNIQUE INDEX UX_CoreImportBasicLocationReviews_Preview
ON dbo.CoreImportBasicLocationReviews(PreviewID);
GO
IF OBJECT_ID(N'dbo.CoreImportBasicLocationCandidates', N'U') IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_CoreImportBasicLocationCandidates_Review_Sort')
CREATE INDEX IX_CoreImportBasicLocationCandidates_Review_Sort
ON dbo.CoreImportBasicLocationCandidates(CoreImportBasicLocationReviewID, SortOrder, CoreImportBasicLocationCandidateID);
GO
IF OBJECT_ID(N'dbo.CoreImportBasicLocationEvidence', N'U') IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_CoreImportBasicLocationEvidence_Candidate_Sort')
CREATE INDEX IX_CoreImportBasicLocationEvidence_Candidate_Sort
ON dbo.CoreImportBasicLocationEvidence(CoreImportBasicLocationCandidateID, SortOrder, CoreImportBasicLocationEvidenceID);
GO
CREATE OR ALTER PROCEDURE dbo.CoreImportBasicLocationReview_GetByPreview
@UserID int,
@PreviewID uniqueidentifier
AS
BEGIN
SET NOCOUNT ON;
SELECT TOP (1) *
FROM dbo.CoreImportBasicLocationReviews
WHERE UserID = @UserID AND PreviewID = @PreviewID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.CoreImportBasicLocationReview_Create
@UserID int,
@PreviewID uniqueidentifier,
@ProjectID int,
@BookID int,
@BuildID int = NULL
AS
BEGIN
SET NOCOUNT ON;
IF EXISTS (SELECT 1 FROM dbo.CoreImportBasicLocationReviews WHERE PreviewID = @PreviewID AND UserID = @UserID)
BEGIN
UPDATE dbo.CoreImportBasicLocationReviews
SET BuildID = COALESCE(@BuildID, BuildID),
UpdatedUtc = SYSUTCDATETIME()
WHERE PreviewID = @PreviewID AND UserID = @UserID;
END
ELSE
BEGIN
INSERT dbo.CoreImportBasicLocationReviews (PreviewID, UserID, ProjectID, BookID, BuildID)
VALUES (@PreviewID, @UserID, @ProjectID, @BookID, @BuildID);
END
EXEC dbo.CoreImportBasicLocationReview_GetByPreview @UserID = @UserID, @PreviewID = @PreviewID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.CoreImportBasicLocationReview_SaveCandidate
@CoreImportBasicLocationReviewID int,
@StableKey nvarchar(200),
@ProposedName nvarchar(200),
@PreferredName nvarchar(200),
@KnownNamesJson nvarchar(max),
@ParentLocationID int = NULL,
@ParentCandidateKey nvarchar(200) = NULL,
@ExistingLocationID int = NULL,
@FirstChapterNumber int,
@FirstSceneNumber int,
@FirstOccurrenceIndex int,
@ObservationCount int,
@SortOrder int
AS
BEGIN
SET NOCOUNT ON;
IF EXISTS
(
SELECT 1
FROM dbo.CoreImportBasicLocationCandidates
WHERE CoreImportBasicLocationReviewID = @CoreImportBasicLocationReviewID
AND StableKey = @StableKey
)
BEGIN
UPDATE dbo.CoreImportBasicLocationCandidates
SET ProposedName = @ProposedName,
PreferredName = @PreferredName,
KnownNamesJson = @KnownNamesJson,
ParentLocationID = @ParentLocationID,
ParentCandidateKey = @ParentCandidateKey,
ExistingLocationID = @ExistingLocationID,
FirstChapterNumber = @FirstChapterNumber,
FirstSceneNumber = @FirstSceneNumber,
FirstOccurrenceIndex = @FirstOccurrenceIndex,
ObservationCount = @ObservationCount,
SortOrder = @SortOrder,
UpdatedUtc = SYSUTCDATETIME()
WHERE CoreImportBasicLocationReviewID = @CoreImportBasicLocationReviewID
AND StableKey = @StableKey;
END
ELSE
BEGIN
INSERT dbo.CoreImportBasicLocationCandidates
(
CoreImportBasicLocationReviewID, StableKey, ProposedName, PreferredName, KnownNamesJson,
ParentLocationID, ParentCandidateKey, ExistingLocationID,
FirstChapterNumber, FirstSceneNumber, FirstOccurrenceIndex, ObservationCount, SortOrder
)
VALUES
(
@CoreImportBasicLocationReviewID, @StableKey, @ProposedName, @PreferredName, @KnownNamesJson,
@ParentLocationID, @ParentCandidateKey, @ExistingLocationID,
@FirstChapterNumber, @FirstSceneNumber, @FirstOccurrenceIndex, @ObservationCount, @SortOrder
);
END
SELECT TOP (1) *
FROM dbo.CoreImportBasicLocationCandidates
WHERE CoreImportBasicLocationReviewID = @CoreImportBasicLocationReviewID
AND StableKey = @StableKey;
END;
GO
CREATE OR ALTER PROCEDURE dbo.CoreImportBasicLocationReview_SaveEvidence
@CoreImportBasicLocationCandidateID int,
@SceneID int,
@ChapterID int,
@ChapterNumber int,
@SceneNumber int,
@OccurrenceIndex int,
@EvidenceText nvarchar(400),
@SortOrder int
AS
BEGIN
SET NOCOUNT ON;
INSERT dbo.CoreImportBasicLocationEvidence
(
CoreImportBasicLocationCandidateID, SceneID, ChapterID, ChapterNumber,
SceneNumber, OccurrenceIndex, EvidenceText, SortOrder
)
VALUES
(
@CoreImportBasicLocationCandidateID, @SceneID, @ChapterID, @ChapterNumber,
@SceneNumber, @OccurrenceIndex, @EvidenceText, @SortOrder
);
END;
GO
CREATE OR ALTER PROCEDURE dbo.CoreImportBasicLocationReview_DeleteEvidence
@CoreImportBasicLocationCandidateID int
AS
BEGIN
SET NOCOUNT ON;
DELETE dbo.CoreImportBasicLocationEvidence
WHERE CoreImportBasicLocationCandidateID = @CoreImportBasicLocationCandidateID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.CoreImportBasicLocationReview_CompletePreparation
@CoreImportBasicLocationReviewID int,
@RawObservationCount int,
@CandidateCount int
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.CoreImportBasicLocationReviews
SET RawObservationCount = @RawObservationCount,
CandidateCount = @CandidateCount,
Status = CASE WHEN @CandidateCount = 0 THEN N'Completed' ELSE Status END,
CompletedUtc = CASE WHEN @CandidateCount = 0 THEN COALESCE(CompletedUtc, SYSUTCDATETIME()) ELSE CompletedUtc END,
UpdatedUtc = SYSUTCDATETIME()
WHERE CoreImportBasicLocationReviewID = @CoreImportBasicLocationReviewID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.CoreImportBasicLocationReview_ListCandidates
@CoreImportBasicLocationReviewID int
AS
BEGIN
SET NOCOUNT ON;
SELECT *
FROM dbo.CoreImportBasicLocationCandidates
WHERE CoreImportBasicLocationReviewID = @CoreImportBasicLocationReviewID
ORDER BY SortOrder, CoreImportBasicLocationCandidateID;
SELECT e.*
FROM dbo.CoreImportBasicLocationEvidence e
INNER JOIN dbo.CoreImportBasicLocationCandidates c
ON c.CoreImportBasicLocationCandidateID = e.CoreImportBasicLocationCandidateID
WHERE c.CoreImportBasicLocationReviewID = @CoreImportBasicLocationReviewID
ORDER BY c.SortOrder, e.SortOrder, e.CoreImportBasicLocationEvidenceID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.CoreImportBasicLocationReview_SaveDecision
@CoreImportBasicLocationReviewID int,
@StableKey nvarchar(200),
@PreferredName nvarchar(200),
@Action nvarchar(40),
@ParentLocationID int = NULL,
@ParentCandidateKey nvarchar(200) = NULL,
@SameAsLocationID int = NULL,
@SameAsCandidateKey nvarchar(200) = NULL
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.CoreImportBasicLocationCandidates
SET PreferredName = COALESCE(NULLIF(LTRIM(RTRIM(@PreferredName)), N''), PreferredName),
Action = @Action,
ParentLocationID = @ParentLocationID,
ParentCandidateKey = NULLIF(LTRIM(RTRIM(@ParentCandidateKey)), N''),
SameAsLocationID = @SameAsLocationID,
SameAsCandidateKey = NULLIF(LTRIM(RTRIM(@SameAsCandidateKey)), N''),
UpdatedUtc = SYSUTCDATETIME()
WHERE CoreImportBasicLocationReviewID = @CoreImportBasicLocationReviewID
AND StableKey = @StableKey;
END;
GO
CREATE OR ALTER PROCEDURE dbo.CoreImportBasicLocationReview_SetResult
@CoreImportBasicLocationReviewID int,
@StableKey nvarchar(200),
@ResultLocationID int
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.CoreImportBasicLocationCandidates
SET ResultLocationID = @ResultLocationID,
UpdatedUtc = SYSUTCDATETIME()
WHERE CoreImportBasicLocationReviewID = @CoreImportBasicLocationReviewID
AND StableKey = @StableKey;
END;
GO
CREATE OR ALTER PROCEDURE dbo.CoreImportBasicLocationReview_MarkComplete
@CoreImportBasicLocationReviewID int
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.CoreImportBasicLocationReviews
SET Status = N'Completed',
CompletedUtc = COALESCE(CompletedUtc, SYSUTCDATETIME()),
UpdatedUtc = SYSUTCDATETIME()
WHERE CoreImportBasicLocationReviewID = @CoreImportBasicLocationReviewID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.CoreImportBasicLocationReview_MarkSkipped
@CoreImportBasicLocationReviewID int
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.CoreImportBasicLocationReviews
SET Status = N'Skipped',
CompletedUtc = COALESCE(CompletedUtc, SYSUTCDATETIME()),
UpdatedUtc = SYSUTCDATETIME()
WHERE CoreImportBasicLocationReviewID = @CoreImportBasicLocationReviewID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.LocationAlias_ListByProject
@ProjectID int
AS
BEGIN
SET NOCOUNT ON;
SELECT la.LocationAliasID, la.LocationID, la.Alias, la.SortOrder, la.CreatedUtc
FROM dbo.LocationAliases la
INNER JOIN dbo.Locations l ON l.LocationID = la.LocationID
WHERE l.ProjectID = @ProjectID
AND l.IsArchived = 0
ORDER BY l.LocationName, la.SortOrder, la.LocationAliasID;
END;
GO

View File

@ -948,6 +948,72 @@ public sealed class ManuscriptScanExistingCharacterOptionViewModel
public string CharacterName { get; init; } = string.Empty;
}
public sealed class CoreImportBasicLocationReviewViewModel
{
public Guid PreviewID { get; init; }
public int ReviewID { get; init; }
public string Status { get; init; } = CoreImportBasicLocationReviewStatuses.Pending;
public string DocumentTitle { get; init; } = "Word manuscript";
public string SelectedProjectName { get; init; } = string.Empty;
public string SelectedBookTitle { get; init; } = string.Empty;
public int RawObservationCount { get; init; }
public int CandidateCount { get; init; }
public IReadOnlyList<CoreImportBasicLocationCandidateViewModel> Candidates { get; init; } = [];
public IReadOnlyList<CoreImportLocationOptionViewModel> ExistingLocationOptions { get; init; } = [];
public bool IsComplete => string.Equals(Status, CoreImportBasicLocationReviewStatuses.Completed, StringComparison.OrdinalIgnoreCase)
|| string.Equals(Status, CoreImportBasicLocationReviewStatuses.Skipped, StringComparison.OrdinalIgnoreCase);
}
public sealed class CoreImportBasicLocationCandidateViewModel
{
public string StableKey { get; init; } = string.Empty;
public string ProposedName { get; init; } = string.Empty;
public string PreferredName { get; init; } = string.Empty;
public IReadOnlyList<string> KnownNames { get; init; } = [];
public string Action { get; init; } = CoreImportBasicLocationCandidateActions.Create;
public int? ParentLocationID { get; init; }
public string? ParentCandidateKey { get; init; }
public int? SameAsLocationID { get; init; }
public string? SameAsCandidateKey { get; init; }
public int ObservationCount { get; init; }
public int FirstChapterNumber { get; init; }
public int FirstSceneNumber { get; init; }
public IReadOnlyList<CoreImportBasicLocationEvidenceViewModel> Evidence { get; init; } = [];
}
public sealed class CoreImportBasicLocationEvidenceViewModel
{
public int ChapterNumber { get; init; }
public int SceneNumber { get; init; }
public string EvidenceText { get; init; } = string.Empty;
public string Label => $"Chapter {ChapterNumber:N0} - Scene {SceneNumber:N0}";
}
public sealed class CoreImportLocationOptionViewModel
{
public int LocationID { get; init; }
public string LocationName { get; init; } = string.Empty;
public string LocationPath { get; init; } = string.Empty;
public string Label => string.IsNullOrWhiteSpace(LocationPath) ? LocationName : LocationPath;
}
public sealed class CoreImportBasicLocationReviewForm
{
public Guid PreviewID { get; set; }
public List<CoreImportBasicLocationReviewFormItem> Locations { get; set; } = [];
}
public sealed class CoreImportBasicLocationReviewFormItem
{
public string StableKey { get; set; } = string.Empty;
public string PreferredName { get; set; } = string.Empty;
public string Action { get; set; } = CoreImportBasicLocationCandidateActions.Create;
public int? ParentLocationID { get; set; }
public string? ParentCandidateKey { get; set; }
public int? SameAsLocationID { get; set; }
public string? SameAsCandidateKey { get; set; }
}
public sealed class ManuscriptScanReviewForm
{
public Guid PreviewID { get; set; }

View File

@ -116,7 +116,7 @@
}
else
{
<a class="btn btn-primary" asp-controller="Onboarding" asp-action="ScanCharacters" asp-route-previewId="@Model.PreviewID">Review characters</a>
<a class="btn btn-primary" asp-controller="Onboarding" asp-action="ScanLocations" asp-route-previewId="@Model.PreviewID">Review locations</a>
<button class="btn btn-outline-primary"
type="button"
data-onboarding-build-start

View File

@ -0,0 +1,129 @@
@model PlotLine.ViewModels.CoreImportBasicLocationReviewViewModel
@{
ViewData["Title"] = "Review Locations";
}
<section class="onboarding-shell">
<div class="onboarding-header">
<p class="eyebrow">Core import</p>
<h1>Review locations</h1>
<p class="lede">@Model.DocumentTitle - @Model.SelectedProjectName - @Model.SelectedBookTitle</p>
</div>
@if (TempData["OnboardingReviewMessage"] is string message)
{
<div class="alert alert-success">@message</div>
}
@if (TempData["ArchiveError"] is string error)
{
<div class="alert alert-danger">@error</div>
}
<div class="onboarding-panel">
<div class="review-summary-grid">
<div>
<span class="metric-label">Raw observations</span>
<strong>@Model.RawObservationCount</strong>
</div>
<div>
<span class="metric-label">Location candidates</span>
<strong>@Model.CandidateCount</strong>
</div>
</div>
<form asp-controller="Onboarding" asp-action="SaveBasicLocations" method="post">
@Html.AntiForgeryToken()
<input type="hidden" name="PreviewID" value="@Model.PreviewID" />
<div class="location-review-list">
@for (var i = 0; i < Model.Candidates.Count; i++)
{
var candidate = Model.Candidates[i];
<article class="location-review-row">
<input type="hidden" name="Locations[@i].StableKey" value="@candidate.StableKey" />
<div class="location-review-main">
<h2>@candidate.ProposedName</h2>
<p>@candidate.ObservationCount observation@(candidate.ObservationCount == 1 ? "" : "s") - first seen in Chapter @candidate.FirstChapterNumber, Scene @candidate.FirstSceneNumber</p>
@if (candidate.KnownNames.Count > 1)
{
<p>Known names: @string.Join(", ", candidate.KnownNames)</p>
}
<div class="location-evidence">
@foreach (var evidence in candidate.Evidence.Take(3))
{
<blockquote><strong>@evidence.Label:</strong> @evidence.EvidenceText</blockquote>
}
</div>
</div>
<div class="location-review-controls">
<label>
Decision
<select name="Locations[@i].Action">
<option value="Create" selected="@(candidate.Action == "Create" ? "selected" : null)">Create location</option>
<option value="SameLocation" selected="@(candidate.Action == "SameLocation" ? "selected" : null)">Same location as</option>
<option value="Ignore" selected="@(candidate.Action == "Ignore" ? "selected" : null)">Ignore</option>
</select>
</label>
<label>
Preferred name
<input type="text" name="Locations[@i].PreferredName" value="@candidate.PreferredName" maxlength="200" />
</label>
<label>
Parent location
<select name="Locations[@i].ParentLocationID">
<option value="">No canonical parent</option>
@foreach (var option in Model.ExistingLocationOptions)
{
<option value="@option.LocationID" selected="@(candidate.ParentLocationID == option.LocationID ? "selected" : null)">@option.Label</option>
}
</select>
</label>
<label>
Parent candidate
<select name="Locations[@i].ParentCandidateKey">
<option value="">No pending parent</option>
@foreach (var option in Model.Candidates.Where(item => item.StableKey != candidate.StableKey))
{
<option value="@option.StableKey" selected="@(candidate.ParentCandidateKey == option.StableKey ? "selected" : null)">@option.ProposedName</option>
}
</select>
</label>
<label>
Same location as existing
<select name="Locations[@i].SameAsLocationID">
<option value="">No existing location</option>
@foreach (var option in Model.ExistingLocationOptions)
{
<option value="@option.LocationID" selected="@(candidate.SameAsLocationID == option.LocationID ? "selected" : null)">@option.Label</option>
}
</select>
</label>
<label>
Same location as candidate
<select name="Locations[@i].SameAsCandidateKey">
<option value="">No pending candidate</option>
@foreach (var option in Model.Candidates.Where(item => item.StableKey != candidate.StableKey))
{
<option value="@option.StableKey" selected="@(candidate.SameAsCandidateKey == option.StableKey ? "selected" : null)">@option.ProposedName</option>
}
</select>
</label>
</div>
</article>
}
</div>
<div class="form-actions">
<button class="btn btn-secondary" type="submit" name="intent" value="save">Save choices</button>
<button class="btn btn-outline-secondary" type="submit" name="intent" value="skip">Skip locations</button>
<button class="btn btn-primary" type="submit" name="intent" value="continue">Save locations and review characters</button>
</div>
</form>
</div>
</section>