Add core import basic location review stage
This commit is contained in:
parent
bb62762b78
commit
1cf0174466
@ -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");
|
||||
|
||||
@ -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)
|
||||
|
||||
160
PlotLine/Data/CoreImportBasicLocationReviewRepository.cs
Normal file
160
PlotLine/Data/CoreImportBasicLocationReviewRepository.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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>();
|
||||
|
||||
291
PlotLine/Services/CoreImportBasicLocationDetector.cs
Normal file
291
PlotLine/Services/CoreImportBasicLocationDetector.cs
Normal 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);
|
||||
}
|
||||
310
PlotLine/Services/CoreImportBasicLocationService.cs
Normal file
310
PlotLine/Services/CoreImportBasicLocationService.cs
Normal 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();
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
|
||||
375
PlotLine/Sql/181_Phase27H_CoreImportBasicLocationReview.sql
Normal file
375
PlotLine/Sql/181_Phase27H_CoreImportBasicLocationReview.sql
Normal 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
|
||||
@ -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; }
|
||||
|
||||
@ -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
|
||||
|
||||
129
PlotLine/Views/Onboarding/ScanLocations.cshtml
Normal file
129
PlotLine/Views/Onboarding/ScanLocations.cshtml
Normal 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>
|
||||
Loading…
x
Reference in New Issue
Block a user