Compare commits

..

No commits in common. "108df3db3ef4d8b8840c9c82431028c9f95cd1b3" and "1c7cf9bee16a81ed5a9a301001d0060fde5fc9ec" have entirely different histories.

209 changed files with 5407 additions and 114323 deletions

File diff suppressed because it is too large Load Diff

View File

@ -21,9 +21,7 @@ public sealed class AdminController(
IStoryIntelligenceExistingChapterQueueService existingChapterQueue,
IStoryIntelligenceFullChapterTestService fullChapterTest,
IStoryIntelligenceImportCommitService storyIntelligenceImportCommit,
INameIntelligenceService nameIntelligence,
IIllustrationLibraryService illustrationLibrary,
ILogger<AdminController> logger) : Controller
IIllustrationLibraryService illustrationLibrary) : Controller
{
[HttpGet("")]
[HttpGet("index")]
@ -41,34 +39,6 @@ public sealed class AdminController(
return View();
}
[HttpPost("name-intelligence/validate")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ValidateNameIntelligence(CancellationToken cancellationToken)
{
var result = await nameIntelligence.ValidatePackageAsync(cancellationToken);
TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = $"{result.Message} Locales={result.LocaleRows:N0}, names={result.GivenNameRows:N0}, relationships={result.RelationshipRows:N0}, usages={result.UsageRows:N0}.";
if (result.Errors.Count > 0)
{
TempData["AdminError"] = string.Join(" ", result.Errors.Take(5));
}
return RedirectToAction(nameof(Index));
}
[HttpPost("name-intelligence/import")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ImportNameIntelligence(bool force, CancellationToken cancellationToken)
{
var result = await nameIntelligence.EnsureReferenceDataImportedAsync(force, cancellationToken);
TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = $"{result.Message} Package {result.PackageId} {result.Version}: locales={result.LocaleRows:N0}, names={result.GivenNameRows:N0}, relationships={result.RelationshipRows:N0}, usages={result.UsageRows:N0}.";
if (result.Errors.Count > 0)
{
TempData["AdminError"] = string.Join(" ", result.Errors.Take(5));
}
return RedirectToAction(nameof(Index));
}
[HttpGet("story-intelligence/illustration-library")]
public async Task<IActionResult> StoryIntelligenceIllustrationLibrary([FromQuery] IllustrationLibraryFilter filter)
{
@ -199,27 +169,8 @@ public sealed class AdminController(
return Forbid();
}
logger.LogWarning("Story Intelligence development reset requested by admin user {UserId}.", userId);
try
{
var result = await storyIntelligenceDevelopmentReset.ResetOwnedProjectsAndImportsAsync(userId, cancellationToken);
TempData[result.Success ? "AdminMessage" : "AdminError"] = result.Message;
logger.LogWarning(
"Story Intelligence development reset finished for admin user {UserId}. Success={Success} DeletedProjects={DeletedProjects} RemainingProjects={RemainingProjects} RemainingImports={RemainingImports} RemainingRuns={RemainingRuns}.",
userId,
result.Success,
result.DeletedProjects,
result.RemainingProjects,
result.RemainingImportSessions,
result.RemainingRuns);
}
catch (Exception ex)
{
logger.LogError(ex, "Story Intelligence development reset failed for admin user {UserId}.", userId);
TempData["AdminError"] = "Story Intelligence reset failed. No files were removed; check the application logs for the blocking dependency.";
}
return RedirectToAction(nameof(StoryIntelligenceDiagnostics));
}

View File

@ -69,8 +69,6 @@ public sealed class BooksController(IBookService books, IOnboardingService onboa
createModel.BookNumber = model.BookNumber;
createModel.Description = model.Description;
createModel.TargetWordCount = model.TargetWordCount;
createModel.StoryEra = model.StoryEra;
createModel.PrimaryLocaleCode = model.PrimaryLocaleCode;
createModel.WritingStartDate = model.WritingStartDate;
createModel.TargetCompletionDate = model.TargetCompletionDate;
createModel.PublicationDate = model.PublicationDate;

View File

@ -51,7 +51,6 @@ public sealed class ChaptersController(IChapterService chapters) : Controller
createModel.ChapterNumber = model.ChapterNumber;
createModel.ChapterTitle = model.ChapterTitle;
createModel.POVCharacterID = model.POVCharacterID;
createModel.Summary = model.Summary;
createModel.RevisionStatusID = model.RevisionStatusID;
return View("Edit", createModel);
@ -60,15 +59,6 @@ public sealed class ChaptersController(IChapterService chapters) : Controller
return RedirectToAction(nameof(Details), new { id = chapterId });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SetScenesPov(int chapterId, int? povCharacterId, bool overwriteAuthor)
{
var updated = await chapters.SetScenesPovAsync(chapterId, povCharacterId, overwriteAuthor);
TempData["ChapterMessage"] = $"POV updated on {updated:N0} scene{(updated == 1 ? string.Empty : "s")}.";
return RedirectToAction(nameof(Details), new { id = chapterId });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Archive(int id, int bookId)

View File

@ -32,12 +32,6 @@ public sealed class CharactersController(ICharacterService characters) : Control
return model is null ? NotFound() : View(model);
}
public async Task<IActionResult> Merge(int id)
{
var model = await characters.GetMergeAsync(id);
return model is null ? NotFound() : View(model);
}
public async Task<IActionResult> CropAvatar(int imageId)
{
var model = await characters.GetAvatarCropAsync(imageId);
@ -105,35 +99,6 @@ public sealed class CharactersController(ICharacterService characters) : Control
: RedirectToAction(nameof(Details), new { id = saveResult.CharacterID });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Merge(CharacterMergeForm form)
{
try
{
var result = await characters.MergeAsync(form);
if (result is null)
{
return NotFound();
}
TempData["CharacterMessage"] = $"{result.SourceCharacterName} merged into {result.TargetCharacterName}.";
return RedirectToAction(nameof(Details), new { id = result.TargetCharacterID });
}
catch (InvalidOperationException ex)
{
ModelState.AddModelError(string.Empty, ex.Message);
var model = await characters.GetMergeAsync(form.SourceCharacterID);
if (model is null)
{
return NotFound();
}
model.Form = form;
return View(model);
}
}
private void ValidateSexWorkflow(CharacterEditViewModel model)
{
if (model.SexValueID == CharacterEditViewModel.CustomSexValue)

View File

@ -0,0 +1,193 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.Services;
using PlotLine.ViewModels;
namespace PlotLine.Controllers;
[Authorize(Policy = "AdminOnly")]
[Route("Development")]
public sealed class DevelopmentController(
IWebHostEnvironment environment,
ICurrentUserService currentUser,
IIllustrationLibraryService illustrationLibrary,
IStoryMemoryService storyMemory,
IStoryMemoryRepository storyMemoryRepository,
IStoryIntelligenceVisualisationSnapshotService snapshots) : Controller
{
[HttpGet("StoryIntelligenceExperience")]
public async Task<IActionResult> StoryIntelligenceExperience([FromQuery] string? mode, [FromQuery] int? importSessionId, [FromQuery] int? runId)
{
if (!environment.IsDevelopment())
{
return NotFound();
}
var requestedMode = string.IsNullOrWhiteSpace(mode) ? "real" : mode.Trim();
var simulationRequested = string.Equals(requestedMode, "simulation", StringComparison.OrdinalIgnoreCase);
var replayRequested = string.Equals(requestedMode, "replay", StringComparison.OrdinalIgnoreCase);
if (!simulationRequested && importSessionId.HasValue && currentUser.UserId is int userId)
{
var realModel = replayRequested
? await snapshots.BuildReplaySnapshotAsync(importSessionId.Value, userId)
: await snapshots.BuildImportSessionSnapshotAsync(importSessionId.Value, userId);
if (realModel is null)
{
return View(ErrorModel(
requestedMode,
importSessionId,
$"Import Session {importSessionId.Value:N0} could not be found for the current user, or it has no persisted chapter analysis runs yet."));
}
return View(realModel);
}
if (!simulationRequested && runId.HasValue && currentUser.UserId is int legacyUserId)
{
var realModel = await snapshots.BuildSnapshotAsync(runId.Value, legacyUserId);
if (realModel is null)
{
return View(ErrorModel(
requestedMode,
null,
$"Run {runId.Value:N0} could not be found for the current user. Live visualisation URLs should use importSessionId for book imports."));
}
return View(realModel);
}
if (simulationRequested)
{
var model = StoryIntelligenceExperiencePrototypeData.Build();
await illustrationLibrary.ApplyApprovedIllustrationsAsync(model);
return View(model);
}
if (currentUser.UserId is int selectorUserId)
{
var sessions = await snapshots.ListImportSessionOptionsAsync(selectorUserId);
return View(new StoryIntelligenceExperiencePrototypeViewModel
{
Mode = "selector",
AvailableRuns = sessions,
ProjectName = "Development",
BookTitle = "Story Intelligence",
SnapshotMessage = sessions.Count == 0
? "No persisted Story Intelligence import sessions were found for the current user. Start a real import, then refresh this page."
: "Choose a Story Intelligence import session, or explicitly open simulation mode.",
Diagnostics = new StoryIntelligenceExperienceSnapshotDiagnostics
{
RequestedMode = requestedMode,
RequestedImportSessionId = importSessionId,
SnapshotSource = "Real"
}
});
}
return View(ErrorModel(requestedMode, importSessionId, "No development user is signed in."));
}
[HttpPost("StoryIntelligenceExperience/Rebuild")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RebuildStoryIntelligenceExperience(
[FromForm] int importSessionId,
[FromForm] string? rebuildScope,
[FromForm] bool dryRun = false,
[FromForm] bool queueDemand = false,
[FromForm] int? maxDemandToQueue = null,
[FromForm] string? appearancePreference = null)
{
if (!environment.IsDevelopment())
{
return NotFound();
}
if (importSessionId <= 0 || currentUser.UserId is not int userId)
{
return BadRequest("A valid importSessionId is required.");
}
var rebuild = await storyMemory.RebuildImportSessionAsync(
importSessionId,
userId,
new StoryMemoryRebuildOptions
{
DryRun = dryRun,
RebuildAll = !string.Equals(rebuildScope, "incremental", StringComparison.OrdinalIgnoreCase),
QueueDemand = queueDemand,
MaxDemandToQueue = maxDemandToQueue,
AppearancePreference = string.IsNullOrWhiteSpace(appearancePreference)
? StoryMemoryAppearancePreferences.Balanced
: appearancePreference.Trim()
},
HttpContext.RequestAborted);
if (rebuild.DryRun)
{
TempData["StoryIntelligenceReplayMessage"] = $"Dry run complete. {rebuild.SceneResultsRead:N0} stored scene result(s), {rebuild.SceneResultsProcessed:N0} parseable. No database changes, AI calls, or image credits.";
}
else
{
TempData["StoryIntelligenceReplayMessage"] = $"Durable Story Memory rebuilt. {rebuild.SceneResultsProcessed:N0} scene result(s) processed, {rebuild.AssignmentsCreatedOrValidated:N0} assignment(s), {rebuild.DemandsCreated:N0} demand record(s), {rebuild.AiCalls:N0} AI calls.";
}
return RedirectToAction(nameof(StoryIntelligenceExperience), new { importSessionId, mode = "replay" });
}
[HttpGet("StoryMemoryDiagnostics")]
public async Task<IActionResult> StoryMemoryDiagnostics([FromQuery] int importSessionId)
{
if (!environment.IsDevelopment())
{
return NotFound();
}
if (importSessionId <= 0)
{
return BadRequest("A valid importSessionId is required.");
}
var diagnostics = await storyMemoryRepository.GetDiagnosticsAsync(importSessionId);
return Json(diagnostics);
}
[HttpGet("StoryIntelligenceIllustrationDiagnostics")]
public async Task<IActionResult> StoryIntelligenceIllustrationDiagnostics([FromQuery] int importSessionId)
{
if (!environment.IsDevelopment())
{
return NotFound();
}
if (importSessionId <= 0 || currentUser.UserId is not int userId)
{
return BadRequest("A valid importSessionId is required.");
}
var model = await snapshots.BuildImportSessionSnapshotAsync(importSessionId, userId);
if (model is null)
{
return NotFound($"Import Session {importSessionId:N0} could not be found for the current user, or it has no persisted chapter analysis runs yet.");
}
return View(model);
}
private static StoryIntelligenceExperiencePrototypeViewModel ErrorModel(string requestedMode, int? importSessionId, string message)
=> new()
{
Mode = "error",
ProjectName = "Development",
BookTitle = "Story Intelligence",
SnapshotMessage = message,
Diagnostics = new StoryIntelligenceExperienceSnapshotDiagnostics
{
RequestedMode = requestedMode,
RequestedImportSessionId = importSessionId,
SnapshotSource = "Real",
Warning = message
}
};
}

View File

@ -1,21 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PlotLine.Models;
using PlotLine.Services;
namespace PlotLine.Controllers;
[Authorize]
public sealed class NamesController(INameIntelligenceService names) : Controller
{
public async Task<IActionResult> Index([FromQuery] NameSearchFilter filter, CancellationToken cancellationToken)
{
if (filter.ProjectID <= 0)
{
return BadRequest("ProjectID is required.");
}
var model = await names.BuildLibraryAsync(filter, cancellationToken);
return model is null ? NotFound() : View(model);
}
}

View File

@ -60,14 +60,6 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return RedirectToAction(nameof(Index));
}
var build = await onboarding.GetBuildResultAsync(model.PreviewID);
if (build is null || build.SceneMappings.Count == 0)
{
TempData["ArchiveError"] = "Import the approved chapters and scenes before reviewing characters.";
await onboarding.SetCurrentStepAsync(OnboardingSteps.NextPathPreview);
return RedirectToAction(nameof(Index));
}
ViewData["ScanReviewStep"] = "characters";
ViewData["CharacterReviewPage"] = Math.Max(1, page);
return View("ScanReview", model);
@ -84,23 +76,14 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
public async Task<IActionResult> StoryIntelligence()
{
var model = await storyIntelligence.GetOverviewAsync();
if (model.ExistingBatchID.HasValue)
{
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId = model.ExistingBatchID.Value });
}
if (model.PreviewID == Guid.Empty)
{
return RedirectToAction(nameof(Index));
}
return RedirectToAction(nameof(BuildComplete), new { previewId = model.PreviewID });
return View(model);
}
[HttpGet("story-intelligence/context")]
public async Task<IActionResult> StoryIntelligenceContext()
{
return await StoryIntelligence();
var model = await storyIntelligence.GetOverviewAsync();
return View(model);
}
[HttpGet("story-intelligence/book/{bookId:int}/continue")]
@ -116,7 +99,6 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return target.Route switch
{
StoryIntelligenceResumeRoutes.Complete => RedirectToAction(nameof(StoryIntelligenceComplete), new { batchId = target.BatchID }),
StoryIntelligenceResumeRoutes.Narrative => RedirectToAction(nameof(StoryIntelligenceNarrative), new { batchId = target.BatchID }),
StoryIntelligenceResumeRoutes.Knowledge => RedirectToAction(nameof(StoryIntelligenceKnowledge), new { batchId = target.BatchID }),
StoryIntelligenceResumeRoutes.Relationships => RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId = target.BatchID }),
StoryIntelligenceResumeRoutes.Assets => RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId = target.BatchID }),
@ -128,46 +110,22 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
[HttpPost("story-intelligence/start")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> StartStoryIntelligence()
public async Task<IActionResult> StartStoryIntelligence(
[FromForm] string? appearancePreference,
[FromForm] string? primaryAgeGroup,
[FromForm] string? storyEra,
[FromForm] string? storyLocation)
{
var job = await storyIntelligence.StartAsync();
var job = await storyIntelligence.StartAsync(appearancePreference, primaryAgeGroup, storyEra, storyLocation);
if (job is null)
{
TempData["OnboardingStoryIntelligenceError"] = "Review the manuscript scan before starting Story Intelligence.";
return RedirectToAction(nameof(Index));
return RedirectToAction(nameof(StoryIntelligenceContext));
}
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId = job.BatchID });
}
[HttpPost("story-intelligence/book/{bookId:int}/start")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> StartBookStoryIntelligence(int bookId)
{
var existing = await storyIntelligence.ResumeBookAsync(bookId);
if (existing is not null)
{
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId = existing.BatchID });
}
try
{
var job = await storyIntelligence.StartBookAsync(bookId);
if (job is null)
{
TempData["ArchiveError"] = "Story Intelligence could not find that book.";
return RedirectToAction("Details", "Books", new { id = bookId });
}
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId = job.BatchID });
}
catch (InvalidOperationException ex)
{
TempData["ArchiveError"] = ex.Message;
return RedirectToAction("Details", "Books", new { id = bookId });
}
}
[HttpPost("story-intelligence/skip")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SkipStoryIntelligence()
@ -521,73 +479,6 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return model is null ? NotFound() : View(model);
}
[HttpGet("story-intelligence/narrative")]
public async Task<IActionResult> StoryIntelligenceNarrative(Guid batchId)
{
var model = await storyIntelligence.GetProgressAsync(batchId);
if (model is null)
{
return NotFound();
}
if (model.HasActiveRuns)
{
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId });
}
if (!model.AllCommitted)
{
return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId });
}
if (!model.PipelineDashboard.CharacterStageComplete)
{
return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId });
}
if (!model.PipelineDashboard.LocationStageComplete)
{
return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId });
}
if (!model.PipelineDashboard.AssetStageComplete)
{
return RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId });
}
if (!model.PipelineDashboard.RelationshipStageComplete)
{
return RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId });
}
return !model.PipelineDashboard.KnowledgeStageComplete
? RedirectToAction(nameof(StoryIntelligenceKnowledge), new { batchId })
: View(model);
}
[HttpPost("story-intelligence/narrative")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ImportStoryIntelligenceNarrative(StoryIntelligenceNarrativeImportForm form)
{
var (progress, result) = await storyIntelligence.ImportNarrativeAsync(form.BatchID, form);
if (progress is null)
{
return NotFound();
}
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
return result.Success
? RedirectToAction(nameof(StoryIntelligenceNarrativeComplete), new { batchId = form.BatchID })
: RedirectToAction(nameof(StoryIntelligenceNarrative), new { batchId = form.BatchID });
}
[HttpGet("story-intelligence/narrative/complete")]
public async Task<IActionResult> StoryIntelligenceNarrativeComplete(Guid batchId)
{
var model = await storyIntelligence.GetNarrativeImportResultAsync(batchId);
return model is null ? NotFound() : View(model);
}
[HttpGet("story-intelligence/complete")]
public async Task<IActionResult> StoryIntelligenceComplete(Guid batchId)
{
@ -622,11 +513,6 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return RedirectToAction(nameof(StoryIntelligenceKnowledge), new { batchId });
}
if (!progress.PipelineDashboard.NarrativeStageComplete)
{
return RedirectToAction(nameof(StoryIntelligenceNarrative), new { batchId });
}
var model = await storyIntelligence.GetCompletionAsync(batchId);
return model is null ? NotFound() : View(model);
}
@ -640,47 +526,23 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
var continueToCharacters = string.Equals(intent, "characters", StringComparison.OrdinalIgnoreCase);
var continueCharacterReview = string.Equals(intent, "character-next", StringComparison.OrdinalIgnoreCase);
var backCharacterReview = string.Equals(intent, "character-back", StringComparison.OrdinalIgnoreCase);
var readyToImport = string.Equals(intent, "structure", StringComparison.OrdinalIgnoreCase);
var completeCoreImport = string.Equals(intent, "continue", StringComparison.OrdinalIgnoreCase);
var readyToImport = string.Equals(intent, "continue", StringComparison.OrdinalIgnoreCase);
var nextCharacterPage = Math.Max(1, characterPage + (backCharacterReview ? -1 : 1));
try
{
if (completeCoreImport)
{
var result = await onboarding.FinaliseCharacterReviewAsync(form);
if (result is null)
{
return NotFound();
}
TempData["OnboardingReviewMessage"] = "Characters saved. Core manuscript import is complete.";
return RedirectToAction(nameof(BuildComplete), new { previewId = form.PreviewID });
}
var model = await onboarding.SaveScanReviewAsync(form, readyToImport);
if (model is null)
{
return NotFound();
}
if (continueToCharacters)
{
var build = await onboarding.GetBuildResultAsync(form.PreviewID);
if (build is null || build.SceneMappings.Count == 0)
{
TempData["ArchiveError"] = "Import the approved chapters and scenes before reviewing characters.";
await onboarding.SetCurrentStepAsync(OnboardingSteps.NextPathPreview);
return RedirectToAction(nameof(Index));
}
}
TempData["OnboardingReviewMessage"] = readyToImport
? "Chapter review saved. Next: import scenes and update Word."
? "Review saved. Next: story and portrait context."
: continueToCharacters ? "Chapter and scene review saved. Next: review detected characters."
: continueCharacterReview || backCharacterReview ? "Character review choices saved."
: "Review choices saved.";
return readyToImport
? RedirectToAction(nameof(Index))
? RedirectToAction(nameof(StoryIntelligence))
: continueCharacterReview || backCharacterReview ? RedirectToAction(nameof(ScanCharacters), new { previewId = form.PreviewID, page = nextCharacterPage })
: continueToCharacters ? RedirectToAction(nameof(ScanCharacters), new { previewId = form.PreviewID })
: RedirectToAction(nameof(ScanReview), new { previewId = form.PreviewID });
@ -694,7 +556,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return NotFound();
}
if (continueToCharacters || continueCharacterReview || backCharacterReview || completeCoreImport)
if (readyToImport || continueCharacterReview || backCharacterReview)
{
ViewData["ScanReviewStep"] = "characters";
ViewData["CharacterReviewPage"] = readyToImport

View File

@ -50,10 +50,8 @@ public sealed class ProjectMetricsController(ISceneMetricTypeService metrics) :
[ValidateAntiForgeryToken]
public async Task<IActionResult> AddMissingDefaults(int projectId)
{
var backfilled = await metrics.AddMissingDefaultsAsync(projectId);
TempData["ProjectMetricMessage"] = backfilled > 0
? $"Missing default scene metrics added. {backfilled:N0} scene metric value(s) backfilled."
: "Missing default scene metrics added. Existing scenes already had values for the enabled defaults.";
await metrics.AddMissingDefaultsAsync(projectId);
TempData["ProjectMetricMessage"] = "Missing default scene metrics added.";
return RedirectToAction(nameof(Index), new { projectId });
}

View File

@ -6,7 +6,7 @@ using PlotLine.ViewModels;
namespace PlotLine.Controllers;
[Authorize]
public sealed class ProjectsController(IProjectService projects, IProjectActivityService activity, ILogger<ProjectsController> logger) : Controller
public sealed class ProjectsController(IProjectService projects, IProjectActivityService activity) : Controller
{
public async Task<IActionResult> Index() => View(await projects.ListAsync());
@ -92,19 +92,15 @@ public sealed class ProjectsController(IProjectService projects, IProjectActivit
[ValidateAntiForgeryToken]
public async Task<IActionResult> HardDeleteArchivedProject(int projectId, string? confirmationName)
{
logger.LogWarning("Permanent project delete requested for archived project {ProjectID}.", projectId);
var project = await projects.GetArchivedOwnerProjectAsync(projectId);
if (project is null)
{
logger.LogWarning("Permanent project delete rejected for project {ProjectID}: project is not archived or current user is not owner.", projectId);
TempData["ArchiveError"] = "Only an archived project owner can permanently delete a project.";
return RedirectToAction(nameof(Index));
}
if (!string.Equals(confirmationName?.Trim(), project.ProjectName, StringComparison.Ordinal))
{
logger.LogWarning("Permanent project delete rejected for archived project {ProjectID}: confirmation name did not match.", projectId);
TempData["ArchiveError"] = "Type the project name exactly to permanently delete it.";
return RedirectToAction(nameof(Index));
}
@ -112,12 +108,10 @@ public sealed class ProjectsController(IProjectService projects, IProjectActivit
var result = await projects.HardDeleteAsync(projectId);
if (result.Succeeded)
{
logger.LogWarning("Permanent project delete completed for archived project {ProjectID}.", projectId);
TempData["ArchiveMessage"] = "Project permanently deleted.";
}
else
{
logger.LogWarning("Permanent project delete failed for archived project {ProjectID}: {Message}", projectId, result.Message);
TempData["ArchiveError"] = result.NotArchived
? "Only archived projects can be permanently deleted."
: result.Message;

View File

@ -0,0 +1,61 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PlotLine.Services;
namespace PlotLine.Controllers;
[Authorize]
[Route("api/story-intelligence")]
public sealed class StoryIntelligenceVisualisationController(
ICurrentUserService currentUser,
IStoryIntelligenceVisualisationSnapshotService snapshots,
ILogger<StoryIntelligenceVisualisationController> logger) : ControllerBase
{
[HttpGet("runs/{runId:int}/visualisation-snapshot")]
public async Task<IActionResult> GetSnapshot(int runId)
{
if (currentUser.UserId is not int userId)
{
return Unauthorized();
}
var snapshot = await snapshots.BuildSnapshotAsync(runId, userId);
if (snapshot is not null)
{
var current = snapshot.Scenes.FirstOrDefault(scene => scene.IsCurrent) ?? snapshot.Scenes.LastOrDefault();
logger.LogInformation(
"Story Intelligence browser endpoint synchronisation: import session {ImportSessionId}; run {RunId}; current chapter {CurrentChapter}; current scene {CurrentScene}; scene result {SceneResultId}; snapshot version Endpoint; change token {ChangeToken}.",
snapshot.ImportSessionId,
snapshot.RunId,
current?.ChapterLabel,
current?.SceneNumber,
snapshot.Diagnostics.SelectedCurrentSceneResultId,
snapshot.ChangeToken);
}
return snapshot is null ? NotFound() : Ok(snapshot);
}
[HttpGet("import-sessions/{importSessionId:int}/visualisation-snapshot")]
public async Task<IActionResult> GetImportSessionSnapshot(int importSessionId)
{
if (currentUser.UserId is not int userId)
{
return Unauthorized();
}
var snapshot = await snapshots.BuildImportSessionSnapshotAsync(importSessionId, userId);
if (snapshot is not null)
{
var current = snapshot.Scenes.FirstOrDefault(scene => scene.IsCurrent) ?? snapshot.Scenes.LastOrDefault();
logger.LogInformation(
"Story Intelligence browser endpoint synchronisation: import session {ImportSessionId}; run {RunId}; current chapter {CurrentChapter}; current scene {CurrentScene}; scene result {SceneResultId}; snapshot version Endpoint; change token {ChangeToken}.",
snapshot.ImportSessionId,
snapshot.RunId,
current?.ChapterLabel,
current?.SceneNumber,
snapshot.Diagnostics.SelectedCurrentSceneResultId,
snapshot.ChangeToken);
}
return snapshot is null ? NotFound() : Ok(snapshot);
}
}

View File

@ -13,9 +13,7 @@ namespace PlotLine.Controllers;
[Route("api/word-companion")]
public sealed class WordCompanionController(
IWordCompanionService wordCompanion,
IWordCompanionPresenceService presence,
IHubContext<WordCompanionFollowHub> followHub,
ILogger<WordCompanionController> logger) : ControllerBase
IHubContext<WordCompanionFollowHub> followHub) : ControllerBase
{
[HttpGet("projects")]
public Task<WordCompanionProjectsResponse> GetProjects() => wordCompanion.ListProjectsAsync();
@ -126,35 +124,12 @@ public sealed class WordCompanionController(
[HttpPost("runtime/current-scene")]
public async Task<IActionResult> UpdateRuntimeCurrentScene(WordCompanionRuntimeCurrentSceneRequest request)
{
logger.LogInformation(
"Word Companion current scene received. ProjectID={ProjectID} BookID={BookID} ChapterID={ChapterID} SceneID={SceneID} DocumentGuidPresent={DocumentGuidPresent}",
request.ProjectId,
request.BookId,
request.ChapterId,
request.SceneId,
request.DocumentGuid != Guid.Empty);
var response = await wordCompanion.ValidateRuntimeCurrentSceneAsync(request);
if (response is null)
{
logger.LogWarning(
"Word Companion current scene rejected. ProjectID={ProjectID} BookID={BookID} ChapterID={ChapterID} SceneID={SceneID} DocumentGuidPresent={DocumentGuidPresent}",
request.ProjectId,
request.BookId,
request.ChapterId,
request.SceneId,
request.DocumentGuid != Guid.Empty);
return BadRequest();
}
await presence.UpdateCurrentStoryPositionAsync(RequireUserId(), response.Position);
logger.LogInformation(
"Word Companion current scene accepted. ProjectID={ProjectID} BookID={BookID} ChapterID={ChapterID} SceneID={SceneID}",
response.ProjectId,
response.BookId,
response.ChapterId,
response.SceneId);
await BroadcastToCurrentUserAsync("wordCompanionSceneChanged", new
{
response.ProjectId,
@ -292,12 +267,4 @@ public sealed class WordCompanionController(
await followHub.Clients.User(userId).SendAsync(eventName, payload);
}
}
private int RequireUserId()
{
var raw = User.FindFirstValue(ClaimTypes.NameIdentifier);
return int.TryParse(raw, out var userId) && userId > 0
? userId
: throw new InvalidOperationException("The current user could not be identified.");
}
}

View File

@ -7,21 +7,13 @@ namespace PlotLine.Controllers;
[AllowAnonymous]
[Route("word-companion")]
public sealed class WordCompanionHostController(
ICurrentUserService currentUser,
IAuthorizationService authorization,
ILogger<WordCompanionHostController> logger) : Controller
public sealed class WordCompanionHostController(ICurrentUserService currentUser, IAuthorizationService authorization) : Controller
{
[HttpGet("")]
public async Task<IActionResult> Index()
{
var isAdmin = currentUser.IsAuthenticated
&& (await authorization.AuthorizeAsync(User, "AdminOnly")).Succeeded;
logger.LogInformation(
"Serving Word Companion host. IsAuthenticated={IsAuthenticated} UserID={UserID} IsAdmin={IsAdmin}",
currentUser.IsAuthenticated,
currentUser.UserId,
isAdmin);
return View(new WordCompanionHostViewModel
{
@ -33,3 +25,4 @@ public sealed class WordCompanionHostController(
});
}
}

View File

@ -13,60 +13,37 @@ public sealed class WritingController(
IStoryContextService storyContext,
IRelationshipMapService relationshipMap,
IFloorPlanService floorPlans,
IWriterWorkspaceService writerWorkspace,
ILogger<WritingController> logger) : Controller
IWriterWorkspaceService writerWorkspace) : Controller
{
[HttpGet("Context")]
public async Task<IActionResult> Context(int? projectId, int? bookId, int? chapterId, int? sceneId, Guid? documentGuid)
public async Task<IActionResult> Context(int? projectId, int? bookId, int? chapterId, int? sceneId)
{
logger.LogInformation(
"Opening Writing Context. ProjectID={ProjectID} BookID={BookID} ChapterID={ChapterID} SceneID={SceneID} DocumentGuidPresent={DocumentGuidPresent}",
projectId,
bookId,
chapterId,
sceneId,
documentGuid.HasValue);
var display = sceneId.HasValue
? await BuildDisplayAsync(projectId, bookId, chapterId, sceneId, "Manual context")
: WaitingContextDisplay(projectId, bookId, documentGuid);
var model = new WritingContextPageViewModel
{
ProjectID = projectId,
BookID = bookId,
ChapterID = chapterId,
SceneID = sceneId,
DocumentGuid = documentGuid,
Display = display ?? MissingContextDisplay(projectId, bookId, sceneId)
Display = sceneId.HasValue
? await BuildDisplayAsync(projectId, bookId, chapterId, sceneId, "Manual context")
: new WritingContextDisplayViewModel { FollowStatus = "Connected, waiting for scene" }
};
return View(model);
}
[HttpGet("Intent")]
public async Task<IActionResult> Intent(int? projectId, int? bookId, int? chapterId, int? sceneId, Guid? documentGuid)
public async Task<IActionResult> Intent(int? projectId, int? bookId, int? chapterId, int? sceneId)
{
logger.LogInformation(
"Opening Writing Intent. ProjectID={ProjectID} BookID={BookID} ChapterID={ChapterID} SceneID={SceneID} DocumentGuidPresent={DocumentGuidPresent}",
projectId,
bookId,
chapterId,
sceneId,
documentGuid.HasValue);
var display = sceneId.HasValue || chapterId.HasValue
? await BuildIntentDisplayAsync(projectId, bookId, chapterId, sceneId, "Manual intent")
: WaitingIntentDisplay(projectId, bookId, documentGuid);
var model = new WritingIntentPageViewModel
{
ProjectID = projectId,
BookID = bookId,
ChapterID = chapterId,
SceneID = sceneId,
DocumentGuid = documentGuid,
Display = display ?? MissingIntentDisplay(projectId, bookId, chapterId, sceneId)
Display = sceneId.HasValue || chapterId.HasValue
? await BuildIntentDisplayAsync(projectId, bookId, chapterId, sceneId, "Manual intent")
: new WritingIntentDisplayViewModel { FollowStatus = "Connected, waiting for scene" }
};
return View(model);
@ -77,18 +54,16 @@ public sealed class WritingController(
{
if (!sceneId.HasValue)
{
logger.LogWarning("Writing Context partial requested without SceneID. ProjectID={ProjectID} BookID={BookID} ChapterID={ChapterID}", projectId, bookId, chapterId);
return PartialView("~/Views/Writing/_WritingContextContent.cshtml", WaitingContextDisplay(projectId, bookId, null));
return PartialView("~/Views/Writing/_WritingContextContent.cshtml", new WritingContextDisplayViewModel
{
FollowStatus = "Connected, waiting for scene"
});
}
var model = await BuildDisplayAsync(projectId, bookId, chapterId, sceneId, "Following Word");
if (model?.HasScene == true)
{
return PartialView("~/Views/Writing/_WritingContextContent.cshtml", model);
}
logger.LogWarning("Writing Context could not resolve scene. ProjectID={ProjectID} BookID={BookID} ChapterID={ChapterID} SceneID={SceneID}", projectId, bookId, chapterId, sceneId);
return PartialView("~/Views/Writing/_WritingContextContent.cshtml", MissingContextDisplay(projectId, bookId, sceneId));
return model?.HasScene == true
? PartialView("~/Views/Writing/_WritingContextContent.cshtml", model)
: NotFound();
}
[HttpGet("Intent/Scene")]
@ -96,18 +71,16 @@ public sealed class WritingController(
{
if (!sceneId.HasValue && !chapterId.HasValue)
{
logger.LogWarning("Writing Intent partial requested without ChapterID or SceneID. ProjectID={ProjectID} BookID={BookID}", projectId, bookId);
return PartialView("~/Views/Writing/_WritingIntentContent.cshtml", WaitingIntentDisplay(projectId, bookId, null));
return PartialView("~/Views/Writing/_WritingIntentContent.cshtml", new WritingIntentDisplayViewModel
{
FollowStatus = "Connected, waiting for scene"
});
}
var model = await BuildIntentDisplayAsync(projectId, bookId, chapterId, sceneId, "Following Word");
if (model is not null && (model.HasScene || model.HasChapter))
{
return PartialView("~/Views/Writing/_WritingIntentContent.cshtml", model);
}
logger.LogWarning("Writing Intent could not resolve chapter/scene. ProjectID={ProjectID} BookID={BookID} ChapterID={ChapterID} SceneID={SceneID}", projectId, bookId, chapterId, sceneId);
return PartialView("~/Views/Writing/_WritingIntentContent.cshtml", MissingIntentDisplay(projectId, bookId, chapterId, sceneId));
return model is not null && (model.HasScene || model.HasChapter)
? PartialView("~/Views/Writing/_WritingIntentContent.cshtml", model)
: NotFound();
}
private async Task<WritingContextDisplayViewModel?> BuildDisplayAsync(int? projectId, int? bookId, int? chapterId, int? sceneId, string followStatus)
@ -215,42 +188,4 @@ public sealed class WritingController(
.ToList()
?? [];
}
private static WritingContextDisplayViewModel WaitingContextDisplay(int? projectId, int? bookId, Guid? documentGuid) => new()
{
FollowStatus = "No current Word scene received",
EmptyTitle = "No current Word scene received",
EmptyMessage = MissingScopeMessage(projectId, bookId, documentGuid, "Context")
};
private static WritingIntentDisplayViewModel WaitingIntentDisplay(int? projectId, int? bookId, Guid? documentGuid) => new()
{
FollowStatus = "No current Word scene received",
EmptyTitle = "No current Word scene received",
EmptyMessage = MissingScopeMessage(projectId, bookId, documentGuid, "Intent")
};
private static WritingContextDisplayViewModel MissingContextDisplay(int? projectId, int? bookId, int? sceneId) => new()
{
FollowStatus = "Context unavailable",
EmptyTitle = "No Story Intelligence context is available",
EmptyMessage = $"PlotDirector could not load context for scene {sceneId?.ToString() ?? "unknown"} in project {projectId?.ToString() ?? "unknown"}, book {bookId?.ToString() ?? "unknown"}."
};
private static WritingIntentDisplayViewModel MissingIntentDisplay(int? projectId, int? bookId, int? chapterId, int? sceneId) => new()
{
FollowStatus = "Intent unavailable",
EmptyTitle = "No writing intent is available",
EmptyMessage = $"PlotDirector could not load writing intent for chapter {chapterId?.ToString() ?? "unknown"}, scene {sceneId?.ToString() ?? "none"} in project {projectId?.ToString() ?? "unknown"}, book {bookId?.ToString() ?? "unknown"}."
};
private static string MissingScopeMessage(int? projectId, int? bookId, Guid? documentGuid, string screen)
{
if (!projectId.HasValue || !bookId.HasValue)
{
return $"{screen} did not receive a project/book from Word Companion. Reopen it from a linked manuscript, or choose a scene manually.";
}
return $"Waiting for Word Companion to report the current scene for project {projectId}, book {bookId}{(documentGuid.HasValue ? " and this Word document" : "")}.";
}
}

View File

@ -107,9 +107,6 @@ public sealed class IllustrationLibraryRepository(ISqlConnectionFactory connecti
image.MimeType,
Provider = providerResult.Provider,
Model = providerResult.Model,
providerResult.Quality,
providerResult.Size,
providerResult.OutputFormat,
PromptText = prompt,
PromptTemplateVersion = templateVersion,
ProviderRequestID = providerResult.ProviderRequestID

View File

@ -1,81 +0,0 @@
using System.Data;
using Dapper;
using PlotLine.Models;
namespace PlotLine.Data;
public interface IManuscriptTextStatisticsRepository
{
Task SaveAsync(int sceneId, ManuscriptTextStatistics statistics);
Task<ManuscriptTextStatistics?> GetBySceneAsync(int sceneId);
Task<IReadOnlyDictionary<int, ManuscriptTextStatistics>> ListByChapterAsync(int chapterId);
Task<SceneManuscriptSourceRecord?> GetSourceBySceneAsync(int sceneId);
}
public sealed class ManuscriptTextStatisticsRepository(ISqlConnectionFactory connectionFactory) : IManuscriptTextStatisticsRepository
{
public async Task SaveAsync(int sceneId, ManuscriptTextStatistics statistics)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.SceneTextStatistics_Save",
new
{
SceneID = sceneId,
statistics.TotalWordCount,
statistics.DialogueWordCount,
statistics.ProseWordCount,
statistics.TotalParagraphCount,
statistics.DialogueParagraphCount,
statistics.ProseParagraphCount
},
commandType: CommandType.StoredProcedure);
}
public async Task<ManuscriptTextStatistics?> GetBySceneAsync(int sceneId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<ManuscriptTextStatistics>(
"dbo.SceneTextStatistics_GetByScene",
new { SceneID = sceneId },
commandType: CommandType.StoredProcedure);
}
public async Task<IReadOnlyDictionary<int, ManuscriptTextStatistics>> ListByChapterAsync(int chapterId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<SceneTextStatisticsRow>(
"dbo.SceneTextStatistics_ListByChapter",
new { ChapterID = chapterId },
commandType: CommandType.StoredProcedure);
return rows.ToDictionary(row => row.SceneID, row => new ManuscriptTextStatistics
{
TotalWordCount = row.TotalWordCount,
DialogueWordCount = row.DialogueWordCount,
ProseWordCount = row.ProseWordCount,
TotalParagraphCount = row.TotalParagraphCount,
DialogueParagraphCount = row.DialogueParagraphCount,
ProseParagraphCount = row.ProseParagraphCount
});
}
public async Task<SceneManuscriptSourceRecord?> GetSourceBySceneAsync(int sceneId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<SceneManuscriptSourceRecord>(
"dbo.SceneManuscriptSource_GetByScene",
new { SceneID = sceneId },
commandType: CommandType.StoredProcedure);
}
private sealed class SceneTextStatisticsRow
{
public int SceneID { get; init; }
public int TotalWordCount { get; init; }
public int DialogueWordCount { get; init; }
public int ProseWordCount { get; init; }
public int TotalParagraphCount { get; init; }
public int DialogueParagraphCount { get; init; }
public int ProseParagraphCount { get; init; }
}
}

View File

@ -1,161 +0,0 @@
using System.Data;
using Dapper;
using Microsoft.Data.SqlClient;
using PlotLine.Models;
namespace PlotLine.Data;
public interface INameIntelligenceRepository
{
Task<NameReferenceImportStatus?> GetLatestImportAsync(string packageId, string version, CancellationToken cancellationToken = default);
Task<int> StartImportAsync(string packageId, string version, string checksum, IDbConnection connection, IDbTransaction transaction, CancellationToken cancellationToken = default);
Task CompleteImportAsync(int importId, bool succeeded, int localeRows, int givenNameRows, int relationshipRows, int usageRows, string? errorSummary, IDbConnection connection, IDbTransaction transaction, CancellationToken cancellationToken = default);
Task UpsertLocalesAsync(DataTable rows, IDbConnection connection, IDbTransaction transaction, CancellationToken cancellationToken = default);
Task UpsertGivenNamesAsync(DataTable rows, IDbConnection connection, IDbTransaction transaction, CancellationToken cancellationToken = default);
Task UpsertRelationshipsAsync(DataTable rows, IDbConnection connection, IDbTransaction transaction, CancellationToken cancellationToken = default);
Task UpsertUsagesAsync(DataTable rows, IDbConnection connection, IDbTransaction transaction, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NameLocale>> ListLocalesAsync(CancellationToken cancellationToken = default);
Task<NameRecognitionResult> RecogniseAsync(string normalisedName, string? localeCode, int? birthYear, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NameRelationshipReference>> ListRelationshipsAsync(int givenNameId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NameSearchResult>> SearchAsync(int userId, NameSearchFilter filter, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NameUsageDecoration>> ListUsedDecorationsAsync(int userId, int projectId, int? bookId, string normalisedName, string usedScope, CancellationToken cancellationToken = default);
}
public sealed class NameIntelligenceRepository(ISqlConnectionFactory connectionFactory) : INameIntelligenceRepository
{
public async Task<NameReferenceImportStatus?> GetLatestImportAsync(string packageId, string version, CancellationToken cancellationToken = default)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<NameReferenceImportStatus>(Command(
"dbo.NameReferenceImport_GetLatest",
new { PackageID = packageId, SemanticVersion = version },
cancellationToken: cancellationToken));
}
public async Task<int> StartImportAsync(string packageId, string version, string checksum, IDbConnection connection, IDbTransaction transaction, CancellationToken cancellationToken = default)
=> await connection.QuerySingleAsync<int>(Command(
"dbo.NameReferenceImport_Start",
new { PackageID = packageId, SemanticVersion = version, PackageChecksum = checksum },
transaction,
cancellationToken));
public Task CompleteImportAsync(int importId, bool succeeded, int localeRows, int givenNameRows, int relationshipRows, int usageRows, string? errorSummary, IDbConnection connection, IDbTransaction transaction, CancellationToken cancellationToken = default)
=> connection.ExecuteAsync(Command(
"dbo.NameReferenceImport_Complete",
new
{
NameReferenceImportID = importId,
Succeeded = succeeded,
LocaleRows = localeRows,
GivenNameRows = givenNameRows,
RelationshipRows = relationshipRows,
UsageRows = usageRows,
ErrorSummary = errorSummary
},
transaction,
cancellationToken));
public Task UpsertLocalesAsync(DataTable rows, IDbConnection connection, IDbTransaction transaction, CancellationToken cancellationToken = default)
=> ExecuteTableAsync("dbo.NameLocale_BulkUpsert", "Rows", "dbo.NameLocaleImportTable", rows, connection, transaction, cancellationToken);
public Task UpsertGivenNamesAsync(DataTable rows, IDbConnection connection, IDbTransaction transaction, CancellationToken cancellationToken = default)
=> ExecuteTableAsync("dbo.GivenName_BulkUpsert", "Rows", "dbo.GivenNameImportTable", rows, connection, transaction, cancellationToken);
public Task UpsertRelationshipsAsync(DataTable rows, IDbConnection connection, IDbTransaction transaction, CancellationToken cancellationToken = default)
=> ExecuteTableAsync("dbo.NameRelationship_BulkUpsert", "Rows", "dbo.NameRelationshipImportTable", rows, connection, transaction, cancellationToken);
public Task UpsertUsagesAsync(DataTable rows, IDbConnection connection, IDbTransaction transaction, CancellationToken cancellationToken = default)
=> ExecuteTableAsync("dbo.NameUsage_BulkUpsert", "Rows", "dbo.NameUsageImportTable", rows, connection, transaction, cancellationToken);
public async Task<IReadOnlyList<NameLocale>> ListLocalesAsync(CancellationToken cancellationToken = default)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<NameLocale>(Command("dbo.NameLocale_List", cancellationToken: cancellationToken));
return rows.ToList();
}
public async Task<NameRecognitionResult> RecogniseAsync(string normalisedName, string? localeCode, int? birthYear, CancellationToken cancellationToken = default)
{
using var connection = connectionFactory.CreateConnection();
using var grid = await connection.QueryMultipleAsync(Command(
"dbo.GivenName_Recognise",
new { NormalisedName = normalisedName, LocaleCode = localeCode, BirthYear = birthYear },
cancellationToken: cancellationToken));
var name = await grid.ReadSingleOrDefaultAsync<GivenNameReference>();
if (name is null)
{
return new NameRecognitionResult();
}
var usage = await grid.ReadSingleOrDefaultAsync<NameUsageProfile>();
var related = (await grid.ReadAsync<NameRelationshipReference>()).ToList();
return new NameRecognitionResult
{
Recognised = true,
Name = name,
Usage = usage,
RelatedForms = related,
Message = "Recognised given-name form."
};
}
public async Task<IReadOnlyList<NameRelationshipReference>> ListRelationshipsAsync(int givenNameId, CancellationToken cancellationToken = default)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<NameRelationshipReference>(Command(
"dbo.GivenName_Relationships",
new { GivenNameID = givenNameId },
cancellationToken: cancellationToken));
return rows.ToList();
}
public async Task<IReadOnlyList<NameSearchResult>> SearchAsync(int userId, NameSearchFilter filter, CancellationToken cancellationToken = default)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<NameSearchResult>(Command(
"dbo.GivenName_Search",
new
{
UserID = userId,
filter.ProjectID,
filter.BookID,
Search = NullIfWhiteSpace(filter.Search),
LocaleCode = NullIfWhiteSpace(filter.LocaleCode),
filter.BirthYear,
GenderPreference = NullIfWhiteSpace(filter.GenderPreference),
filter.GenderThreshold,
NameType = NullIfWhiteSpace(filter.NameType),
filter.MinimumUsage,
filter.UsedScope,
filter.UsedOnly,
filter.Page,
filter.PageSize
},
cancellationToken: cancellationToken));
return rows.ToList();
}
public async Task<IReadOnlyList<NameUsageDecoration>> ListUsedDecorationsAsync(int userId, int projectId, int? bookId, string normalisedName, string usedScope, CancellationToken cancellationToken = default)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<NameUsageDecoration>(Command(
"dbo.GivenName_UsedDecorations",
new { UserID = userId, ProjectID = projectId, BookID = bookId, NormalisedName = normalisedName, UsedScope = usedScope },
cancellationToken: cancellationToken));
return rows.ToList();
}
public IDbConnection CreateConnection() => connectionFactory.CreateConnection();
private static async Task ExecuteTableAsync(string procedure, string parameterName, string typeName, DataTable rows, IDbConnection connection, IDbTransaction transaction, CancellationToken cancellationToken)
{
var parameters = new DynamicParameters();
parameters.Add(parameterName, rows.AsTableValuedParameter(typeName));
await connection.ExecuteAsync(Command(procedure, parameters, transaction, cancellationToken));
}
private static CommandDefinition Command(string commandText, object? parameters = null, IDbTransaction? transaction = null, CancellationToken cancellationToken = default)
=> new(commandText, parameters, transaction, commandType: CommandType.StoredProcedure, cancellationToken: cancellationToken);
private static string? NullIfWhiteSpace(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}

View File

@ -8,16 +8,6 @@ namespace PlotLine.Data;
public interface IOnboardingBuildRepository
{
Task<OnboardingManuscriptBuildResult?> BuildAsync(int userId, OnboardingManuscriptBuildRequest request);
Task<OnboardingManuscriptBuildResult?> GetByPreviewAsync(int userId, Guid previewId);
Task MarkMarkerSyncSentAsync(int buildId);
Task MarkMarkerSyncCompletedAsync(int buildId, string markerStatus, string message);
Task MarkMarkerSyncUserSaveRequiredAsync(int buildId, string message);
Task MarkMarkerSyncFailedAsync(int buildId, string message, string? diagnostics = null);
Task SaveStructuralChapterSourceAsync(StructuralChapterSourceSaveRequest request);
Task SaveSceneManuscriptSourceAsync(SceneManuscriptSourceSaveRequest request);
Task SaveSceneStructuralSummaryAsync(int sceneId, string structuralSummary, string? structuralSetting = null);
Task<IReadOnlyList<SceneManuscriptSourceRecord>> ListSceneManuscriptSourcesByChapterAsync(int chapterId);
Task<IReadOnlyList<SceneManuscriptSourceRecord>> ListSceneManuscriptSourcesByBookAsync(int bookId);
}
public sealed class OnboardingBuildRepository(ISqlConnectionFactory connectionFactory) : IOnboardingBuildRepository
@ -41,22 +31,6 @@ public sealed class OnboardingBuildRepository(ISqlConnectionFactory connectionFa
},
commandType: CommandType.StoredProcedure);
return await ReadBuildResultAsync(result);
}
public async Task<OnboardingManuscriptBuildResult?> GetByPreviewAsync(int userId, Guid previewId)
{
using var connection = connectionFactory.CreateConnection();
using var result = await connection.QueryMultipleAsync(
"dbo.Onboarding_ManuscriptBuild_GetByPreview",
new { UserID = userId, PreviewID = previewId },
commandType: CommandType.StoredProcedure);
return await ReadBuildResultAsync(result);
}
private static async Task<OnboardingManuscriptBuildResult?> ReadBuildResultAsync(SqlMapper.GridReader result)
{
var header = await result.ReadSingleOrDefaultAsync<OnboardingManuscriptBuildResult>();
if (header is null)
{
@ -87,115 +61,6 @@ public sealed class OnboardingBuildRepository(ISqlConnectionFactory connectionFa
CharacterMappings = characters
};
}
public async Task MarkMarkerSyncSentAsync(int buildId)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.Onboarding_ManuscriptBuild_MarkerSyncSent",
new { BuildID = buildId },
commandType: CommandType.StoredProcedure);
}
public async Task MarkMarkerSyncCompletedAsync(int buildId, string markerStatus, string message)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.Onboarding_ManuscriptBuild_MarkerSyncCompleted",
new { BuildID = buildId, MarkerStatus = markerStatus, Message = message },
commandType: CommandType.StoredProcedure);
}
public async Task MarkMarkerSyncUserSaveRequiredAsync(int buildId, string message)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.Onboarding_ManuscriptBuild_MarkerSyncUserSaveRequired",
new { BuildID = buildId, Message = message },
commandType: CommandType.StoredProcedure);
}
public async Task MarkMarkerSyncFailedAsync(int buildId, string message, string? diagnostics = null)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.Onboarding_ManuscriptBuild_MarkerSyncFailed",
new { BuildID = buildId, Message = message, Diagnostics = diagnostics },
commandType: CommandType.StoredProcedure);
}
public async Task SaveStructuralChapterSourceAsync(StructuralChapterSourceSaveRequest request)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.ManuscriptStructuralChapterSource_Save",
new
{
request.ChapterID,
request.ProjectID,
request.BookID,
request.SourceText,
request.SourceWordCount,
request.SourceCharacterCount,
request.ParagraphMapJson,
request.StructuralParsedJson,
request.DefaultPovName,
request.ExplicitNarratorName
},
commandType: CommandType.StoredProcedure);
}
public async Task SaveSceneManuscriptSourceAsync(SceneManuscriptSourceSaveRequest request)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.SceneManuscriptSource_Save",
new
{
request.SceneID,
request.ProjectID,
request.BookID,
request.ChapterID,
request.SourceText,
request.SourceWordCount,
request.SourceCharacterCount,
request.SourceStartParagraph,
request.SourceEndParagraph,
request.WordStartParagraphIndex,
request.WordEndParagraphIndex,
request.SourceKind
},
commandType: CommandType.StoredProcedure);
}
public async Task SaveSceneStructuralSummaryAsync(int sceneId, string structuralSummary, string? structuralSetting = null)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.SceneStructuralSummary_Save",
new { SceneID = sceneId, StructuralSummary = structuralSummary, StructuralSetting = structuralSetting },
commandType: CommandType.StoredProcedure);
}
public async Task<IReadOnlyList<SceneManuscriptSourceRecord>> ListSceneManuscriptSourcesByBookAsync(int bookId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<SceneManuscriptSourceRecord>(
"dbo.SceneManuscriptSource_ListByBook",
new { BookID = bookId },
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<IReadOnlyList<SceneManuscriptSourceRecord>> ListSceneManuscriptSourcesByChapterAsync(int chapterId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<SceneManuscriptSourceRecord>(
"dbo.SceneManuscriptSource_ListByChapter",
new { ChapterID = chapterId },
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
}
public sealed class OnboardingManuscriptBuildRequest
@ -222,7 +87,6 @@ public sealed class OnboardingBuildSceneRequest
public string TemporarySceneKey { get; init; } = string.Empty;
public string TemporaryChapterKey { get; init; } = string.Empty;
public string Title { get; init; } = string.Empty;
public string? SuggestedTitle { get; init; }
public int SortOrder { get; init; }
public int WordCount { get; init; }
}
@ -233,54 +97,3 @@ public sealed class OnboardingBuildCharacterRequest
public string Name { get; init; } = string.Empty;
public int? ExistingCharacterID { get; init; }
}
public sealed class StructuralChapterSourceSaveRequest
{
public int ChapterID { get; init; }
public int ProjectID { get; init; }
public int BookID { get; init; }
public string SourceText { get; init; } = string.Empty;
public int SourceWordCount { get; init; }
public int SourceCharacterCount { get; init; }
public string? ParagraphMapJson { get; init; }
public string? StructuralParsedJson { get; init; }
public string? DefaultPovName { get; init; }
public string? ExplicitNarratorName { get; init; }
}
public sealed class SceneManuscriptSourceSaveRequest
{
public int SceneID { get; init; }
public int ProjectID { get; init; }
public int BookID { get; init; }
public int ChapterID { get; init; }
public string SourceText { get; init; } = string.Empty;
public int SourceWordCount { get; init; }
public int SourceCharacterCount { get; init; }
public int? SourceStartParagraph { get; init; }
public int? SourceEndParagraph { get; init; }
public int? WordStartParagraphIndex { get; init; }
public int? WordEndParagraphIndex { get; init; }
public string SourceKind { get; init; } = "StructuralImport";
}
public sealed class SceneManuscriptSourceRecord
{
public int SceneID { get; init; }
public decimal SceneNumber { get; init; }
public string? SceneTitle { get; init; }
public string? SceneTitleSource { get; init; }
public int ProjectID { get; init; }
public int BookID { get; init; }
public int ChapterID { get; init; }
public string SourceText { get; init; } = string.Empty;
public int SourceWordCount { get; init; }
public int SourceCharacterCount { get; init; }
public int? SourceStartParagraph { get; init; }
public int? SourceEndParagraph { get; init; }
public int? WordStartParagraphIndex { get; init; }
public int? WordEndParagraphIndex { get; init; }
public string SourceKind { get; init; } = string.Empty;
public string? StructuralSummary { get; init; }
public string? StructuralSetting { get; init; }
}

View File

@ -1,32 +0,0 @@
# Data contract
All four datasets are shared/global and contain no user, project, series, book, character or alias ownership.
## GivenNames
`given_name_key` is the immutable import identity (`gn_elizabeth`); database implementations may additionally use an integer surrogate PK. `display_name` preserves spelling. `normalised_name` is the lookup value. `name_type` is one of `Canonical`, `Independent`, `Diminutive`, `Nickname`, or `Variant`. `is_word_ambiguous` asks parsers to require stronger context. `is_active` supports non-destructive correction.
## NameRelationships
`relationship_key` is immutable import identity. `from_given_name_key` and `to_given_name_key` reference GivenNames. `relationship_type` is one of `ShortForm`, `Diminutive`, `Nickname`, `SpellingVariant`, `AlternativeForm`, or `AnglicisedForm`. `strength` describes confidence in the linguistic relationship, not confidence that two manuscript mentions identify one character. `is_bidirectional` controls graph traversal. `curation_source` preserves provenance.
## NameUsages
Each row covers one recognised form, one locale and one inclusive birth-year range. `male_weight` + `female_weight` equals 1.0 within tolerance. These are gender priors. `usage_weight` is relative recognisability within a locale/era and is suitable for ranking, not as an absolute population probability. `confidence` describes evidential confidence. `source_type` separates direct statistical derivation from blended estimates.
Lookup precedence: known birth year → estimated birth year → story year fallback → nearest band. Use exact locale first. Do not silently treat US evidence as another locale’s direct statistic.
## NameLocales
Supported codes: `en-GB`, `en-IE`, `en-US`, `en-CA`, `en-AU`, `en-NZ`. `evidence_basis` and `default_confidence` make limitations visible.
## Recommended constraints
- Unique stable keys in every table.
- Unique Unicode-aware `GivenNames.NormalisedName` under an explicitly chosen collation.
- Unique active `(FromNameId, ToNameId, RelationshipType)`.
- Unique active `(GivenNameId, LocaleId, BirthYearFrom, BirthYearTo)`.
- All weights/confidence in `[0,1]`; gender weights sum to 1 within `0.0002`.
- Ordered, non-overlapping usage bands.
- Reference tables read-only to ordinary users.

View File

@ -1,30 +0,0 @@
# Methodology and limitations
## Inclusion
The seed selects names with sustained or count-based evidence in US or UK birth-registration series from 1900 onward, then retains every required hand-curated canonical, diminutive, nickname and variant from the prototype. The result is intentionally broad enough to include established names of many linguistic origins used in English-speaking societies.
## Usage profiles
- `en-US`: derived from US Social Security Administration annual national name counts.
- `en-GB`: derived from the unified ONS, National Records of Scotland and NISRA series. Historical ranked rows lacking counts receive a decreasing rank-based proxy so the period remains usable without implying exact births.
- `en-IE`: Northern Ireland evidence with conservative UK/US smoothing; labelled as an estimate rather than Republic of Ireland direct statistics.
- `en-CA`: a documented US/UK evidence blend.
- `en-AU` and `en-NZ`: documented UK/US evidence blends.
Within each locale and band, gender weights use observed counts with light smoothing. Usage weights use a logarithmic scale relative to the strongest name in the same evidence pool, preventing a few extremely common names from flattening the remainder. Zero-count bands retain a low usage weight and a broader all-era gender prior with reduced confidence.
## Sources
- SSA Popular Baby Names: https://www.ssa.gov/oact/babynames/limits.html
- SSA qualifications: https://www.ssa.gov/OACT/babynames/background.html
- ONS baby names: https://www.ons.gov.uk/peoplepopulationandcommunity/birthsdeathsandmarriages/livebirths/datasets/babynamesinenglandandwalesfrom1996/1996tocurrent
- UK unified processing and upstream source links: https://github.com/rossbowen/babynames
- SSA mirror and processing documentation: https://github.com/hackerb9/ssa-baby-names
- Curated nickname mappings: https://github.com/dxdc/babynames
- Irish CSO reference for future direct enrichment: https://www.cso.ie/en/statistics/birthsdeathsandmarriages/irishbabiesnames/
## Limitations
Registration spelling does not prove etymological canonicity, and official privacy thresholds omit very rare names. Locale estimates are deliberately labelled. This package is a deterministic knowledge layer, not an onomastic authority or demographic classifier. Future releases may replace blended profiles with direct official observations without changing stable name keys or consumer semantics.

View File

@ -1,28 +0,0 @@
# PlotDirector Name Intelligence reference data v1.0.0
Production-candidate global reference package for deterministic name recognition, ranking and relationship evidence in PlotDirector.
## Scale
- 3,067 recognised given-name forms.
- 525 curated short-form, diminutive, nickname, spelling-variant and alternative-form relationships.
- 92,010 usage profiles: every name × six locales × five inclusive birth-era bands.
- Direct registration-derived profiles for the United States and United Kingdom; explicitly labelled blended estimates for Ireland, Canada, Australia and New Zealand.
## Non-negotiable semantics
This library is **positive evidence, not a whitelist**. Missing names remain eligible for character detection through manuscript evidence. Gender values are priors, not character facts. Relationships generate candidates and continuity warnings; they never auto-merge characters. Existing project-specific `CharacterAliases` remain untouched and authoritative for that project.
## Import order
1. `name_locales.csv`
2. `given_names.csv`
3. `name_relationships.csv`
4. `name_usages.csv`
Upsert using the stable string keys, optionally resolving them to application surrogate keys. Never use CSV row order as identity.
## Review and validation
Start with `REVIEW.md` and the three `review_sample_*.csv` files. `validation_report.json` records the full structural validation result. See `DATA_CONTRACT.md`, `METHODOLOGY.md`, and `VALIDATION.md` before implementation.

View File

@ -1,21 +0,0 @@
# Validation rules
Reject an import atomically if any error rule fails:
1. Exact documented headers; UTF-8; unique, non-empty stable keys.
2. Unique normalised names; trimmed display values; controlled enum/boolean values.
3. Every relationship endpoint and every usage foreign key resolves.
4. No self-relationships or duplicate `(from, to, type)` relationships.
5. All numeric weights are finite and in `[0,1]`.
6. Male and female weights total 1 within `0.0002`.
7. Every name/locale has exactly five ordered, contiguous, non-overlapping bands covering 1900–2026.
8. No ownership columns or project-specific aliases enter the reference package.
9. Package version and import outcome are recorded transactionally.
Behavioural invariants:
- Unknown name → no dictionary evidence, never “not a person”.
- Relationship → candidate/explanation/warning, never automatic identity merge.
- Gender prior → ranking hint, always subordinate to manuscript evidence.
- Used-name status → computed from project/book/series character data at query time.

File diff suppressed because it is too large Load Diff

View File

@ -1,71 +0,0 @@
{
"packageId": "plotdirector-name-intelligence",
"version": "1.0.0",
"status": "production-candidate",
"generatedAt": "2026-08-24",
"positiveEvidenceNotWhitelist": true,
"coverage": {
"birthYears": "1900-2026",
"locales": [
"en-GB",
"en-IE",
"en-US",
"en-CA",
"en-AU",
"en-NZ"
],
"selection": "Established by sustained/count-based US or UK registration evidence, plus required curated forms",
"maximumRecognisedForms": 3000
},
"files": {
"name_locales": {
"file": "name_locales.csv",
"rows": 6
},
"given_names": {
"file": "given_names.csv",
"rows": 3067
},
"name_relationships": {
"file": "name_relationships.csv",
"rows": 525
},
"name_usages": {
"file": "name_usages.csv",
"rows": 92010
}
},
"valueSets": {
"nameType": [
"Canonical",
"Independent",
"Diminutive",
"Nickname",
"Variant"
],
"relationshipType": [
"ShortForm",
"Diminutive",
"Nickname",
"SpellingVariant",
"AlternativeForm",
"AnglicisedForm"
],
"sourceType": [
"OfficialStatisticalDerived",
"OfficialSourceBlendedEstimate"
]
},
"weights": {
"minimum": 0,
"maximum": 1,
"genderSumTolerance": 0.0002
},
"importOrder": [
"name_locales.csv",
"given_names.csv",
"name_relationships.csv",
"name_usages.csv"
],
"encoding": "UTF-8 with CRLF CSV"
}

View File

@ -1,7 +0,0 @@
locale_code,display_name,iso_country_code,evidence_basis,default_confidence,sort_order,is_active
en-GB,United Kingdom,GBR,Direct UK registration series,0.88,1,true
en-IE,Ireland,IRL,Northern Ireland evidence plus UK/US smoothing,0.67,2,true
en-US,United States,USA,Direct SSA registration series,0.92,3,true
en-CA,Canada,CAN,US/UK evidence blend,0.64,4,true
en-AU,Australia,AUS,UK/US evidence blend,0.62,5,true
en-NZ,New Zealand,NZL,UK/US evidence blend,0.62,6,true
1 locale_code display_name iso_country_code evidence_basis default_confidence sort_order is_active
2 en-GB United Kingdom GBR Direct UK registration series 0.88 1 true
3 en-IE Ireland IRL Northern Ireland evidence plus UK/US smoothing 0.67 2 true
4 en-US United States USA Direct SSA registration series 0.92 3 true
5 en-CA Canada CAN US/UK evidence blend 0.64 4 true
6 en-AU Australia AUS UK/US evidence blend 0.62 5 true
7 en-NZ New Zealand NZL UK/US evidence blend 0.62 6 true

View File

@ -1,526 +0,0 @@
relationship_key,from_given_name_key,to_given_name_key,relationship_type,strength,is_bidirectional,is_active,curation_source,notes
nr_000001,gn_abbey,gn_abigail,SpellingVariant,0.9700,true,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000002,gn_abbie,gn_abigail,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000003,gn_abby,gn_abigail,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000004,gn_abby,gn_abigail,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000005,gn_abe,gn_abraham,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000006,gn_addie,gn_adelaide,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000007,gn_addie,gn_adeline,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000008,gn_al,gn_alan,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000009,gn_al,gn_albert,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000010,gn_alec,gn_alexander,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000011,gn_alex,gn_alexander,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000012,gn_alex,gn_alexander,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000013,gn_alex,gn_alexandra,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000014,gn_alf,gn_alfred,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000015,gn_alfie,gn_alfred,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000016,gn_alfie,gn_alfred,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000017,gn_ali,gn_alison,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000018,gn_allie,gn_alison,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000019,gn_allison,gn_alison,SpellingVariant,0.9700,true,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000020,gn_ally,gn_alexandra,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000021,gn_andy,gn_andrew,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000022,gn_andy,gn_andrew,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000023,gn_angie,gn_angela,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000024,gn_angie,gn_angela,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000025,gn_angie,gn_angelina,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000026,gn_anne,gn_ann,SpellingVariant,0.9700,true,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000027,gn_annie,gn_ann,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000028,gn_annie,gn_ann,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000029,gn_annie,gn_anna,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000030,gn_annie,gn_anna,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000031,gn_ant,gn_anthony,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000032,gn_archie,gn_archibald,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000033,gn_arnie,gn_arnold,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000034,gn_art,gn_arthur,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000035,gn_art,gn_arthur,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000036,gn_artie,gn_arthur,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000037,gn_artie,gn_arthur,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000038,gn_babs,gn_barbara,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000039,gn_barb,gn_barbara,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000040,gn_barb,gn_barbara,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000041,gn_barbie,gn_barbara,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000042,gn_barbie,gn_barbara,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000043,gn_barney,gn_bernard,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000044,gn_bastian,gn_sebastian,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000045,gn_bea,gn_beatrice,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000046,gn_becca,gn_rebecca,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000047,gn_becca,gn_rebecca,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000048,gn_becky,gn_rebecca,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000049,gn_becky,gn_rebecca,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000050,gn_bella,gn_isabel,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000051,gn_bella,gn_isabella,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000052,gn_belle,gn_isabella,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000053,gn_ben,gn_benjamin,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000054,gn_ben,gn_benjamin,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000055,gn_benny,gn_benjamin,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000056,gn_benny,gn_benjamin,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000057,gn_bernie,gn_bernard,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000058,gn_bernie,gn_bernard,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000059,gn_bert,gn_albert,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000060,gn_bert,gn_herbert,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000061,gn_bert,gn_robert,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000062,gn_bertie,gn_albert,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000063,gn_bess,gn_elizabeth,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000064,gn_bessie,gn_elizabeth,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000065,gn_beth,gn_elizabeth,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000066,gn_beth,gn_elizabeth,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000067,gn_betsy,gn_elizabeth,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000068,gn_betty,gn_elizabeth,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000069,gn_bex,gn_rebecca,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000070,gn_bill,gn_william,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000071,gn_billie,gn_wilhelmina,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000072,gn_billy,gn_william,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000073,gn_billy,gn_william,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000074,gn_bob,gn_robert,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000075,gn_bobbie,gn_roberta,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000076,gn_bobby,gn_robert,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000077,gn_bobby,gn_robert,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000078,gn_bridie,gn_bridget,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000079,gn_cam,gn_cameron,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000080,gn_carol,gn_caroline,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000081,gn_carrie,gn_carol,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000082,gn_carrie,gn_caroline,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000083,gn_carrie,gn_caroline,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000084,gn_cass,gn_cassandra,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000085,gn_cass,gn_cassandra,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000086,gn_cassie,gn_cassandra,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000087,gn_cat,gn_catherine,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000088,gn_cathy,gn_catherine,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000089,gn_cathy,gn_catherine,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000090,gn_charlie,gn_charles,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000091,gn_charlie,gn_charles,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000092,gn_charlie,gn_charlotte,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000093,gn_charlie,gn_charlotte,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000094,gn_chas,gn_charles,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000095,gn_chris,gn_christina,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000096,gn_chris,gn_christina,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000097,gn_chris,gn_christine,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000098,gn_chris,gn_christine,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000099,gn_chris,gn_christopher,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000100,gn_chris,gn_christopher,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000101,gn_chrissie,gn_christine,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000102,gn_chrissy,gn_christina,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000103,gn_chuck,gn_charles,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000104,gn_cindy,gn_cynthia,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000105,gn_cindy,gn_lucinda,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000106,gn_clare,gn_claire,SpellingVariant,0.9700,true,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000107,gn_clint,gn_clinton,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000108,gn_connie,gn_constance,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000109,gn_dan,gn_daniel,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000110,gn_dan,gn_daniel,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000111,gn_dani,gn_danielle,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000112,gn_danny,gn_daniel,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000113,gn_danny,gn_daniel,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000114,gn_danny,gn_danielle,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000115,gn_dave,gn_david,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000116,gn_dave,gn_david,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000117,gn_davey,gn_david,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000118,gn_davy,gn_david,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000119,gn_davy,gn_david,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000120,gn_deb,gn_deborah,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000121,gn_deb,gn_deborah,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000122,gn_debbie,gn_deborah,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000123,gn_debbie,gn_deborah,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000124,gn_debby,gn_deborah,SpellingVariant,0.9700,true,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000125,gn_della,gn_adelaide,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000126,gn_dick,gn_richard,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000127,gn_dolly,gn_dorothy,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000128,gn_dom,gn_dominic,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000129,gn_don,gn_donald,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000130,gn_donnie,gn_donald,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000131,gn_donny,gn_donald,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000132,gn_dora,gn_theodora,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000133,gn_dot,gn_dorothy,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000134,gn_dot,gn_dorothy,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000135,gn_dottie,gn_dorothy,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000136,gn_dottie,gn_dorothy,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000137,gn_drew,gn_andrew,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000138,gn_drew,gn_andrew,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000139,gn_ed,gn_edmund,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000140,gn_ed,gn_edward,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000141,gn_ed,gn_edward,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000142,gn_eddie,gn_edmund,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000143,gn_eddie,gn_edward,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000144,gn_eddie,gn_edward,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000145,gn_edie,gn_edith,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000146,gn_eli,gn_elias,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000147,gn_eli,gn_elijah,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000148,gn_eliza,gn_elizabeth,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000149,gn_eliza,gn_elizabeth,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000150,gn_ella,gn_eleanor,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000151,gn_ellie,gn_eleanor,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000152,gn_ellie,gn_eleanor,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000153,gn_ellie,gn_ellen,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000154,gn_elliot,gn_elliott,SpellingVariant,0.9700,true,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000155,gn_elsie,gn_elizabeth,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000156,gn_emmy,gn_emily,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000157,gn_ernie,gn_ernest,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000158,gn_etta,gn_henrietta,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000159,gn_fanny,gn_frances,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000160,gn_flo,gn_florence,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000161,gn_flo,gn_florence,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000162,gn_florrie,gn_florence,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000163,gn_fran,gn_frances,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000164,gn_fran,gn_frances,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000165,gn_frank,gn_francis,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000166,gn_frankie,gn_frances,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000167,gn_frankie,gn_francis,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000168,gn_frankie,gn_francis,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000169,gn_frankie,gn_frank,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000170,gn_fred,gn_alfred,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000171,gn_fred,gn_frederick,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000172,gn_fred,gn_frederick,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000173,gn_freddie,gn_alfred,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000174,gn_freddie,gn_frederick,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000175,gn_freddie,gn_frederick,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000176,gn_freddy,gn_frederick,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000177,gn_freddy,gn_frederick,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000178,gn_gabby,gn_gabrielle,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000179,gn_gabe,gn_gabriel,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000180,gn_gabe,gn_gabriel,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000181,gn_gabi,gn_gabrielle,SpellingVariant,0.9700,true,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000182,gn_gail,gn_abigail,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000183,gn_gail,gn_abigail,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000184,gn_gene,gn_eugene,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000185,gn_geoff,gn_geoffrey,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000186,gn_georgie,gn_george,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000187,gn_georgie,gn_georgia,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000188,gn_gerry,gn_gerald,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000189,gn_gerry,gn_gerald,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000190,gn_gerry,gn_geraldine,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000191,gn_gigi,gn_virginia,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000192,gn_ginger,gn_virginia,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000193,gn_ginny,gn_virginia,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000194,gn_ginny,gn_virginia,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000195,gn_greg,gn_gregory,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000196,gn_greg,gn_gregory,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000197,gn_gus,gn_augustus,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000198,gn_gus,gn_gustavo,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000199,gn_gwen,gn_gwendoline,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000200,gn_gwen,gn_gwendolyn,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000201,gn_hal,gn_harold,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000202,gn_hal,gn_henry,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000203,gn_hank,gn_henry,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000204,gn_harry,gn_harold,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000205,gn_harry,gn_henry,AlternativeForm,0.8800,true,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000206,gn_harry,gn_henry,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000207,gn_hattie,gn_harriet,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000208,gn_hattie,gn_harriet,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000209,gn_isabella,gn_isabel,SpellingVariant,0.9700,true,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000210,gn_isabelle,gn_isabel,SpellingVariant,0.9700,true,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000211,gn_izzy,gn_isabel,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000212,gn_izzy,gn_isabella,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000213,gn_jack,gn_john,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000214,gn_jackie,gn_jacqueline,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000215,gn_jackie,gn_jacqueline,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000216,gn_jake,gn_jacob,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000217,gn_jamie,gn_james,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000218,gn_janey,gn_jane,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000219,gn_jay,gn_james,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000220,gn_jay,gn_jason,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000221,gn_jeff,gn_jeffrey,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000222,gn_jeffrey,gn_geoffrey,SpellingVariant,0.9700,true,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000223,gn_jen,gn_jennifer,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000224,gn_jen,gn_jennifer,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000225,gn_jenny,gn_jennifer,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000226,gn_jenny,gn_jennifer,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000227,gn_jerry,gn_gerald,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000228,gn_jerry,gn_gerald,SpellingVariant,0.9700,true,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000229,gn_jerry,gn_jeremy,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000230,gn_jess,gn_jessica,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000231,gn_jess,gn_jessica,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000232,gn_jessie,gn_jessica,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000233,gn_jessie,gn_jessica,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000234,gn_jez,gn_jeremy,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000235,gn_jim,gn_james,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000236,gn_jimmy,gn_james,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000237,gn_jimmy,gn_james,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000238,gn_jo,gn_joanne,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000239,gn_jo,gn_josephine,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000240,gn_jo,gn_josephine,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000241,gn_joe,gn_joseph,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000242,gn_joe,gn_joseph,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000243,gn_joey,gn_joseph,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000244,gn_joey,gn_joseph,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000245,gn_johnny,gn_john,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000246,gn_johnny,gn_john,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000247,gn_johnny,gn_jonathan,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000248,gn_jon,gn_jonathan,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000249,gn_jonny,gn_jonathan,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000250,gn_josh,gn_joshua,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000251,gn_josh,gn_joshua,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000252,gn_josie,gn_josephine,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000253,gn_josie,gn_josephine,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000254,gn_joy,gn_joyce,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000255,gn_judy,gn_judith,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000256,gn_judy,gn_judith,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000257,gn_julie,gn_julia,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000258,gn_julie,gn_julia,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000259,gn_kaitlin,gn_caitlin,SpellingVariant,0.9700,true,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000260,gn_kate,gn_catherine,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000261,gn_kate,gn_catherine,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000262,gn_kate,gn_katherine,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000263,gn_katelyn,gn_caitlin,SpellingVariant,0.9700,true,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000264,gn_katherine,gn_catherine,SpellingVariant,0.9700,true,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000265,gn_kathryn,gn_catherine,SpellingVariant,0.9700,true,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000266,gn_kathy,gn_catherine,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000267,gn_kathy,gn_katherine,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000268,gn_katie,gn_catherine,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000269,gn_katie,gn_catherine,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000270,gn_katie,gn_katherine,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000271,gn_kay,gn_catherine,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000272,gn_ken,gn_kenneth,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000273,gn_ken,gn_kenneth,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000274,gn_kenny,gn_kenneth,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000275,gn_kenny,gn_kenneth,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000276,gn_kim,gn_kimberly,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000277,gn_kit,gn_christopher,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000278,gn_kitty,gn_catherine,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000279,gn_kris,gn_christina,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000280,gn_larry,gn_laurence,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000281,gn_larry,gn_lawrence,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000282,gn_laurie,gn_laura,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000283,gn_laurie,gn_laura,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000284,gn_laurie,gn_lauren,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000285,gn_laurie,gn_laurence,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000286,gn_lawrence,gn_laurence,SpellingVariant,0.9700,true,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000287,gn_len,gn_leonard,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000288,gn_lena,gn_helena,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000289,gn_lenny,gn_leonard,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000290,gn_lenny,gn_leonard,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000291,gn_leo,gn_leonard,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000292,gn_leo,gn_leonard,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000293,gn_leo,gn_leonardo,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000294,gn_lettie,gn_charlotte,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000295,gn_lettie,gn_letitia,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000296,gn_lewis,gn_louis,SpellingVariant,0.9700,true,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000297,gn_lex,gn_alexander,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000298,gn_lexi,gn_alexandra,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000299,gn_lexi,gn_alexandra,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000300,gn_lexi,gn_alexis,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000301,gn_lexie,gn_alexandra,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000302,gn_liam,gn_william,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000303,gn_libby,gn_elizabeth,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000304,gn_lillie,gn_lillian,SpellingVariant,0.9700,true,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000305,gn_lily,gn_lillian,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000306,gn_lisa,gn_elizabeth,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000307,gn_liv,gn_olivia,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000308,gn_liv,gn_olivia,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000309,gn_livvy,gn_olivia,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000310,gn_liz,gn_elizabeth,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000311,gn_liz,gn_elizabeth,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000312,gn_lizzie,gn_elizabeth,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000313,gn_lizzy,gn_elizabeth,SpellingVariant,0.9700,true,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000314,gn_lori,gn_lorraine,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000315,gn_lottie,gn_charlotte,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000316,gn_lottie,gn_charlotte,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000317,gn_lou,gn_louis,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000318,gn_lou,gn_louis,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000319,gn_lou,gn_louise,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000320,gn_louie,gn_louis,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000321,gn_lucy,gn_lucille,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000322,gn_lucy,gn_lucinda,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000323,gn_lulu,gn_louise,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000324,gn_lulu,gn_lucille,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000325,gn_maddie,gn_madeline,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000326,gn_mae,gn_mary,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000327,gn_maggie,gn_margaret,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000328,gn_maggie,gn_margaret,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000329,gn_maisie,gn_margaret,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000330,gn_mamie,gn_mary,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000331,gn_mandy,gn_amanda,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000332,gn_mandy,gn_amanda,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000333,gn_margo,gn_margaret,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000334,gn_mari,gn_marianne,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000335,gn_marie,gn_maria,SpellingVariant,0.9700,true,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000336,gn_marty,gn_martin,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000337,gn_mary,gn_maria,AlternativeForm,0.8800,true,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000338,gn_matt,gn_matthew,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000339,gn_matt,gn_matthew,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000340,gn_mattie,gn_matilda,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000341,gn_matty,gn_matthew,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000342,gn_matty,gn_matthew,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000343,gn_max,gn_maximilian,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000344,gn_max,gn_maxwell,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000345,gn_meg,gn_margaret,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000346,gn_meg,gn_margaret,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000347,gn_mel,gn_melissa,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000348,gn_mick,gn_michael,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000349,gn_mickey,gn_michael,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000350,gn_mike,gn_michael,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000351,gn_mike,gn_michael,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000352,gn_mikey,gn_michael,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000353,gn_millie,gn_mildred,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000354,gn_millie,gn_millicent,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000355,gn_mimi,gn_miriam,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000356,gn_mindy,gn_melinda,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000357,gn_minnie,gn_minerva,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000358,gn_missy,gn_melissa,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000359,gn_mitch,gn_michelle,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000360,gn_mitch,gn_mitchell,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000361,gn_molly,gn_mary,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000362,gn_monty,gn_montgomery,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000363,gn_nat,gn_natalie,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000364,gn_nate,gn_nathan,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000365,gn_nate,gn_nathaniel,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000366,gn_nate,gn_nathaniel,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000367,gn_nathan,gn_nathaniel,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000368,gn_ned,gn_edmund,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000369,gn_ned,gn_edward,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000370,gn_nell,gn_eleanor,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000371,gn_nell,gn_ellen,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000372,gn_nell,gn_helen,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000373,gn_nell,gn_penelope,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000374,gn_nellie,gn_eleanor,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000375,gn_nellie,gn_eleanor,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000376,gn_nellie,gn_helen,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000377,gn_nellie,gn_helen,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000378,gn_nelly,gn_helen,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000379,gn_nic,gn_nicole,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000380,gn_nick,gn_dominic,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000381,gn_nick,gn_nicholas,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000382,gn_nick,gn_nicholas,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000383,gn_nicky,gn_nicholas,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000384,gn_nicky,gn_nicholas,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000385,gn_nicky,gn_nicole,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000386,gn_nico,gn_nicholas,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000387,gn_nikki,gn_nicole,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000388,gn_nora,gn_eleanor,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000389,gn_paddy,gn_patrick,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000390,gn_pat,gn_patricia,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000391,gn_pat,gn_patricia,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000392,gn_pat,gn_patrick,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000393,gn_pat,gn_patrick,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000394,gn_patsy,gn_patricia,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000395,gn_peggy,gn_margaret,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000396,gn_penny,gn_penelope,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000397,gn_penny,gn_penelope,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000398,gn_pete,gn_peter,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000399,gn_pete,gn_peter,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000400,gn_phil,gn_philip,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000401,gn_phil,gn_philip,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000402,gn_phillip,gn_philip,SpellingVariant,0.9700,true,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000403,gn_polly,gn_mary,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000404,gn_ray,gn_raymond,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000405,gn_ray,gn_raymond,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000406,gn_reg,gn_reginald,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000407,gn_reggie,gn_reginald,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000408,gn_reggie,gn_reginald,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000409,gn_rich,gn_richard,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000410,gn_rich,gn_richard,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000411,gn_richie,gn_richard,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000412,gn_rick,gn_richard,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000413,gn_rick,gn_richard,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000414,gn_ricky,gn_richard,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000415,gn_ricky,gn_richard,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000416,gn_rob,gn_robert,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000417,gn_rob,gn_robert,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000418,gn_robbie,gn_robert,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000419,gn_robbie,gn_robert,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000420,gn_rodger,gn_roger,SpellingVariant,0.9700,true,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000421,gn_ron,gn_ronald,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000422,gn_ron,gn_ronald,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000423,gn_ronnie,gn_ronald,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000424,gn_ronnie,gn_ronald,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000425,gn_rose,gn_rosemary,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000426,gn_rosie,gn_rose,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000427,gn_rosie,gn_rosemary,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000428,gn_rosie,gn_rosemary,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000429,gn_roxie,gn_roxanne,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000430,gn_roxy,gn_roxanne,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000431,gn_sadie,gn_sarah,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000432,gn_sally,gn_sarah,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000433,gn_sam,gn_samantha,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000434,gn_sam,gn_samantha,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000435,gn_sam,gn_samuel,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000436,gn_sam,gn_samuel,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000437,gn_sammie,gn_samantha,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000438,gn_sammy,gn_samantha,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000439,gn_sammy,gn_samuel,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000440,gn_sammy,gn_samuel,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000441,gn_sandra,gn_cassandra,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000442,gn_sandy,gn_alexander,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000443,gn_sandy,gn_sandra,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000444,gn_sandy,gn_sandra,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000445,gn_sara,gn_sarah,SpellingVariant,0.9700,true,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000446,gn_sasha,gn_alexandra,AlternativeForm,0.8800,true,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000447,gn_seb,gn_sebastian,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000448,gn_shelly,gn_michelle,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000449,gn_sofia,gn_sophia,SpellingVariant,0.9700,true,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000450,gn_sophie,gn_sophia,AlternativeForm,0.8800,true,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000451,gn_stella,gn_estella,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000452,gn_steph,gn_stephanie,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000453,gn_steve,gn_stephen,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000454,gn_steve,gn_steven,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000455,gn_steven,gn_stephen,SpellingVariant,0.9700,true,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000456,gn_stevie,gn_stephanie,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000457,gn_stevie,gn_stephen,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000458,gn_stevie,gn_steven,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000459,gn_sue,gn_susan,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000460,gn_sue,gn_susan,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000461,gn_sue,gn_suzanne,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000462,gn_susie,gn_susan,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000463,gn_susie,gn_susan,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000464,gn_susy,gn_susan,SpellingVariant,0.9700,true,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000465,gn_suzie,gn_susan,SpellingVariant,0.9700,true,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000466,gn_suzie,gn_suzanne,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000467,gn_suzy,gn_susan,SpellingVariant,0.9700,true,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000468,gn_suzy,gn_suzanne,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000469,gn_tammy,gn_tamara,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000470,gn_ted,gn_edward,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000471,gn_ted,gn_theodore,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000472,gn_teddy,gn_edward,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000473,gn_teddy,gn_edward,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000474,gn_teddy,gn_theodore,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000475,gn_teddy,gn_theodore,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000476,gn_terri,gn_teresa,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000477,gn_tess,gn_teresa,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000478,gn_tessa,gn_teresa,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000479,gn_tessa,gn_teresa,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000480,gn_theo,gn_theodore,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000481,gn_theo,gn_theodore,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000482,gn_theresa,gn_teresa,SpellingVariant,0.9700,true,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000483,gn_tilly,gn_matilda,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000484,gn_tim,gn_timothy,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000485,gn_tim,gn_timothy,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000486,gn_timmy,gn_timothy,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000487,gn_timmy,gn_timothy,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000488,gn_tina,gn_christina,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000489,gn_tina,gn_christina,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000490,gn_tina,gn_christine,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000491,gn_tom,gn_thomas,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000492,gn_tom,gn_thomas,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000493,gn_tommy,gn_thomas,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000494,gn_tommy,gn_thomas,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000495,gn_toni,gn_antoinette,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000496,gn_toni,gn_antonia,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000497,gn_tony,gn_anthony,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000498,gn_tony,gn_anthony,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000499,gn_topher,gn_christopher,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000500,gn_tori,gn_victoria,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000501,gn_tori,gn_victoria,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000502,gn_tricia,gn_patricia,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000503,gn_trixie,gn_beatrice,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000504,gn_trudy,gn_gertrude,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000505,gn_vicki,gn_victoria,SpellingVariant,0.9700,true,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000506,gn_vicky,gn_victoria,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000507,gn_vicky,gn_victoria,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000508,gn_vince,gn_vincent,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000509,gn_vinny,gn_vincent,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000510,gn_wally,gn_walter,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000511,gn_wally,gn_walter,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000512,gn_walt,gn_walter,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000513,gn_walt,gn_walter,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000514,gn_wendy,gn_gwendoline,Nickname,0.7200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000515,gn_wendy,gn_gwendolyn,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000516,gn_will,gn_william,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000517,gn_will,gn_william,ShortForm,0.9200,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000518,gn_willie,gn_william,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000519,gn_willie,gn_william,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000520,gn_winnie,gn_winifred,Diminutive,0.9000,false,true,PrototypeCurated,Potential relationship only; never auto-merge characters
nr_000521,gn_winnie,gn_winifred,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000522,gn_woody,gn_woodrow,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000523,gn_xander,gn_alexander,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000524,gn_zach,gn_zachary,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
nr_000525,gn_zack,gn_zachary,Nickname,0.7800,false,true,DxdcCuratedNicknameMap,Potential relationship only; never auto-merge characters
1 relationship_key from_given_name_key to_given_name_key relationship_type strength is_bidirectional is_active curation_source notes
2 nr_000001 gn_abbey gn_abigail SpellingVariant 0.9700 true true PrototypeCurated Potential relationship only; never auto-merge characters
3 nr_000002 gn_abbie gn_abigail Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
4 nr_000003 gn_abby gn_abigail Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
5 nr_000004 gn_abby gn_abigail Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
6 nr_000005 gn_abe gn_abraham Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
7 nr_000006 gn_addie gn_adelaide Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
8 nr_000007 gn_addie gn_adeline Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
9 nr_000008 gn_al gn_alan Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
10 nr_000009 gn_al gn_albert Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
11 nr_000010 gn_alec gn_alexander Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
12 nr_000011 gn_alex gn_alexander Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
13 nr_000012 gn_alex gn_alexander ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
14 nr_000013 gn_alex gn_alexandra ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
15 nr_000014 gn_alf gn_alfred Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
16 nr_000015 gn_alfie gn_alfred Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
17 nr_000016 gn_alfie gn_alfred Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
18 nr_000017 gn_ali gn_alison ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
19 nr_000018 gn_allie gn_alison Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
20 nr_000019 gn_allison gn_alison SpellingVariant 0.9700 true true PrototypeCurated Potential relationship only; never auto-merge characters
21 nr_000020 gn_ally gn_alexandra Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
22 nr_000021 gn_andy gn_andrew Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
23 nr_000022 gn_andy gn_andrew Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
24 nr_000023 gn_angie gn_angela Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
25 nr_000024 gn_angie gn_angela Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
26 nr_000025 gn_angie gn_angelina Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
27 nr_000026 gn_anne gn_ann SpellingVariant 0.9700 true true PrototypeCurated Potential relationship only; never auto-merge characters
28 nr_000027 gn_annie gn_ann Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
29 nr_000028 gn_annie gn_ann Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
30 nr_000029 gn_annie gn_anna Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
31 nr_000030 gn_annie gn_anna Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
32 nr_000031 gn_ant gn_anthony ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
33 nr_000032 gn_archie gn_archibald Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
34 nr_000033 gn_arnie gn_arnold Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
35 nr_000034 gn_art gn_arthur Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
36 nr_000035 gn_art gn_arthur ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
37 nr_000036 gn_artie gn_arthur Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
38 nr_000037 gn_artie gn_arthur Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
39 nr_000038 gn_babs gn_barbara Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
40 nr_000039 gn_barb gn_barbara Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
41 nr_000040 gn_barb gn_barbara ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
42 nr_000041 gn_barbie gn_barbara Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
43 nr_000042 gn_barbie gn_barbara Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
44 nr_000043 gn_barney gn_bernard Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
45 nr_000044 gn_bastian gn_sebastian ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
46 nr_000045 gn_bea gn_beatrice ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
47 nr_000046 gn_becca gn_rebecca Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
48 nr_000047 gn_becca gn_rebecca ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
49 nr_000048 gn_becky gn_rebecca Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
50 nr_000049 gn_becky gn_rebecca Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
51 nr_000050 gn_bella gn_isabel ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
52 nr_000051 gn_bella gn_isabella Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
53 nr_000052 gn_belle gn_isabella Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
54 nr_000053 gn_ben gn_benjamin Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
55 nr_000054 gn_ben gn_benjamin ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
56 nr_000055 gn_benny gn_benjamin Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
57 nr_000056 gn_benny gn_benjamin Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
58 nr_000057 gn_bernie gn_bernard Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
59 nr_000058 gn_bernie gn_bernard Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
60 nr_000059 gn_bert gn_albert Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
61 nr_000060 gn_bert gn_herbert Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
62 nr_000061 gn_bert gn_robert Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
63 nr_000062 gn_bertie gn_albert Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
64 nr_000063 gn_bess gn_elizabeth Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
65 nr_000064 gn_bessie gn_elizabeth Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
66 nr_000065 gn_beth gn_elizabeth Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
67 nr_000066 gn_beth gn_elizabeth ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
68 nr_000067 gn_betsy gn_elizabeth Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
69 nr_000068 gn_betty gn_elizabeth Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
70 nr_000069 gn_bex gn_rebecca Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
71 nr_000070 gn_bill gn_william Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
72 nr_000071 gn_billie gn_wilhelmina Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
73 nr_000072 gn_billy gn_william Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
74 nr_000073 gn_billy gn_william Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
75 nr_000074 gn_bob gn_robert Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
76 nr_000075 gn_bobbie gn_roberta Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
77 nr_000076 gn_bobby gn_robert Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
78 nr_000077 gn_bobby gn_robert Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
79 nr_000078 gn_bridie gn_bridget Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
80 nr_000079 gn_cam gn_cameron ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
81 nr_000080 gn_carol gn_caroline ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
82 nr_000081 gn_carrie gn_carol Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
83 nr_000082 gn_carrie gn_caroline Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
84 nr_000083 gn_carrie gn_caroline Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
85 nr_000084 gn_cass gn_cassandra Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
86 nr_000085 gn_cass gn_cassandra ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
87 nr_000086 gn_cassie gn_cassandra Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
88 nr_000087 gn_cat gn_catherine ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
89 nr_000088 gn_cathy gn_catherine Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
90 nr_000089 gn_cathy gn_catherine Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
91 nr_000090 gn_charlie gn_charles Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
92 nr_000091 gn_charlie gn_charles Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
93 nr_000092 gn_charlie gn_charlotte Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
94 nr_000093 gn_charlie gn_charlotte Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
95 nr_000094 gn_chas gn_charles ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
96 nr_000095 gn_chris gn_christina Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
97 nr_000096 gn_chris gn_christina ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
98 nr_000097 gn_chris gn_christine Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
99 nr_000098 gn_chris gn_christine ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
100 nr_000099 gn_chris gn_christopher Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
101 nr_000100 gn_chris gn_christopher ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
102 nr_000101 gn_chrissie gn_christine Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
103 nr_000102 gn_chrissy gn_christina Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
104 nr_000103 gn_chuck gn_charles Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
105 nr_000104 gn_cindy gn_cynthia Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
106 nr_000105 gn_cindy gn_lucinda Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
107 nr_000106 gn_clare gn_claire SpellingVariant 0.9700 true true PrototypeCurated Potential relationship only; never auto-merge characters
108 nr_000107 gn_clint gn_clinton Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
109 nr_000108 gn_connie gn_constance Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
110 nr_000109 gn_dan gn_daniel Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
111 nr_000110 gn_dan gn_daniel ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
112 nr_000111 gn_dani gn_danielle ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
113 nr_000112 gn_danny gn_daniel Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
114 nr_000113 gn_danny gn_daniel Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
115 nr_000114 gn_danny gn_danielle Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
116 nr_000115 gn_dave gn_david Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
117 nr_000116 gn_dave gn_david ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
118 nr_000117 gn_davey gn_david Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
119 nr_000118 gn_davy gn_david Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
120 nr_000119 gn_davy gn_david Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
121 nr_000120 gn_deb gn_deborah Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
122 nr_000121 gn_deb gn_deborah ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
123 nr_000122 gn_debbie gn_deborah Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
124 nr_000123 gn_debbie gn_deborah Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
125 nr_000124 gn_debby gn_deborah SpellingVariant 0.9700 true true PrototypeCurated Potential relationship only; never auto-merge characters
126 nr_000125 gn_della gn_adelaide Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
127 nr_000126 gn_dick gn_richard Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
128 nr_000127 gn_dolly gn_dorothy Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
129 nr_000128 gn_dom gn_dominic ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
130 nr_000129 gn_don gn_donald Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
131 nr_000130 gn_donnie gn_donald Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
132 nr_000131 gn_donny gn_donald Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
133 nr_000132 gn_dora gn_theodora Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
134 nr_000133 gn_dot gn_dorothy Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
135 nr_000134 gn_dot gn_dorothy ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
136 nr_000135 gn_dottie gn_dorothy Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
137 nr_000136 gn_dottie gn_dorothy Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
138 nr_000137 gn_drew gn_andrew Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
139 nr_000138 gn_drew gn_andrew ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
140 nr_000139 gn_ed gn_edmund ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
141 nr_000140 gn_ed gn_edward Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
142 nr_000141 gn_ed gn_edward ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
143 nr_000142 gn_eddie gn_edmund Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
144 nr_000143 gn_eddie gn_edward Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
145 nr_000144 gn_eddie gn_edward Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
146 nr_000145 gn_edie gn_edith Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
147 nr_000146 gn_eli gn_elias Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
148 nr_000147 gn_eli gn_elijah Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
149 nr_000148 gn_eliza gn_elizabeth Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
150 nr_000149 gn_eliza gn_elizabeth ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
151 nr_000150 gn_ella gn_eleanor Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
152 nr_000151 gn_ellie gn_eleanor Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
153 nr_000152 gn_ellie gn_eleanor Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
154 nr_000153 gn_ellie gn_ellen Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
155 nr_000154 gn_elliot gn_elliott SpellingVariant 0.9700 true true PrototypeCurated Potential relationship only; never auto-merge characters
156 nr_000155 gn_elsie gn_elizabeth Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
157 nr_000156 gn_emmy gn_emily Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
158 nr_000157 gn_ernie gn_ernest Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
159 nr_000158 gn_etta gn_henrietta Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
160 nr_000159 gn_fanny gn_frances Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
161 nr_000160 gn_flo gn_florence Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
162 nr_000161 gn_flo gn_florence ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
163 nr_000162 gn_florrie gn_florence Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
164 nr_000163 gn_fran gn_frances Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
165 nr_000164 gn_fran gn_frances ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
166 nr_000165 gn_frank gn_francis Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
167 nr_000166 gn_frankie gn_frances Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
168 nr_000167 gn_frankie gn_francis Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
169 nr_000168 gn_frankie gn_francis Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
170 nr_000169 gn_frankie gn_frank Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
171 nr_000170 gn_fred gn_alfred ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
172 nr_000171 gn_fred gn_frederick Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
173 nr_000172 gn_fred gn_frederick ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
174 nr_000173 gn_freddie gn_alfred Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
175 nr_000174 gn_freddie gn_frederick Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
176 nr_000175 gn_freddie gn_frederick Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
177 nr_000176 gn_freddy gn_frederick Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
178 nr_000177 gn_freddy gn_frederick Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
179 nr_000178 gn_gabby gn_gabrielle Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
180 nr_000179 gn_gabe gn_gabriel Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
181 nr_000180 gn_gabe gn_gabriel ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
182 nr_000181 gn_gabi gn_gabrielle SpellingVariant 0.9700 true true PrototypeCurated Potential relationship only; never auto-merge characters
183 nr_000182 gn_gail gn_abigail Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
184 nr_000183 gn_gail gn_abigail ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
185 nr_000184 gn_gene gn_eugene Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
186 nr_000185 gn_geoff gn_geoffrey ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
187 nr_000186 gn_georgie gn_george Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
188 nr_000187 gn_georgie gn_georgia Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
189 nr_000188 gn_gerry gn_gerald Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
190 nr_000189 gn_gerry gn_gerald Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
191 nr_000190 gn_gerry gn_geraldine Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
192 nr_000191 gn_gigi gn_virginia Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
193 nr_000192 gn_ginger gn_virginia Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
194 nr_000193 gn_ginny gn_virginia Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
195 nr_000194 gn_ginny gn_virginia Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
196 nr_000195 gn_greg gn_gregory Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
197 nr_000196 gn_greg gn_gregory ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
198 nr_000197 gn_gus gn_augustus Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
199 nr_000198 gn_gus gn_gustavo Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
200 nr_000199 gn_gwen gn_gwendoline ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
201 nr_000200 gn_gwen gn_gwendolyn Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
202 nr_000201 gn_hal gn_harold Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
203 nr_000202 gn_hal gn_henry Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
204 nr_000203 gn_hank gn_henry Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
205 nr_000204 gn_harry gn_harold Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
206 nr_000205 gn_harry gn_henry AlternativeForm 0.8800 true true PrototypeCurated Potential relationship only; never auto-merge characters
207 nr_000206 gn_harry gn_henry Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
208 nr_000207 gn_hattie gn_harriet Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
209 nr_000208 gn_hattie gn_harriet Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
210 nr_000209 gn_isabella gn_isabel SpellingVariant 0.9700 true true PrototypeCurated Potential relationship only; never auto-merge characters
211 nr_000210 gn_isabelle gn_isabel SpellingVariant 0.9700 true true PrototypeCurated Potential relationship only; never auto-merge characters
212 nr_000211 gn_izzy gn_isabel Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
213 nr_000212 gn_izzy gn_isabella Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
214 nr_000213 gn_jack gn_john Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
215 nr_000214 gn_jackie gn_jacqueline Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
216 nr_000215 gn_jackie gn_jacqueline Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
217 nr_000216 gn_jake gn_jacob Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
218 nr_000217 gn_jamie gn_james Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
219 nr_000218 gn_janey gn_jane Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
220 nr_000219 gn_jay gn_james Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
221 nr_000220 gn_jay gn_jason ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
222 nr_000221 gn_jeff gn_jeffrey Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
223 nr_000222 gn_jeffrey gn_geoffrey SpellingVariant 0.9700 true true PrototypeCurated Potential relationship only; never auto-merge characters
224 nr_000223 gn_jen gn_jennifer Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
225 nr_000224 gn_jen gn_jennifer ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
226 nr_000225 gn_jenny gn_jennifer Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
227 nr_000226 gn_jenny gn_jennifer Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
228 nr_000227 gn_jerry gn_gerald Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
229 nr_000228 gn_jerry gn_gerald SpellingVariant 0.9700 true true PrototypeCurated Potential relationship only; never auto-merge characters
230 nr_000229 gn_jerry gn_jeremy Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
231 nr_000230 gn_jess gn_jessica Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
232 nr_000231 gn_jess gn_jessica ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
233 nr_000232 gn_jessie gn_jessica Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
234 nr_000233 gn_jessie gn_jessica Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
235 nr_000234 gn_jez gn_jeremy Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
236 nr_000235 gn_jim gn_james Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
237 nr_000236 gn_jimmy gn_james Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
238 nr_000237 gn_jimmy gn_james Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
239 nr_000238 gn_jo gn_joanne Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
240 nr_000239 gn_jo gn_josephine Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
241 nr_000240 gn_jo gn_josephine ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
242 nr_000241 gn_joe gn_joseph Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
243 nr_000242 gn_joe gn_joseph ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
244 nr_000243 gn_joey gn_joseph Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
245 nr_000244 gn_joey gn_joseph Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
246 nr_000245 gn_johnny gn_john Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
247 nr_000246 gn_johnny gn_john Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
248 nr_000247 gn_johnny gn_jonathan Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
249 nr_000248 gn_jon gn_jonathan ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
250 nr_000249 gn_jonny gn_jonathan Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
251 nr_000250 gn_josh gn_joshua Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
252 nr_000251 gn_josh gn_joshua ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
253 nr_000252 gn_josie gn_josephine Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
254 nr_000253 gn_josie gn_josephine Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
255 nr_000254 gn_joy gn_joyce Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
256 nr_000255 gn_judy gn_judith Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
257 nr_000256 gn_judy gn_judith Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
258 nr_000257 gn_julie gn_julia Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
259 nr_000258 gn_julie gn_julia Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
260 nr_000259 gn_kaitlin gn_caitlin SpellingVariant 0.9700 true true PrototypeCurated Potential relationship only; never auto-merge characters
261 nr_000260 gn_kate gn_catherine Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
262 nr_000261 gn_kate gn_catherine ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
263 nr_000262 gn_kate gn_katherine ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
264 nr_000263 gn_katelyn gn_caitlin SpellingVariant 0.9700 true true PrototypeCurated Potential relationship only; never auto-merge characters
265 nr_000264 gn_katherine gn_catherine SpellingVariant 0.9700 true true PrototypeCurated Potential relationship only; never auto-merge characters
266 nr_000265 gn_kathryn gn_catherine SpellingVariant 0.9700 true true PrototypeCurated Potential relationship only; never auto-merge characters
267 nr_000266 gn_kathy gn_catherine Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
268 nr_000267 gn_kathy gn_katherine Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
269 nr_000268 gn_katie gn_catherine Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
270 nr_000269 gn_katie gn_catherine Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
271 nr_000270 gn_katie gn_katherine Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
272 nr_000271 gn_kay gn_catherine Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
273 nr_000272 gn_ken gn_kenneth Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
274 nr_000273 gn_ken gn_kenneth ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
275 nr_000274 gn_kenny gn_kenneth Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
276 nr_000275 gn_kenny gn_kenneth Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
277 nr_000276 gn_kim gn_kimberly ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
278 nr_000277 gn_kit gn_christopher Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
279 nr_000278 gn_kitty gn_catherine Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
280 nr_000279 gn_kris gn_christina Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
281 nr_000280 gn_larry gn_laurence Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
282 nr_000281 gn_larry gn_lawrence Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
283 nr_000282 gn_laurie gn_laura Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
284 nr_000283 gn_laurie gn_laura Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
285 nr_000284 gn_laurie gn_lauren Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
286 nr_000285 gn_laurie gn_laurence Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
287 nr_000286 gn_lawrence gn_laurence SpellingVariant 0.9700 true true PrototypeCurated Potential relationship only; never auto-merge characters
288 nr_000287 gn_len gn_leonard ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
289 nr_000288 gn_lena gn_helena Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
290 nr_000289 gn_lenny gn_leonard Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
291 nr_000290 gn_lenny gn_leonard Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
292 nr_000291 gn_leo gn_leonard Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
293 nr_000292 gn_leo gn_leonard ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
294 nr_000293 gn_leo gn_leonardo Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
295 nr_000294 gn_lettie gn_charlotte Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
296 nr_000295 gn_lettie gn_letitia Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
297 nr_000296 gn_lewis gn_louis SpellingVariant 0.9700 true true PrototypeCurated Potential relationship only; never auto-merge characters
298 nr_000297 gn_lex gn_alexander Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
299 nr_000298 gn_lexi gn_alexandra Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
300 nr_000299 gn_lexi gn_alexandra Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
301 nr_000300 gn_lexi gn_alexis Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
302 nr_000301 gn_lexie gn_alexandra Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
303 nr_000302 gn_liam gn_william ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
304 nr_000303 gn_libby gn_elizabeth Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
305 nr_000304 gn_lillie gn_lillian SpellingVariant 0.9700 true true PrototypeCurated Potential relationship only; never auto-merge characters
306 nr_000305 gn_lily gn_lillian Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
307 nr_000306 gn_lisa gn_elizabeth Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
308 nr_000307 gn_liv gn_olivia Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
309 nr_000308 gn_liv gn_olivia ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
310 nr_000309 gn_livvy gn_olivia Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
311 nr_000310 gn_liz gn_elizabeth Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
312 nr_000311 gn_liz gn_elizabeth ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
313 nr_000312 gn_lizzie gn_elizabeth Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
314 nr_000313 gn_lizzy gn_elizabeth SpellingVariant 0.9700 true true PrototypeCurated Potential relationship only; never auto-merge characters
315 nr_000314 gn_lori gn_lorraine Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
316 nr_000315 gn_lottie gn_charlotte Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
317 nr_000316 gn_lottie gn_charlotte Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
318 nr_000317 gn_lou gn_louis Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
319 nr_000318 gn_lou gn_louis ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
320 nr_000319 gn_lou gn_louise ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
321 nr_000320 gn_louie gn_louis Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
322 nr_000321 gn_lucy gn_lucille Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
323 nr_000322 gn_lucy gn_lucinda ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
324 nr_000323 gn_lulu gn_louise Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
325 nr_000324 gn_lulu gn_lucille Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
326 nr_000325 gn_maddie gn_madeline Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
327 nr_000326 gn_mae gn_mary Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
328 nr_000327 gn_maggie gn_margaret Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
329 nr_000328 gn_maggie gn_margaret Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
330 nr_000329 gn_maisie gn_margaret Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
331 nr_000330 gn_mamie gn_mary Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
332 nr_000331 gn_mandy gn_amanda Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
333 nr_000332 gn_mandy gn_amanda Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
334 nr_000333 gn_margo gn_margaret ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
335 nr_000334 gn_mari gn_marianne ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
336 nr_000335 gn_marie gn_maria SpellingVariant 0.9700 true true PrototypeCurated Potential relationship only; never auto-merge characters
337 nr_000336 gn_marty gn_martin Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
338 nr_000337 gn_mary gn_maria AlternativeForm 0.8800 true true PrototypeCurated Potential relationship only; never auto-merge characters
339 nr_000338 gn_matt gn_matthew Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
340 nr_000339 gn_matt gn_matthew ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
341 nr_000340 gn_mattie gn_matilda Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
342 nr_000341 gn_matty gn_matthew Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
343 nr_000342 gn_matty gn_matthew Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
344 nr_000343 gn_max gn_maximilian Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
345 nr_000344 gn_max gn_maxwell Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
346 nr_000345 gn_meg gn_margaret Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
347 nr_000346 gn_meg gn_margaret ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
348 nr_000347 gn_mel gn_melissa ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
349 nr_000348 gn_mick gn_michael Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
350 nr_000349 gn_mickey gn_michael Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
351 nr_000350 gn_mike gn_michael Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
352 nr_000351 gn_mike gn_michael ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
353 nr_000352 gn_mikey gn_michael Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
354 nr_000353 gn_millie gn_mildred Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
355 nr_000354 gn_millie gn_millicent Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
356 nr_000355 gn_mimi gn_miriam Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
357 nr_000356 gn_mindy gn_melinda Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
358 nr_000357 gn_minnie gn_minerva Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
359 nr_000358 gn_missy gn_melissa Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
360 nr_000359 gn_mitch gn_michelle Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
361 nr_000360 gn_mitch gn_mitchell Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
362 nr_000361 gn_molly gn_mary Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
363 nr_000362 gn_monty gn_montgomery Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
364 nr_000363 gn_nat gn_natalie ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
365 nr_000364 gn_nate gn_nathan Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
366 nr_000365 gn_nate gn_nathaniel Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
367 nr_000366 gn_nate gn_nathaniel ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
368 nr_000367 gn_nathan gn_nathaniel ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
369 nr_000368 gn_ned gn_edmund Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
370 nr_000369 gn_ned gn_edward Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
371 nr_000370 gn_nell gn_eleanor Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
372 nr_000371 gn_nell gn_ellen Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
373 nr_000372 gn_nell gn_helen Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
374 nr_000373 gn_nell gn_penelope Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
375 nr_000374 gn_nellie gn_eleanor Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
376 nr_000375 gn_nellie gn_eleanor Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
377 nr_000376 gn_nellie gn_helen Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
378 nr_000377 gn_nellie gn_helen Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
379 nr_000378 gn_nelly gn_helen Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
380 nr_000379 gn_nic gn_nicole ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
381 nr_000380 gn_nick gn_dominic Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
382 nr_000381 gn_nick gn_nicholas Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
383 nr_000382 gn_nick gn_nicholas ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
384 nr_000383 gn_nicky gn_nicholas Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
385 nr_000384 gn_nicky gn_nicholas Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
386 nr_000385 gn_nicky gn_nicole Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
387 nr_000386 gn_nico gn_nicholas Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
388 nr_000387 gn_nikki gn_nicole Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
389 nr_000388 gn_nora gn_eleanor Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
390 nr_000389 gn_paddy gn_patrick Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
391 nr_000390 gn_pat gn_patricia Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
392 nr_000391 gn_pat gn_patricia ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
393 nr_000392 gn_pat gn_patrick Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
394 nr_000393 gn_pat gn_patrick ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
395 nr_000394 gn_patsy gn_patricia Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
396 nr_000395 gn_peggy gn_margaret Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
397 nr_000396 gn_penny gn_penelope Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
398 nr_000397 gn_penny gn_penelope Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
399 nr_000398 gn_pete gn_peter Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
400 nr_000399 gn_pete gn_peter ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
401 nr_000400 gn_phil gn_philip Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
402 nr_000401 gn_phil gn_philip ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
403 nr_000402 gn_phillip gn_philip SpellingVariant 0.9700 true true PrototypeCurated Potential relationship only; never auto-merge characters
404 nr_000403 gn_polly gn_mary Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
405 nr_000404 gn_ray gn_raymond Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
406 nr_000405 gn_ray gn_raymond ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
407 nr_000406 gn_reg gn_reginald ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
408 nr_000407 gn_reggie gn_reginald Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
409 nr_000408 gn_reggie gn_reginald Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
410 nr_000409 gn_rich gn_richard Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
411 nr_000410 gn_rich gn_richard ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
412 nr_000411 gn_richie gn_richard Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
413 nr_000412 gn_rick gn_richard Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
414 nr_000413 gn_rick gn_richard ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
415 nr_000414 gn_ricky gn_richard Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
416 nr_000415 gn_ricky gn_richard Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
417 nr_000416 gn_rob gn_robert Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
418 nr_000417 gn_rob gn_robert ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
419 nr_000418 gn_robbie gn_robert Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
420 nr_000419 gn_robbie gn_robert Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
421 nr_000420 gn_rodger gn_roger SpellingVariant 0.9700 true true PrototypeCurated Potential relationship only; never auto-merge characters
422 nr_000421 gn_ron gn_ronald Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
423 nr_000422 gn_ron gn_ronald ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
424 nr_000423 gn_ronnie gn_ronald Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
425 nr_000424 gn_ronnie gn_ronald Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
426 nr_000425 gn_rose gn_rosemary ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
427 nr_000426 gn_rosie gn_rose Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
428 nr_000427 gn_rosie gn_rosemary Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
429 nr_000428 gn_rosie gn_rosemary Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
430 nr_000429 gn_roxie gn_roxanne Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
431 nr_000430 gn_roxy gn_roxanne Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
432 nr_000431 gn_sadie gn_sarah Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
433 nr_000432 gn_sally gn_sarah Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
434 nr_000433 gn_sam gn_samantha Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
435 nr_000434 gn_sam gn_samantha ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
436 nr_000435 gn_sam gn_samuel Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
437 nr_000436 gn_sam gn_samuel ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
438 nr_000437 gn_sammie gn_samantha Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
439 nr_000438 gn_sammy gn_samantha Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
440 nr_000439 gn_sammy gn_samuel Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
441 nr_000440 gn_sammy gn_samuel Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
442 nr_000441 gn_sandra gn_cassandra ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
443 nr_000442 gn_sandy gn_alexander Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
444 nr_000443 gn_sandy gn_sandra Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
445 nr_000444 gn_sandy gn_sandra Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
446 nr_000445 gn_sara gn_sarah SpellingVariant 0.9700 true true PrototypeCurated Potential relationship only; never auto-merge characters
447 nr_000446 gn_sasha gn_alexandra AlternativeForm 0.8800 true true PrototypeCurated Potential relationship only; never auto-merge characters
448 nr_000447 gn_seb gn_sebastian ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
449 nr_000448 gn_shelly gn_michelle Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
450 nr_000449 gn_sofia gn_sophia SpellingVariant 0.9700 true true PrototypeCurated Potential relationship only; never auto-merge characters
451 nr_000450 gn_sophie gn_sophia AlternativeForm 0.8800 true true PrototypeCurated Potential relationship only; never auto-merge characters
452 nr_000451 gn_stella gn_estella Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
453 nr_000452 gn_steph gn_stephanie ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
454 nr_000453 gn_steve gn_stephen ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
455 nr_000454 gn_steve gn_steven Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
456 nr_000455 gn_steven gn_stephen SpellingVariant 0.9700 true true PrototypeCurated Potential relationship only; never auto-merge characters
457 nr_000456 gn_stevie gn_stephanie Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
458 nr_000457 gn_stevie gn_stephen Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
459 nr_000458 gn_stevie gn_steven Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
460 nr_000459 gn_sue gn_susan Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
461 nr_000460 gn_sue gn_susan ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
462 nr_000461 gn_sue gn_suzanne ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
463 nr_000462 gn_susie gn_susan Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
464 nr_000463 gn_susie gn_susan Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
465 nr_000464 gn_susy gn_susan SpellingVariant 0.9700 true true PrototypeCurated Potential relationship only; never auto-merge characters
466 nr_000465 gn_suzie gn_susan SpellingVariant 0.9700 true true PrototypeCurated Potential relationship only; never auto-merge characters
467 nr_000466 gn_suzie gn_suzanne Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
468 nr_000467 gn_suzy gn_susan SpellingVariant 0.9700 true true PrototypeCurated Potential relationship only; never auto-merge characters
469 nr_000468 gn_suzy gn_suzanne Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
470 nr_000469 gn_tammy gn_tamara Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
471 nr_000470 gn_ted gn_edward Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
472 nr_000471 gn_ted gn_theodore Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
473 nr_000472 gn_teddy gn_edward Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
474 nr_000473 gn_teddy gn_edward Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
475 nr_000474 gn_teddy gn_theodore Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
476 nr_000475 gn_teddy gn_theodore Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
477 nr_000476 gn_terri gn_teresa Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
478 nr_000477 gn_tess gn_teresa Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
479 nr_000478 gn_tessa gn_teresa Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
480 nr_000479 gn_tessa gn_teresa Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
481 nr_000480 gn_theo gn_theodore Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
482 nr_000481 gn_theo gn_theodore ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
483 nr_000482 gn_theresa gn_teresa SpellingVariant 0.9700 true true PrototypeCurated Potential relationship only; never auto-merge characters
484 nr_000483 gn_tilly gn_matilda Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
485 nr_000484 gn_tim gn_timothy Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
486 nr_000485 gn_tim gn_timothy ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
487 nr_000486 gn_timmy gn_timothy Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
488 nr_000487 gn_timmy gn_timothy Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
489 nr_000488 gn_tina gn_christina Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
490 nr_000489 gn_tina gn_christina ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
491 nr_000490 gn_tina gn_christine ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
492 nr_000491 gn_tom gn_thomas Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
493 nr_000492 gn_tom gn_thomas ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
494 nr_000493 gn_tommy gn_thomas Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
495 nr_000494 gn_tommy gn_thomas Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
496 nr_000495 gn_toni gn_antoinette Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
497 nr_000496 gn_toni gn_antonia Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
498 nr_000497 gn_tony gn_anthony Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
499 nr_000498 gn_tony gn_anthony Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
500 nr_000499 gn_topher gn_christopher ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
501 nr_000500 gn_tori gn_victoria Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
502 nr_000501 gn_tori gn_victoria ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
503 nr_000502 gn_tricia gn_patricia ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
504 nr_000503 gn_trixie gn_beatrice Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
505 nr_000504 gn_trudy gn_gertrude Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
506 nr_000505 gn_vicki gn_victoria SpellingVariant 0.9700 true true PrototypeCurated Potential relationship only; never auto-merge characters
507 nr_000506 gn_vicky gn_victoria Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
508 nr_000507 gn_vicky gn_victoria Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
509 nr_000508 gn_vince gn_vincent ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
510 nr_000509 gn_vinny gn_vincent Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
511 nr_000510 gn_wally gn_walter Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
512 nr_000511 gn_wally gn_walter Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
513 nr_000512 gn_walt gn_walter Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
514 nr_000513 gn_walt gn_walter ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
515 nr_000514 gn_wendy gn_gwendoline Nickname 0.7200 false true PrototypeCurated Potential relationship only; never auto-merge characters
516 nr_000515 gn_wendy gn_gwendolyn Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
517 nr_000516 gn_will gn_william Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
518 nr_000517 gn_will gn_william ShortForm 0.9200 false true PrototypeCurated Potential relationship only; never auto-merge characters
519 nr_000518 gn_willie gn_william Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
520 nr_000519 gn_willie gn_william Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
521 nr_000520 gn_winnie gn_winifred Diminutive 0.9000 false true PrototypeCurated Potential relationship only; never auto-merge characters
522 nr_000521 gn_winnie gn_winifred Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
523 nr_000522 gn_woody gn_woodrow Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
524 nr_000523 gn_xander gn_alexander Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
525 nr_000524 gn_zach gn_zachary Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters
526 nr_000525 gn_zack gn_zachary Nickname 0.7800 false true DxdcCuratedNicknameMap Potential relationship only; never auto-merge characters

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +0,0 @@
{
"ok": true,
"counts": {
"locales": 6,
"givenNames": 3067,
"relationships": 525,
"usages": 92010
},
"coverage": {
"usageGroups": 18402,
"expectedUsageGroups": 18402
},
"errors": [],
"warnings": []
}

View File

@ -75,8 +75,6 @@ public interface IChapterRepository
Task<IReadOnlyList<Chapter>> ListByBookAsync(int bookId);
Task<Chapter?> GetAsync(int chapterId);
Task<int> SaveAsync(Chapter chapter);
Task UpdatePovCharacterAsync(int chapterId, int? povCharacterId, string source = "Author", decimal? confidence = null, string? evidence = null, string? suggestedName = null);
Task<int> SetScenesPovAsync(int chapterId, int? povCharacterId, bool overwriteAuthor);
Task ArchiveAsync(int chapterId);
}
@ -86,7 +84,7 @@ public interface ISceneRepository
Task<IReadOnlyList<Scene>> ListByFloorPlanAsync(int floorPlanId);
Task<Scene?> GetAsync(int sceneId);
Task<int> SaveAsync(Scene scene);
Task UpdatePovCharacterAsync(int sceneId, int? povCharacterId, string source = "Author", decimal? confidence = null, string? evidence = null, string? suggestedName = null, bool overwriteAuthor = false);
Task UpdatePovCharacterAsync(int sceneId, int? povCharacterId);
Task SaveFloorPlanLinkAsync(int sceneId, int? floorPlanId, int? initialFloorPlanFloorId);
Task SavePurposesAsync(int sceneId, IEnumerable<int> purposeIds);
Task SaveMetricValuesAsync(int sceneId, IEnumerable<SceneMetricValue> metricValues);
@ -143,7 +141,6 @@ public interface ISceneMetricTypeRepository
Task<bool> SetActiveAsync(int metricTypeId, int projectId, bool isActive);
Task<bool> MoveAsync(int metricTypeId, int projectId, string direction);
Task AddMissingDefaultsAsync(int projectId);
Task<int> BackfillDefaultValuesForProjectAsync(int projectId);
Task<bool> RemoveUnusedAsync(int metricTypeId, int projectId);
Task<bool> HasProjectMetricsAsync(int projectId);
}
@ -168,14 +165,6 @@ public interface IPlotRepository
Task<IReadOnlyList<SceneOption>> ListSceneOptionsAsync(int projectId);
}
public interface IStoryIntelligenceNarrativeCandidateRepository
{
Task<int> SaveAsync(StoryIntelligenceNarrativeCandidateSeed candidate);
Task SaveEvidenceAsync(int candidateId, int sceneId, string? contributionType, string? evidence, int sortOrder);
Task<IReadOnlyList<StoryIntelligenceNarrativeCandidate>> ListByBookAsync(int bookId);
Task MarkReviewedAsync(int candidateId, string status, string reviewAction, int userId, int? acceptedPlotLineId, int? acceptedPlotThreadId);
}
public interface IAssetRepository
{
Task<AssetLookupData> GetLookupsAsync(int? projectId = null);
@ -218,7 +207,6 @@ public interface ICharacterRepository
Task<int> AddAliasAsync(int characterId, string alias, int? sortOrder);
Task UpdateAliasAsync(int characterAliasId, string alias, int? sortOrder);
Task DeleteAliasAsync(int characterAliasId);
Task<CharacterMergeResult?> MergeAsync(int sourceCharacterId, int targetCharacterId, int? userId);
Task<IReadOnlyList<CharacterSexValue>> ListSexValuesAsync(int ownerUserId);
Task<CharacterSexValue?> GetSexValueAsync(int characterSexValueId, int ownerUserId);
Task<int> GetOrCreateSexValueAsync(int ownerUserId, string sexName);
@ -2109,31 +2097,10 @@ public sealed class ProjectRepository(ISqlConnectionFactory connectionFactory) :
public async Task HardDeleteForOwnerAsync(int projectId, int userId)
{
using var connection = connectionFactory.CreateConnection();
connection.Open();
using var transaction = connection.BeginTransaction();
try
{
await connection.ExecuteAsync(
"dbo.ProjectHardDelete_PrepareDependencies",
new { ProjectID = projectId, UserID = userId, AllowActiveProject = false },
transaction,
commandType: CommandType.StoredProcedure,
commandTimeout: 120);
await connection.ExecuteAsync(
"dbo.ProjectHardDelete_ForOwner",
new { ProjectID = projectId, UserID = userId },
transaction,
commandType: CommandType.StoredProcedure);
transaction.Commit();
}
catch
{
TryRollback(transaction);
throw;
}
}
public async Task<IReadOnlyList<string>> ListHardDeleteFilePathsForAccountDeletionAsync(int projectId, int userId)
@ -2149,43 +2116,10 @@ public sealed class ProjectRepository(ISqlConnectionFactory connectionFactory) :
public async Task HardDeleteForAccountDeletionAsync(int projectId, int userId)
{
using var connection = connectionFactory.CreateConnection();
connection.Open();
using var transaction = connection.BeginTransaction();
try
{
await connection.ExecuteAsync(
"dbo.ProjectHardDelete_PrepareDependencies",
new { ProjectID = projectId, UserID = userId, AllowActiveProject = true },
transaction,
commandType: CommandType.StoredProcedure,
commandTimeout: 120);
await connection.ExecuteAsync(
"dbo.ProjectHardDelete_ForAccountDeletion",
new { ProjectID = projectId, UserID = userId },
transaction,
commandType: CommandType.StoredProcedure);
transaction.Commit();
}
catch
{
TryRollback(transaction);
throw;
}
}
private static void TryRollback(IDbTransaction transaction)
{
try
{
transaction.Rollback();
}
catch
{
// The stored procedure may have already rolled back the ambient transaction.
}
}
}
@ -2355,8 +2289,6 @@ public sealed class BookRepository(ISqlConnectionFactory connectionFactory) : IB
book.BookNumber,
book.Description,
book.TargetWordCount,
book.StoryEra,
book.PrimaryLocaleCode,
book.WritingStartDate,
book.TargetCompletionDate,
book.PublicationDate,
@ -2465,33 +2397,7 @@ public sealed class ChapterRepository(ISqlConnectionFactory connectionFactory) :
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>(
"dbo.Chapter_Save",
new { chapter.ChapterID, chapter.BookID, chapter.ChapterNumber, chapter.ChapterTitle, chapter.Summary, chapter.ChapterPurposeID, chapter.RevisionStatusID, chapter.POVCharacterID },
commandType: CommandType.StoredProcedure);
}
public async Task UpdatePovCharacterAsync(int chapterId, int? povCharacterId, string source = "Author", decimal? confidence = null, string? evidence = null, string? suggestedName = null)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.Chapter_UpdatePovCharacter",
new
{
ChapterID = chapterId,
POVCharacterID = povCharacterId,
POVSource = source,
POVConfidence = confidence,
POVEvidence = evidence,
POVSuggestedName = suggestedName
},
commandType: CommandType.StoredProcedure);
}
public async Task<int> SetScenesPovAsync(int chapterId, int? povCharacterId, bool overwriteAuthor)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>(
"dbo.Chapter_SetScenesPov",
new { ChapterID = chapterId, POVCharacterID = povCharacterId, OverwriteAuthor = overwriteAuthor },
new { chapter.ChapterID, chapter.BookID, chapter.ChapterNumber, chapter.ChapterTitle, chapter.Summary, chapter.ChapterPurposeID, chapter.RevisionStatusID },
commandType: CommandType.StoredProcedure);
}
@ -2564,27 +2470,17 @@ public sealed class SceneRepository(ISqlConnectionFactory connectionFactory) : I
scene.RevisionStatusID,
scene.PrimaryLocationID,
scene.FloorPlanID,
scene.InitialFloorPlanFloorID,
scene.POVCharacterID
scene.InitialFloorPlanFloorID
},
commandType: CommandType.StoredProcedure);
}
public async Task UpdatePovCharacterAsync(int sceneId, int? povCharacterId, string source = "Author", decimal? confidence = null, string? evidence = null, string? suggestedName = null, bool overwriteAuthor = false)
public async Task UpdatePovCharacterAsync(int sceneId, int? povCharacterId)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.Scene_UpdatePovCharacter",
new
{
SceneID = sceneId,
POVCharacterID = povCharacterId,
POVSource = source,
POVConfidence = confidence,
POVEvidence = evidence,
POVSuggestedName = suggestedName,
OverwriteAuthor = overwriteAuthor
},
new { SceneID = sceneId, POVCharacterID = povCharacterId },
commandType: CommandType.StoredProcedure);
}
@ -2968,15 +2864,6 @@ public sealed class SceneMetricTypeRepository(ISqlConnectionFactory connectionFa
await connection.ExecuteAsync("dbo.SceneMetricType_AddMissingDefaultsForProject", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure);
}
public async Task<int> BackfillDefaultValuesForProjectAsync(int projectId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>(
"dbo.SceneMetric_BackfillDefaultsForProject",
new { ProjectID = projectId },
commandType: CommandType.StoredProcedure);
}
public async Task<bool> RemoveUnusedAsync(int metricTypeId, int projectId)
{
using var connection = connectionFactory.CreateConnection();
@ -3239,86 +3126,6 @@ public sealed class PlotRepository(ISqlConnectionFactory connectionFactory) : IP
}
}
public sealed class StoryIntelligenceNarrativeCandidateRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligenceNarrativeCandidateRepository
{
public async Task<int> SaveAsync(StoryIntelligenceNarrativeCandidateSeed candidate)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>(
"dbo.StoryIntelligenceNarrativeCandidate_Save",
new
{
candidate.ProjectID,
candidate.BookID,
candidate.CandidateType,
candidate.CandidateKey,
candidate.ProposedTitle,
candidate.ProposedDescription,
candidate.Category,
candidate.InferredState,
candidate.ExistingPlotLineID,
candidate.ExistingPlotThreadID,
candidate.RelatedPlotLineID,
candidate.Confidence
},
commandType: CommandType.StoredProcedure);
}
public async Task SaveEvidenceAsync(int candidateId, int sceneId, string? contributionType, string? evidence, int sortOrder)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.StoryIntelligenceNarrativeCandidateEvidence_Save",
new
{
NarrativeCandidateID = candidateId,
SceneID = sceneId,
ContributionType = contributionType,
Evidence = evidence,
SortOrder = sortOrder
},
commandType: CommandType.StoredProcedure);
}
public async Task<IReadOnlyList<StoryIntelligenceNarrativeCandidate>> ListByBookAsync(int bookId)
{
using var connection = connectionFactory.CreateConnection();
using var result = await connection.QueryMultipleAsync(
"dbo.StoryIntelligenceNarrativeCandidate_ListByBook",
new { BookID = bookId },
commandType: CommandType.StoredProcedure);
var candidates = (await result.ReadAsync<StoryIntelligenceNarrativeCandidate>()).ToList();
var evidence = (await result.ReadAsync<StoryIntelligenceNarrativeCandidateEvidence>()).ToList();
var evidenceByCandidate = evidence
.GroupBy(item => item.NarrativeCandidateID)
.ToDictionary(group => group.Key, group => (IReadOnlyList<StoryIntelligenceNarrativeCandidateEvidence>)group.ToList());
foreach (var candidate in candidates)
{
candidate.Evidence = evidenceByCandidate.TryGetValue(candidate.NarrativeCandidateID, out var items) ? items : [];
}
return candidates;
}
public async Task MarkReviewedAsync(int candidateId, string status, string reviewAction, int userId, int? acceptedPlotLineId, int? acceptedPlotThreadId)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.StoryIntelligenceNarrativeCandidate_MarkReviewed",
new
{
NarrativeCandidateID = candidateId,
Status = status,
ReviewAction = reviewAction,
ReviewedByUserID = userId,
AcceptedPlotLineID = acceptedPlotLineId,
AcceptedPlotThreadID = acceptedPlotThreadId
},
commandType: CommandType.StoredProcedure);
}
}
public sealed class AssetRepository(ISqlConnectionFactory connectionFactory) : IAssetRepository
{
public async Task<AssetLookupData> GetLookupsAsync(int? projectId = null)
@ -3735,22 +3542,6 @@ public sealed class CharacterRepository(ISqlConnectionFactory connectionFactory)
commandType: CommandType.StoredProcedure);
}
public async Task<CharacterMergeResult?> MergeAsync(int sourceCharacterId, int targetCharacterId, int? userId)
{
using var connection = connectionFactory.CreateConnection();
try
{
return await connection.QuerySingleOrDefaultAsync<CharacterMergeResult>(
"dbo.Character_Merge",
new { SourceCharacterID = sourceCharacterId, TargetCharacterID = targetCharacterId, UserID = userId },
commandType: CommandType.StoredProcedure);
}
catch (SqlException ex) when (ex.Number == 51000)
{
throw new InvalidOperationException(ex.Message, ex);
}
}
public async Task<IReadOnlyList<CharacterSexValue>> ListSexValuesAsync(int ownerUserId)
{
using var connection = connectionFactory.CreateConnection();
@ -3880,12 +3671,7 @@ public sealed class CharacterRepository(ISqlConnectionFactory connectionFactory)
sceneCharacter.OutfitDescription,
sceneCharacter.PhysicalCondition,
sceneCharacter.EmotionalState,
sceneCharacter.KnowledgeNotes,
sceneCharacter.MatchSource,
sceneCharacter.MatchedText,
sceneCharacter.OccurrenceCount,
sceneCharacter.FirstOccurrenceIndex,
PreserveExistingWhenNull = string.Equals(sceneCharacter.MatchSource, "CoreImportDeterministic", StringComparison.Ordinal)
sceneCharacter.KnowledgeNotes
},
commandType: CommandType.StoredProcedure);
}

View File

@ -0,0 +1,117 @@
using Dapper;
using PlotLine.Models;
namespace PlotLine.Data;
public interface IStoryIntelligenceIllustrationMatchingRepository
{
Task<IReadOnlyList<StoryIntelligenceCharacterIllustrationAssignment>> ListAssignmentsAsync(int projectId);
Task<StoryIntelligenceCharacterIllustrationAssignment> UpsertAssignmentAsync(StoryIntelligenceCharacterIllustrationAssignmentSave request);
Task<StoryIntelligenceIllustrationDemand> UpsertDemandAsync(StoryIntelligenceIllustrationDemandRequest request);
}
public sealed class StoryIntelligenceIllustrationMatchingRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligenceIllustrationMatchingRepository
{
public async Task<IReadOnlyList<StoryIntelligenceCharacterIllustrationAssignment>> ListAssignmentsAsync(int projectId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<StoryIntelligenceCharacterIllustrationAssignment>(
"""
SELECT
StoryIntelligenceCharacterIllustrationAssignmentID,
ProjectID,
CharacterKey,
CharacterName,
IllustrationLibraryItemID,
StableCode,
MatchConfidence,
MatchingScore,
CAST(NULL AS nvarchar(max)) AS EvidenceJson,
CAST(NULL AS nvarchar(max)) AS CandidateDiagnosticsJson,
ReasonChosen,
ReasonReassigned,
IsActive,
CreatedUtc,
UpdatedUtc,
AssignmentStatus,
AssignedUtc,
LastValidatedUtc,
EvidenceVersion,
IsFallback
FROM dbo.StoryIntelligenceCharacterIllustrationAssignments
WHERE ProjectID = @ProjectID
AND IsActive = 1;
""",
new { ProjectID = projectId });
return rows.ToList();
}
public async Task<StoryIntelligenceCharacterIllustrationAssignment> UpsertAssignmentAsync(StoryIntelligenceCharacterIllustrationAssignmentSave request)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<StoryIntelligenceCharacterIllustrationAssignment>(
"""
SET XACT_ABORT ON;
MERGE dbo.StoryIntelligenceCharacterIllustrationAssignments WITH (HOLDLOCK) AS target
USING (SELECT @ProjectID AS ProjectID, @CharacterKey AS CharacterKey) AS source
ON target.ProjectID = source.ProjectID
AND target.CharacterKey = source.CharacterKey
AND target.IsActive = 1
WHEN MATCHED THEN
UPDATE SET
CharacterName = @CharacterName,
IllustrationLibraryItemID = @IllustrationLibraryItemID,
StableCode = @StableCode,
MatchConfidence = @MatchConfidence,
MatchingScore = @MatchingScore,
EvidenceJson = @EvidenceJson,
CandidateDiagnosticsJson = @CandidateDiagnosticsJson,
ReasonChosen = @ReasonChosen,
ReasonReassigned = @ReasonReassigned,
AssignmentStatus = CASE WHEN @IllustrationLibraryItemID IS NULL THEN N'Fallback' ELSE N'Assigned' END,
LastValidatedUtc = SYSUTCDATETIME(),
EvidenceVersion = N'21K',
IsFallback = CASE WHEN @IllustrationLibraryItemID IS NULL THEN 1 ELSE 0 END,
UpdatedUtc = SYSUTCDATETIME()
WHEN NOT MATCHED THEN
INSERT (ProjectID, CharacterKey, CharacterName, IllustrationLibraryItemID, StableCode, MatchConfidence, MatchingScore, EvidenceJson, CandidateDiagnosticsJson, ReasonChosen, ReasonReassigned, AssignmentStatus, AssignedUtc, LastValidatedUtc, EvidenceVersion, IsFallback)
VALUES (@ProjectID, @CharacterKey, @CharacterName, @IllustrationLibraryItemID, @StableCode, @MatchConfidence, @MatchingScore, @EvidenceJson, @CandidateDiagnosticsJson, @ReasonChosen, @ReasonReassigned, CASE WHEN @IllustrationLibraryItemID IS NULL THEN N'Fallback' ELSE N'Assigned' END, SYSUTCDATETIME(), SYSUTCDATETIME(), N'21K', CASE WHEN @IllustrationLibraryItemID IS NULL THEN 1 ELSE 0 END);
SELECT *
FROM dbo.StoryIntelligenceCharacterIllustrationAssignments
WHERE ProjectID = @ProjectID
AND CharacterKey = @CharacterKey
AND IsActive = 1;
""",
request);
}
public async Task<StoryIntelligenceIllustrationDemand> UpsertDemandAsync(StoryIntelligenceIllustrationDemandRequest request)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<StoryIntelligenceIllustrationDemand>(
"""
SET XACT_ABORT ON;
MERGE dbo.StoryIntelligenceIllustrationDemands WITH (HOLDLOCK) AS target
USING (SELECT @ProjectID AS ProjectID, @DemandKey AS DemandKey) AS source
ON ISNULL(target.ProjectID, -1) = ISNULL(source.ProjectID, -1)
AND target.DemandKey = source.DemandKey
WHEN MATCHED THEN
UPDATE SET
RequestCount = RequestCount + 1,
LastRequestedUtc = SYSUTCDATETIME(),
UpdatedUtc = SYSUTCDATETIME()
WHEN NOT MATCHED THEN
INSERT (ProjectID, DemandKey, AgeBand, Presentation, HairColour, SkinTone, HairLength, Glasses, FacialHair)
VALUES (@ProjectID, @DemandKey, @AgeBand, @Presentation, @HairColour, @SkinTone, @HairLength, @Glasses, @FacialHair);
SELECT *
FROM dbo.StoryIntelligenceIllustrationDemands
WHERE ISNULL(ProjectID, -1) = ISNULL(@ProjectID, -1)
AND DemandKey = @DemandKey;
""",
request);
}
}

View File

@ -12,7 +12,6 @@ public interface IStoryIntelligencePipelineRepository
Task<IReadOnlyList<StoryIntelligenceBookPipelineState>> ListForUserAsync(int userId);
Task<StoryIntelligenceBookPipelineState> UpsertAsync(StoryIntelligenceBookPipelineSaveRequest request);
Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId);
Task<StoryIntelligenceImportNotificationTarget?> TryBeginCompletionNotificationAsync(int bookId, int userId);
}
public sealed class StoryIntelligencePipelineRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligencePipelineRepository
@ -82,13 +81,4 @@ public sealed class StoryIntelligencePipelineRepository(ISqlConnectionFactory co
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<StoryIntelligenceImportNotificationTarget?> TryBeginCompletionNotificationAsync(int bookId, int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceImportNotificationTarget>(
"dbo.StoryIntelligenceBookPipeline_TryBeginCompletionNotification",
new { BookID = bookId, UserID = userId },
commandType: CommandType.StoredProcedure);
}
}

View File

@ -34,7 +34,6 @@ public interface IStoryIntelligenceResultRepository
Task<StoryIntelligenceSavedRun?> GetRunAsync(int runId);
Task<StoryIntelligenceSavedChapterResult?> GetChapterResultAsync(int runId);
Task<IReadOnlyList<StoryIntelligenceSavedSceneResult>> ListSceneResultsAsync(int runId);
Task<IReadOnlyList<Scene>> ListChapterScenesAsync(int chapterId);
Task<StoryIntelligenceImportCommit?> GetImportCommitAsync(int runId);
Task<StoryIntelligenceChapterImportReadiness> GetChapterImportReadinessAsync(int chapterId);
Task<StoryIntelligenceImportCommitResult> CommitImportAsync(StoryIntelligenceImportCommitRequest request);
@ -518,20 +517,6 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
return rows.ToList();
}
public async Task<IReadOnlyList<Scene>> ListChapterScenesAsync(int chapterId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<Scene>(
"dbo.Scene_ListByChapter",
new { ChapterID = chapterId },
commandType: CommandType.StoredProcedure);
return rows.Where(scene => !scene.IsArchived)
.OrderBy(scene => scene.SortOrder)
.ThenBy(scene => scene.SceneNumber)
.ThenBy(scene => scene.SceneID)
.ToList();
}
public async Task<StoryIntelligenceImportCommit?> GetImportCommitAsync(int runId)
{
using var connection = connectionFactory.CreateConnection();
@ -581,31 +566,15 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
new { request.ChapterID },
transaction,
commandType: CommandType.StoredProcedure);
var existingScenes = new List<Scene>();
var orderedScenes = request.Scenes.OrderBy(scene => scene.TemporarySceneNumber).ToList();
if (!readiness.CanCommit)
{
existingScenes = (await connection.QueryAsync<Scene>(
"dbo.Scene_ListByChapter",
new { request.ChapterID },
transaction,
commandType: CommandType.StoredProcedure))
.Where(scene => !scene.IsArchived)
.OrderBy(scene => scene.SortOrder)
.ThenBy(scene => scene.SceneNumber)
.ThenBy(scene => scene.SceneID)
.ToList();
if (existingScenes.Count != orderedScenes.Count)
{
transaction.Rollback();
return new StoryIntelligenceImportCommitResult
{
Success = false,
Message = $"This chapter contains {existingScenes.Count:N0} existing scene(s), but Story Intelligence produced {orderedScenes.Count:N0} scene result(s)."
Message = readiness.BlockReason ?? "This chapter already contains scenes."
};
}
}
if (!string.IsNullOrWhiteSpace(request.ChapterSummary) || request.ChapterPurposeID.HasValue)
{
@ -621,6 +590,7 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
commandType: CommandType.StoredProcedure);
}
var orderedScenes = request.Scenes.OrderBy(scene => scene.TemporarySceneNumber).ToList();
for (var index = 0; index < orderedScenes.Count; index++)
{
var item = orderedScenes[index];
@ -642,7 +612,7 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
"dbo.Scene_Save",
new
{
SceneID = item.SceneID ?? (existingScenes.Count > index ? existingScenes[index].SceneID : (int?)null),
SceneID = (int?)null,
ChapterID = request.ChapterID,
SceneNumber = Convert.ToDecimal(item.TemporarySceneNumber),
SceneTitle = sceneTitle,
@ -767,7 +737,7 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
}
var notes = new List<string>();
notes.Add(existingScenes.Count > 0 ? "Enriched existing structural scenes." : "Committed into empty chapter.");
notes.Add("Committed into empty chapter.");
notes.AddRange(request.Warnings.Where(warning => !string.IsNullOrWhiteSpace(warning)));
notes.AddRange(commitWarnings.Where(warning => !string.IsNullOrWhiteSpace(warning)));
@ -794,9 +764,7 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
CommitID = commitId,
ScenesCreated = scenesCreated,
MetricsCreated = metricsCreated,
Message = existingScenes.Count > 0
? $"Enriched {scenesCreated:N0} existing scene(s)."
: $"Created {scenesCreated:N0} scene(s)."
Message = $"Created {scenesCreated:N0} scene(s)."
};
}
catch (Exception ex)

View File

@ -23,7 +23,6 @@ public interface IStoryMemoryRepository
Task UpsertAssignmentAsync(StoryMemoryIllustrationAssignmentSave request);
Task InvalidateAssignmentAsync(int assignmentId, string reason);
Task<StoryMemoryIllustrationDemand> UpsertDemandAsync(StoryMemoryIllustrationDemandSave request);
Task<IReadOnlyList<StoryMemoryIllustrationDemand>> ListDemandsAsync(int importSessionId);
Task<string> GetAppearancePreferenceAsync(int importSessionId);
Task<StoryMemoryImportContext> GetImportContextAsync(int importSessionId);
Task SetAppearancePreferenceAsync(int importSessionId, string preference);
@ -364,18 +363,6 @@ public sealed class StoryMemoryRepository(ISqlConnectionFactory connectionFactor
request);
}
public async Task<IReadOnlyList<StoryMemoryIllustrationDemand>> ListDemandsAsync(int importSessionId)
{
using var connection = connectionFactory.CreateConnection();
return (await connection.QueryAsync<StoryMemoryIllustrationDemand>(
"""
SELECT *
FROM dbo.StoryMemoryIllustrationDemands
WHERE ImportSessionID = @ImportSessionID;
""",
new { ImportSessionID = importSessionId })).ToList();
}
public async Task<string> GetAppearancePreferenceAsync(int importSessionId)
=> (await GetImportContextAsync(importSessionId)).AppearancePreference;

View File

@ -10,9 +10,9 @@ Replace the insertion blocks marked with `{{...}}` before sending.
You are PlotDirector's Chapter Structure engine.
You are an assistant archivist for a novelist. You observe chapter structure only. You do not critique, rewrite, create story entities or infer canon.
You are an assistant archivist for a novelist. You observe chapter structure only. You do not critique, rewrite, summarise scenes, create story entities or infer canon.
Your only task is to identify likely scene boundaries in one chapter, provide a short descriptive title for each detected scene, provide a light factual summary for each detected scene, record a compact structural setting where clear, and record compact point-of-view evidence when it is clear.
Your only task is to identify likely scene boundaries in one chapter.
## Operating Principles
@ -21,9 +21,7 @@ Your only task is to identify likely scene boundaries in one chapter, provide a
3. Prefer slightly larger scenes over excessive fragmentation.
4. Do not split merely because there is a paragraph break.
5. Do not try to create perfect scenes.
6. Return strict JSON only.
7. Do not wrap JSON in markdown fences.
8. Do not include explanatory prose before or after the JSON object.
6. Return JSON only.
## Boundary Guidance
@ -88,25 +86,9 @@ Each `startParagraph` and `endParagraph` must refer to those explicit paragraph
Never invent paragraph numbers.
Never return an `endParagraph` greater than the highest paragraph number supplied.
The final scene must end at the highest paragraph number supplied.
Do not create a separate final scene for a closing line unless it corresponds to an actual supplied paragraph number. If the final beat belongs to the previous scene, include it in the previous scene.
## Point of View Guidance
Identify the default chapter point of view only when the supplied text provides a clear narrator/viewpoint signal.
Explicit manuscript labels such as `Narrated by Maggie` are extremely strong evidence.
When `chapterContext.knownCharacters` contains a matching canonical character or alias, prefer that spelling for POV names.
If no POV can be determined, return `defaultPov` as `null`.
If the chapter has one default POV with a few clear scene-level changes, set `mixedPov` to `true` and add `povOverride` only on scenes where the POV differs from the chapter default.
If the whole chapter is mixed and no default is clear, set `mixedPov` to `true`, return `defaultPov` as `null`, and add scene `povOverride` values only where clear.
Use manuscript names exactly as presented. Do not invent canonical full names.
Do not create placeholder scenes or invalid ranges to "ensure coverage". If the previous scene already ends at the final supplied paragraph, stop there.
Scene boundaries must:
@ -128,8 +110,6 @@ Return only these root properties:
- `schemaVersion`
- `chapterSummary`
- `defaultPov`
- `mixedPov`
- `sceneBoundaries`
Each `sceneBoundaries` item must contain exactly:
@ -137,27 +117,10 @@ Each `sceneBoundaries` item must contain exactly:
- `sceneNumber`
- `startParagraph`
- `endParagraph`
- `suggestedTitle`
- `structuralSummary`
- `structuralSetting`
- `povOverride`
- `confidence`
- `reason`
Use confidence values from `0.0` to `1.0`.
Confidence values must be standard JSON numbers with no spaces inside the number.
Correct examples: `0.8`, `0.75`, `1.0`, `0.0`.
Incorrect examples: `0. 8`, `7 .5`, `0,8`, `"0.8"`.
Use `structuralSummary` for a factual, neutral summary of what happens in that scene. It should normally be 25 to 40 words, avoid interpretation, and only use information present in the supplied text.
Use `structuralSetting` for the scene's compact active place/time label when the chapter text supports it, such as `Beth's car at night`, `church office`, or `Ashdown Trust reception`. Use `null` when no active setting is clear. Do not invent named locations.
Use `suggestedTitle` for a concise, factual scene title. It must be 2 to 6 words, memorable without being dramatic, and grounded only in the supplied text. Avoid generic titles such as "Opening Scene", "Conversation", "Chapter Scene", or "Scene 1". Do not truncate or restate the structural summary.
Use `defaultPov` for the chapter's suggested default narrator/viewpoint character, or `null` when not clear. When present it must contain exactly `characterName`, `confidence`, `evidence`, and `isExplicit`.
Use `povOverride` only where that scene clearly differs from `defaultPov`, or where there is no default but that scene's POV is clear. Otherwise return `null`.
Use short factual reasons, such as:
@ -170,7 +133,7 @@ Do not output:
- assets;
- relationships;
- metrics;
- interpretive scene analysis;
- scene summaries;
- observations;
- continuity warnings;
- plot threads.
@ -181,22 +144,11 @@ Do not output:
{
"schemaVersion": "1.0",
"chapterSummary": "Structural analysis of the supplied chapter.",
"defaultPov": {
"characterName": "Maggie",
"confidence": 0.96,
"evidence": "The chapter begins with an explicit Narrated by Maggie label.",
"isExplicit": true
},
"mixedPov": false,
"sceneBoundaries": [
{
"sceneNumber": 1,
"startParagraph": 1,
"endParagraph": 3,
"suggestedTitle": "Arrival at the Church",
"structuralSummary": "The opening scene follows the same continuous action across the supplied paragraphs, keeping the characters in one time and place while the immediate situation is established.",
"structuralSetting": "church",
"povOverride": null,
"confidence": 0.86,
"reason": "Opening scene remains in the same place and time."
}

View File

@ -44,7 +44,7 @@ It should not:
1. Treat the manuscript as the authority.
2. Prefer omission over invention.
3. Use uncertainty explicitly.
4. Use supplied structural summaries as context rather than regenerating them.
4. Keep summaries neutral and descriptive.
5. Separate present characters from mentioned-only characters.
6. Separate named locations from generic rooms.
7. Treat assets as meaningful story objects, not ordinary props.
@ -77,18 +77,6 @@ It should not:
Use only the scene text and supplied context.
The supplied `sceneContext` distinguishes established PlotDirector context from narrative context and the current manuscript source:
- `book`, `chapter`, `currentScene`, `sceneCharacters`, `configuredMetrics` and `existingPlotLines` are established PlotDirector context from the saved database.
- `neighbourScenes` are narrative orientation only. They may include adjacent titles, summaries, POV and setting, but never full neighbouring scene text.
- the current scene text block is the only manuscript source to analyse.
When `currentScene.structuralSummary` is supplied in `sceneContext`, treat it as PlotDirector's canonical factual summary of the scene. Use it to orient interpretation, metrics, relationships, knowledge, narrative arcs, context and intent. Do not regenerate, rewrite or compete with that factual scene summary.
When `currentScene.structuralSetting` or `currentScene.primaryLocationName` is supplied, treat it as established structural setting context. You may use it for identity/context, but do not invent a new named location from a generic setting label.
Do not regenerate scene boundaries, scene titles, scene numbers, chapter numbers, structural summaries, canonical POV or primary setting. These belong to Core Import and the saved PlotDirector scene model.
When `knownCharacters` is supplied in the scene context, use it only as identity context for names already detected before analysis:
- match scene character names against `canonicalName` and `aliases`;
@ -154,25 +142,62 @@ Use lower confidence when:
- the scene relies on context not supplied;
- interpretation depends on subtext rather than explicit action.
## Summary Rules
Write summaries as an archivist.
The short summary should:
- be one sentence;
- state the main scene event;
- avoid critique;
- avoid advice;
- usually be 12 to 30 words.
The detailed summary should:
- be factual;
- include only supported events;
- preserve uncertainty;
- mention major revelations or changes;
- avoid prose style evaluation.
Good summary style:
`Mara confronts Elias about the missing ledger, and he confirms it vanished after the council meeting.`
Bad summary style:
`This scene needs more tension and should make Elias more suspicious.`
## Metric Scoring Rules
Metrics are descriptive intensity scores. They are not quality ratings.
Metrics are descriptive intensity scores from 1 to 10. They are not quality ratings.
Use the `configuredMetrics` array supplied in `sceneContext`. Do not invent a separate metric catalogue.
Use:
For each configured metric:
- `1`: barely present;
- `2`: very low;
- `3`: low;
- `4`: mild;
- `5`: moderate;
- `6`: noticeable;
- `7`: strong;
- `8`: very strong;
- `9`: dominant;
- `10`: defining feature of the scene.
- use the exact `key` supplied in `sceneContext` as the property name in `metrics`;
- use the supplied `name`, `description`, `minValue`, `maxValue` and `defaultValue` to understand the metric;
- score only what occurs in this scene, not imagined future consequences;
- treat the scale comparatively across scenes in the book;
- use the configured minimum when the metric is absent or barely present;
- use the low range for minor/background presence;
- use the middle range for meaningful/moderate presence;
- use the high range for dominant/intense presence;
- use the configured maximum only for exceptional/extreme presence.
Preferred V1 metrics:
If no configured metrics are supplied, return an empty `metrics` object.
- `action`
- `emotion`
- `tension`
- `conflict`
- `mystery`
- `romance`
- `humour`
- `revelation`
- `pacingIntensity`
Each metric must be an object:
@ -180,7 +205,7 @@ Each metric must be an object:
{ "score": 8, "confidence": 0.75 }
```
Always emit every configured metric. If a metric is barely present, use the configured minimum value with an appropriate confidence. Do not omit configured metric objects.
Always emit every preferred V1 metric. If a metric is barely present, use score `1` with an appropriate confidence. Do not omit preferred metric objects.
## Character Reporting Rules
@ -268,19 +293,14 @@ Example: if Beth thinks `closer, perhaps, to Maggie`, do not create `Maggie at A
## Point Of View Rules
For first-person scenes, canonical PlotDirector POV metadata is authoritative when supplied in `sceneContext.currentScene.povCharacterName`.
For first-person scenes, if the scene text does not explicitly name the narrator:
If canonical POV metadata is supplied:
- use `pointOfView.characterName: "Narrator"` or `null`;
- do not guess the narrator's identity from wider story context unless supplied directly in the scene/chapter metadata;
- if chapter-level narrator metadata is supplied, use it and cite that metadata as evidence;
- otherwise preserve ambiguity.
- use that character name for `pointOfView.characterName`;
- cite the PlotDirector scene metadata in `evidence`;
- do not create `Narrator` as a separate character.
If canonical POV metadata is not supplied and the scene text does not explicitly name the narrator:
- use `pointOfView.characterName: null`;
- preserve ambiguity in `sourceLimits.ambiguityNotes`;
- do not create `Narrator` as a character.
PlotDirector may resolve narrator identity later from chapter narrator metadata. Scene Intelligence should not force narrator identity where the scene does not provide it.
## Asset Reporting Rules
@ -397,55 +417,6 @@ For each raised question, include exactly these properties: `question`, `scope`,
Use a concise lowercase scope such as `plot`, `character`, `asset`, `relationship`, `location`, `timeline` or `scene`. Do not omit `scope`.
## Narrative Arc Rules
Report sustained narrative arcs that this scene materially advances, complicates, reveals or resolves.
Narrative arcs are not final canon. They are evidence-backed signals for later book-level consolidation and author review.
Use `narrativeArcs` for substantial recurring plot-line material such as a disappearance, investigation, quest, conspiracy, romance arc, rivalry, revenge plan, public crisis or long-running secret. Do not use it for a generic scene topic, a one-off event, an ordinary object, or a minor unanswered detail.
When `existingPlotLines` is supplied in `sceneContext`:
- compare the scene's arc signal with the supplied Plot Line `name` and `description`;
- if the scene clearly belongs to an existing Plot Line, set `matchingExistingPlotLineId` to that supplied `plotLineId`;
- if no supplied Plot Line clearly matches, set `matchingExistingPlotLineId` to `null`;
- do not invent Plot Line ids.
For each narrative arc, include exactly these properties: `title`, `description`, `arcType`, `scale`, `matchingExistingPlotLineId`, `evidence`, `confidence`.
Use concise `arcType` values such as `investigation`, `disappearance`, `romance`, `quest`, `conspiracy`, `rivalry`, `secret`, `survival`, `family`, `political`, `mystery`, `revenge`, `redemption`, `subplot` or `other`.
Use `scale: "major"` only when the scene signal appears to belong to a substantial book-level arc. Use `scale: "secondary"` for smaller recurring arcs. Use `scale: "thread"` only when it is likely too small to become a Plot Line; thread-scale signals may still support smaller review candidates later.
Good:
```json
{
"title": "Maggie's Disappearance",
"description": "Beth's search for Maggie advances through absence, clues and investigation.",
"arcType": "disappearance",
"scale": "major",
"matchingExistingPlotLineId": null,
"evidence": "Beth follows a clue that may explain where Maggie went.",
"confidence": 0.82
}
```
Bad:
```json
{
"title": "The kitchen conversation",
"description": "Two characters talk in a kitchen.",
"arcType": "other",
"scale": "major",
"matchingExistingPlotLineId": null,
"evidence": "They talk in the kitchen.",
"confidence": 0.7
}
```
## Questions Answered Rules
Report answers supplied by the scene.
@ -568,6 +539,11 @@ Return exactly one JSON object matching this shape:
"sceneNumber": null,
"sourceLabel": ""
},
"summary": {
"short": "",
"detailed": "",
"confidence": 0.0
},
"scenePurpose": {
"observedFunction": "",
"confidence": 0.0
@ -674,17 +650,6 @@ Return exactly one JSON object matching this shape:
"confidence": 0.0
}
],
"narrativeArcs": [
{
"title": "",
"description": "",
"arcType": "other",
"scale": "major",
"matchingExistingPlotLineId": null,
"evidence": "",
"confidence": 0.0
}
],
"questionsRaised": [
{
"question": "",
@ -715,7 +680,15 @@ Return exactly one JSON object matching this shape:
}
],
"metrics": {
"configuredMetricKey": { "score": 1, "confidence": 0.0 }
"action": { "score": 1, "confidence": 0.0 },
"emotion": { "score": 1, "confidence": 0.0 },
"tension": { "score": 1, "confidence": 0.0 },
"conflict": { "score": 1, "confidence": 0.0 },
"mystery": { "score": 1, "confidence": 0.0 },
"romance": { "score": 1, "confidence": 0.0 },
"humour": { "score": 1, "confidence": 0.0 },
"revelation": { "score": 1, "confidence": 0.0 },
"pacingIntensity": { "score": 1, "confidence": 0.0 }
},
"sourceLimits": {
"containsAmbiguity": false,

View File

@ -1,36 +0,0 @@
# Name Intelligence
Name Intelligence is PlotDirector's shared reference layer for recognised given-name forms, locale/era usage priors and relationship evidence between name forms. The reference tables are global application data: they do not contain user, project, book, character, alias or `IsUsed` state.
## Reference Package
The bundled package is `plotdirector-name-intelligence` version `1.0.0` and lives in:
`Data/Reference/Names/v1.0.0/`
The app publishes the CSVs, manifest, validation report and data-contract documentation as content. Do not embed the 92,010 usage rows in migration SQL or C# constants.
## Import
Administrators can validate or import the package from the Admin dashboard. The importer reads `manifest.json`, validates exact headers, stable-key uniqueness, controlled values, foreign keys, numeric ranges, gender-weight totals and contiguous usage bands before writing data. Imports run in manifest order:
1. `name_locales.csv`
2. `given_names.csv`
3. `name_relationships.csv`
4. `name_usages.csv`
Rows are upserted by stable keys through stored procedures and table-valued parameters. Re-running the same package is idempotent when the checksum matches; use the force re-import action after correcting a local package or applying a future package. Import history is stored in `dbo.NameReferenceImports`.
## Consumer Semantics
Recognition is positive evidence, not a whitelist. Unknown names still pass through the existing deterministic, contextual and AI detection paths. Gender weights are ranking priors only; explicit manuscript evidence wins. Relationships are evidence that two forms can be related and must not auto-merge characters or aliases.
Birth-era lookup should use character birth year when known, then an estimated birth year, then story year, then the nearest/general profile. Locale estimates labelled as blended estimates should be shown as approximate and not described as demographic certainty.
## Project Boundary
Used-name state is computed dynamically from existing `Characters` and `CharacterAliases` for an authorised scope. The global `GivenNames` rows never store ownership or usage. The Names Library page decorates results with current project/book/library usage without mutating reference rows.
## Future Packages
A future package should be placed in a new versioned folder and the expected version in `NameIntelligenceReferencePackage` should be updated. Keep stable keys immutable; add new rows or update non-key fields instead of recycling identities. Validate and import through the admin path after applying the corresponding schema/procedure migration.

View File

@ -43,12 +43,8 @@ public sealed class StoryIntelligenceHub(
return null;
}
var canonicalScenes = run.ChapterID.HasValue
? await persistedRuns.ListChapterScenesAsync(run.ChapterID.Value)
: [];
var currentScene = CurrentCanonicalScene(run, canonicalScenes);
var sceneResults = currentScene is null ? await persistedRuns.ListSceneResultsAsync(runId) : [];
return ToRunProgress(run, "Current status", currentScene, currentScene is null ? LatestSceneSummary(sceneResults) : CanonicalSceneSummary(currentScene));
var sceneResults = await persistedRuns.ListSceneResultsAsync(runId);
return ToRunProgress(run, "Current status", LatestSceneSummary(sceneResults));
}
public async Task<StoryIntelligenceProgressViewModel?> WatchOnboardingStoryIntelligenceBatch(Guid batchId)
@ -60,14 +56,11 @@ public sealed class StoryIntelligenceHub(
public static string UserGroup(int userId) => $"story-intelligence:{userId}";
private static StoryIntelligenceRunProgressEvent ToRunProgress(StoryIntelligenceSavedRun run, string eventType, Scene? currentScene, string? latestSceneSummary)
private static StoryIntelligenceRunProgressEvent ToRunProgress(StoryIntelligenceSavedRun run, string eventType, string? latestSceneSummary)
{
var completedScenes = run.CompletedScenes ?? 0;
var totalScenes = run.TotalDetectedScenes ?? 0;
var elapsedMs = run.TotalDurationMs ?? Math.Max(0, Convert.ToInt64((DateTime.UtcNow - run.StartedUtc).TotalMilliseconds));
var currentSceneNumber = currentScene is null
? CurrentSceneNumber(run)
: Convert.ToInt32(currentScene.SceneNumber);
return new StoryIntelligenceRunProgressEvent
{
@ -86,11 +79,7 @@ public sealed class StoryIntelligenceHub(
FailedScenes = run.FailedScenes,
TotalTokens = run.TotalTokens,
TotalDurationMs = elapsedMs,
CurrentSceneID = currentScene?.SceneID,
CurrentSceneNumber = currentSceneNumber,
CurrentSceneDisplayTitle = currentScene is null || !currentSceneNumber.HasValue
? null
: SceneTitleDisplay.Format(currentScene.SceneNumber, currentScene.SceneTitle),
CurrentSceneNumber = run.CompletedScenes,
ElapsedTime = FormatDuration(elapsedMs),
EstimatedRemaining = EstimateRemaining(elapsedMs, completedScenes, totalScenes),
LatestSceneSummary = latestSceneSummary,
@ -101,56 +90,6 @@ public sealed class StoryIntelligenceHub(
};
}
private static Scene? CurrentCanonicalScene(StoryIntelligenceSavedRun run, IReadOnlyList<Scene> scenes)
{
if (scenes.Count == 0)
{
return null;
}
var orderedScenes = scenes.OrderBy(scene => scene.SceneNumber).ToList();
var sceneNumber = CurrentSceneNumber(run);
if (sceneNumber is not > 0)
{
return orderedScenes[0];
}
var byNumber = orderedScenes.FirstOrDefault(scene => Convert.ToInt32(scene.SceneNumber) == sceneNumber.Value);
if (byNumber is not null)
{
return byNumber;
}
return orderedScenes[Math.Clamp(sceneNumber.Value - 1, 0, orderedScenes.Count - 1)];
}
private static int? CurrentSceneNumber(StoryIntelligenceSavedRun run)
{
var completed = Math.Max(0, run.CompletedScenes ?? 0);
var total = Math.Max(0, run.TotalDetectedScenes ?? 0);
if (run.Status == StoryIntelligenceRunStatuses.Running
&& string.Equals(run.CurrentStage, StoryIntelligenceFailureStages.SceneIntelligence, StringComparison.OrdinalIgnoreCase)
&& total > 0)
{
return Math.Clamp(completed + 1, 1, total);
}
if (completed > 0)
{
return Math.Clamp(completed, 1, total > 0 ? total : completed);
}
return run.Status is StoryIntelligenceRunStatuses.Pending or StoryIntelligenceRunStatuses.Running && total > 0
? 1
: null;
}
private static string? CanonicalSceneSummary(Scene? scene)
=> FirstConfigured(scene?.StructuralSummary, scene?.Summary);
private static string? FirstConfigured(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim();
private static string FormatDuration(long? milliseconds)
{
var elapsed = TimeSpan.FromMilliseconds(Math.Max(0, milliseconds ?? 0));

View File

@ -4,7 +4,6 @@ using PlotLine.Data;
using PlotLine.Models;
using PlotLine.Services;
using System.Security.Claims;
using System.Text.Json;
namespace PlotLine.Hubs;
@ -14,9 +13,7 @@ public sealed class WordCompanionFollowHub(
IWritingWorkspacePresenceService writingPresence,
IOnboardingRepository onboarding,
IManuscriptScanPreviewStore scanStore,
IOnboardingService onboardingService,
IOnboardingBuildRepository onboardingBuilds,
ILogger<WordCompanionFollowHub> logger) : Hub
IOnboardingService onboardingService) : Hub
{
public override async Task OnConnectedAsync()
{
@ -44,12 +41,6 @@ public sealed class WordCompanionFollowHub(
}
var status = await presence.RegisterAsync(userId, Context.ConnectionId, registration);
logger.LogInformation(
"Word Companion registered. UserID={UserID} ProjectID={ProjectID} BookID={BookID} DocumentOpen={DocumentOpen}",
userId,
registration.LinkedProjectID,
registration.LinkedBookID,
registration.DocumentOpen);
await Clients.Group(PresenceGroup(userId)).SendAsync("WordCompanionPresenceChanged", status);
await BroadcastWritingWorkspaceStatusAsync(userId, registration.LinkedProjectID, registration.LinkedBookID);
return status;
@ -59,12 +50,6 @@ public sealed class WordCompanionFollowHub(
{
var userId = RequireUserId();
var status = await presence.HeartbeatAsync(userId, Context.ConnectionId, heartbeat);
logger.LogDebug(
"Word Companion heartbeat. UserID={UserID} ProjectID={ProjectID} BookID={BookID} DocumentOpen={DocumentOpen}",
userId,
heartbeat.LinkedProjectID,
heartbeat.LinkedBookID,
heartbeat.DocumentOpen);
await Clients.Group(PresenceGroup(userId)).SendAsync("WordCompanionPresenceChanged", status);
await BroadcastWritingWorkspaceStatusAsync(userId, heartbeat.LinkedProjectID, heartbeat.LinkedBookID);
return status;
@ -74,11 +59,6 @@ public sealed class WordCompanionFollowHub(
{
var userId = RequireUserId();
await Groups.AddToGroupAsync(Context.ConnectionId, PresenceGroup(userId));
logger.LogInformation(
"Writing workspace watched. UserID={UserID} ProjectID={ProjectID} BookID={BookID}",
userId,
scope.ProjectID,
scope.BookID);
return await WritingWorkspaceStatusAsync(userId, scope);
}
@ -87,13 +67,6 @@ public sealed class WordCompanionFollowHub(
var userId = RequireUserId();
await Groups.AddToGroupAsync(Context.ConnectionId, PresenceGroup(userId));
var status = await writingPresence.RegisterAsync(userId, Context.ConnectionId, registration, await presence.GetStatusAsync(userId));
logger.LogInformation(
"Writing window registered. UserID={UserID} Role={Role} ProjectID={ProjectID} BookID={BookID} Route={Route}",
userId,
registration.WritingRole,
registration.ProjectID,
registration.BookID,
registration.CurrentRoute);
await Clients.Group(PresenceGroup(userId)).SendAsync("WritingWorkspacePresenceChanged", status);
return status;
}
@ -203,7 +176,7 @@ public sealed class WordCompanionFollowHub(
return scanState;
}
public async Task<OnboardingManuscriptBuildResult> StartOnboardingProjectBuild(Guid previewId, bool generateSceneTitles = true)
public async Task<OnboardingManuscriptBuildResult> StartOnboardingProjectBuild(Guid previewId)
{
var userId = RequireUserId();
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildProgress", new OnboardingBuildProgress
@ -214,26 +187,9 @@ public sealed class WordCompanionFollowHub(
PreviewID = previewId
});
OnboardingManuscriptBuildResult? result;
try
{
result = await onboardingService.BuildApprovedStructureAsync(
var result = await onboardingService.BuildApprovedStructureAsync(
previewId,
generateSceneTitles,
progress => Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildProgress", progress));
}
catch (InvalidOperationException ex)
{
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildProgress", new OnboardingBuildProgress
{
Status = "Failed",
Message = ex.Message,
PercentComplete = 25,
PreviewID = previewId
});
throw new HubException(ex.Message);
}
if (result is null)
{
throw new HubException("The reviewed scan could not be found.");
@ -242,116 +198,13 @@ public sealed class WordCompanionFollowHub(
var companionConnectionId = await presence.GetCompanionConnectionIdAsync(userId);
if (!string.IsNullOrWhiteSpace(companionConnectionId))
{
await onboardingBuilds.MarkMarkerSyncSentAsync(result.BuildID);
await Clients.Client(companionConnectionId).SendAsync("UpdateOnboardingBuildMarkers", result);
}
else
{
await onboardingBuilds.MarkMarkerSyncFailedAsync(result.BuildID, "The Word Companion disconnected before marker sync could be sent.");
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildProgress", new OnboardingBuildProgress
{
Status = "Failed",
Message = "The Word Companion disconnected before PlotDirector could update and save Word markers.",
PercentComplete = 90,
ChaptersCreated = result.ChaptersCreated,
ScenesCreated = result.ScenesCreated,
CharactersCreated = result.CharactersCreated + result.CharactersReused,
SceneAppearancesCreated = result.SceneAppearancesCreated,
PreviewID = previewId
});
}
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildCompleted", result);
return result;
}
public async Task CompleteOnboardingBuildMarkerSync(int buildId, MarkerSyncClientResult? result = null)
{
var userId = RequireUserId();
var markerStatus = NormaliseCompletedMarkerStatus(result?.MarkerStatus);
var message = string.IsNullOrWhiteSpace(result?.Message)
? markerStatus switch
{
"SaveNotRequired" => "Scene markers already existed. No Word save was required.",
"UserConfirmedSave" => "Scene markers added; user confirmed the Word document was saved.",
_ => "Scene markers added and document saved."
}
: result.Message;
logger.LogInformation(
"Onboarding marker sync completed. UserID={UserID} BuildID={BuildID} MarkerStatus={MarkerStatus} MarkersChanged={MarkersChanged} SaveState={SaveState} SaveApi={SaveApi} Host={Host} Platform={Platform} Version={Version} DocumentGuid={DocumentGuid}",
userId,
buildId,
markerStatus,
result?.MarkersChanged,
result?.SaveState,
result?.SaveApi,
result?.Host,
result?.Platform,
result?.Version,
result?.DocumentGuid);
await onboardingBuilds.MarkMarkerSyncCompletedAsync(buildId, markerStatus, message);
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildProgress", new OnboardingBuildProgress
{
Status = "Complete",
Message = "Manuscript structure has been created and saved. Word may now be closed; keep this browser workflow open to review characters.",
PercentComplete = 100,
PreviewID = result?.PreviewID,
BuildID = buildId,
MarkerStatus = markerStatus
});
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildMarkerSyncCompleted", new { buildId, markerStatus, previewID = result?.PreviewID });
}
public async Task RequireOnboardingBuildManualSave(int buildId, string message, MarkerSyncClientResult? result = null)
{
var userId = RequireUserId();
logger.LogWarning(
"Onboarding marker sync requires manual Word save. UserID={UserID} BuildID={BuildID} Message={Message} Diagnostics={Diagnostics}",
userId,
buildId,
message,
SerialiseMarkerDiagnostics(result));
await onboardingBuilds.MarkMarkerSyncUserSaveRequiredAsync(buildId, message);
var progress = new OnboardingBuildProgress
{
Status = "Running",
Message = string.IsNullOrWhiteSpace(message)
? "Scene markers have been added. Save the document in Word, then continue the import again."
: message,
PercentComplete = 90,
PreviewID = result?.PreviewID,
BuildID = buildId,
MarkerStatus = "UserSaveRequired"
};
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildProgress", progress);
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildManualSaveRequired", progress);
}
public async Task FailOnboardingBuildMarkerSync(int buildId, string message, MarkerSyncClientResult? result = null)
{
var userId = RequireUserId();
logger.LogWarning(
"Onboarding marker sync failed. UserID={UserID} BuildID={BuildID} Message={Message} Diagnostics={Diagnostics}",
userId,
buildId,
message,
SerialiseMarkerDiagnostics(result));
await onboardingBuilds.MarkMarkerSyncFailedAsync(buildId, message, SerialiseMarkerDiagnostics(result));
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildProgress", new OnboardingBuildProgress
{
Status = "Failed",
Message = string.IsNullOrWhiteSpace(message)
? "Word marker sync failed. Keep Word open and try again."
: message,
PercentComplete = 90,
BuildID = buildId,
MarkerStatus = "Failed"
});
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
int? disconnectedUserId = null;
@ -406,14 +259,9 @@ public sealed class WordCompanionFollowHub(
=> string.Equals(state.WritingSoftware, WritingSoftwareValues.MicrosoftWord, StringComparison.Ordinal)
|| state.UsesWordCompanion == true;
private static string NormaliseCompletedMarkerStatus(string? markerStatus)
=> markerStatus is "SaveNotRequired" or "UserConfirmedSave" ? markerStatus : "Completed";
private static string? SerialiseMarkerDiagnostics(MarkerSyncClientResult? result)
=> result is null ? null : JsonSerializer.Serialize(result);
private static ManuscriptScanPreview NormalisePreviewContext(ManuscriptScanPreview preview, int userId, UserOnboardingState state)
{
var characterCandidates = WordCompanionService.DiscoverManuscriptScanCharacterCandidates(preview);
return new()
{
PreviewID = preview.PreviewID,
@ -426,30 +274,12 @@ public sealed class WordCompanionFollowHub(
CompanionDocumentIdentifier = preview.CompanionDocumentIdentifier,
TotalWordCount = preview.TotalWordCount,
ChapterCount = preview.ChapterCount,
SceneCount = 0,
CharacterCandidateCount = 0,
SceneCount = preview.SceneCount,
CharacterCandidateCount = characterCandidates.Count,
CreatedUtc = preview.CreatedUtc,
Chapters = preview.Chapters,
Scenes = [],
CharacterCandidates = []
Scenes = preview.Scenes,
CharacterCandidates = characterCandidates
};
}
}
public sealed class MarkerSyncClientResult
{
public Guid? PreviewID { get; init; }
public string? MarkerStatus { get; init; }
public string? Message { get; init; }
public bool? MarkersChanged { get; init; }
public string? SaveState { get; init; }
public string? SaveApi { get; init; }
public string? Host { get; init; }
public string? Platform { get; init; }
public string? Version { get; init; }
public string? DocumentGuid { get; init; }
public string? DocumentUrl { get; init; }
public string? ErrorName { get; init; }
public string? ErrorCode { get; init; }
public string? ErrorMessage { get; init; }
}

View File

@ -7,8 +7,6 @@ public sealed class ChapterStructureModel
{
public string? SchemaVersion { get; init; }
public string? ChapterSummary { get; init; }
public ChapterStructurePovSuggestion? DefaultPov { get; init; }
public bool? MixedPov { get; init; }
public List<ChapterSceneBoundary>? SceneBoundaries { get; init; }
[JsonExtensionData]
@ -20,21 +18,9 @@ public sealed class ChapterSceneBoundary
public int? SceneNumber { get; init; }
public int? StartParagraph { get; init; }
public int? EndParagraph { get; init; }
public string? SuggestedTitle { get; init; }
public string? StructuralSummary { get; init; }
public string? StructuralSetting { get; init; }
public ChapterStructurePovSuggestion? PovOverride { get; init; }
public decimal? Confidence { get; init; }
public string? Reason { get; init; }
[JsonExtensionData]
public Dictionary<string, JsonElement>? ExtensionData { get; init; }
}
public sealed class ChapterStructurePovSuggestion
{
public string? CharacterName { get; init; }
public decimal? Confidence { get; init; }
public string? Evidence { get; init; }
public bool? IsExplicit { get; init; }
}

View File

@ -235,8 +235,6 @@ public sealed class Book
public int BookNumber { get; set; }
public string? Description { get; set; }
public int? TargetWordCount { get; set; }
public string? StoryEra { get; set; }
public string? PrimaryLocaleCode { get; set; }
public DateTime? WritingStartDate { get; set; }
public DateTime? TargetCompletionDate { get; set; }
public DateTime? PublicationDate { get; set; }
@ -257,53 +255,6 @@ public sealed class Book
: null;
}
public sealed record BookContextOption(string Value, string Label, int? RepresentativeBirthYear = null);
public static class BookContextOptions
{
public const string ContemporaryEra = "contemporary";
public static readonly IReadOnlyList<BookContextOption> StoryEras =
[
new("historical", "Historical"),
new("1900s", "1900s", 1905),
new("1910s", "1910s", 1915),
new("1920s", "1920s", 1925),
new("1930s", "1930s", 1935),
new("1940s", "1940s", 1945),
new("1950s", "1950s", 1955),
new("1960s", "1960s", 1965),
new("1970s", "1970s", 1975),
new("1980s", "1980s", 1985),
new("1990s", "1990s", 1995),
new("2000s", "2000s", 2005),
new("2010s", "2010s", 2015),
new("2020s", "2020s", 2025),
new(ContemporaryEra, "Contemporary"),
new("future", "Future"),
new("mixed-other", "Mixed / other")
];
public static readonly IReadOnlyList<BookContextOption> PrimaryLocales =
[
new("en-GB", "United Kingdom"),
new("en-IE", "Ireland"),
new("en-US", "United States"),
new("en-CA", "Canada"),
new("en-AU", "Australia"),
new("en-NZ", "New Zealand")
];
public static int? RepresentativeBirthYear(string? storyEra)
=> StoryEras.FirstOrDefault(option => string.Equals(option.Value, storyEra, StringComparison.OrdinalIgnoreCase))?.RepresentativeBirthYear;
public static string? StoryEraLabel(string? storyEra)
=> StoryEras.FirstOrDefault(option => string.Equals(option.Value, storyEra, StringComparison.OrdinalIgnoreCase))?.Label;
public static string? PrimaryLocaleLabel(string? localeCode)
=> PrimaryLocales.FirstOrDefault(option => string.Equals(option.Value, localeCode, StringComparison.OrdinalIgnoreCase))?.Label;
}
public sealed class ManuscriptDocumentModel
{
public int ManuscriptDocumentID { get; set; }
@ -393,11 +344,6 @@ public sealed class Chapter
public decimal ChapterNumber { get; set; }
public string ChapterTitle { get; set; } = string.Empty;
public int? POVCharacterID { get; set; }
public string? POVCharacterName { get; set; }
public string? POVSource { get; set; }
public decimal? POVConfidence { get; set; }
public string? POVEvidence { get; set; }
public string? POVSuggestedName { get; set; }
public string? Summary { get; set; }
public int? ChapterPurposeID { get; set; }
public string? ChapterPurposeName { get; set; }
@ -430,16 +376,8 @@ public sealed class Scene
public string? ChapterTitle { get; set; }
public decimal SceneNumber { get; set; }
public string SceneTitle { get; set; } = string.Empty;
public string? SceneTitleSource { get; set; }
public string? Summary { get; set; }
public string? StructuralSummary { get; set; }
public string? StructuralSetting { get; set; }
public int? POVCharacterID { get; set; }
public string? POVCharacterName { get; set; }
public string? POVSource { get; set; }
public decimal? POVConfidence { get; set; }
public string? POVEvidence { get; set; }
public string? POVSuggestedName { get; set; }
public int? PrimaryLocationID { get; set; }
public int? FloorPlanID { get; set; }
public int? InitialFloorPlanFloorID { get; set; }
@ -481,7 +419,6 @@ public sealed class Scene
public int PendingCharacterSuggestionCount { get; set; }
public int PendingAssetSuggestionCount { get; set; }
public int PendingLocationSuggestionCount { get; set; }
public ManuscriptTextStatistics? TextStatistics { get; set; }
public string TimeLabel
{
@ -497,25 +434,6 @@ public sealed class Scene
}
}
public sealed class ManuscriptTextStatistics
{
public int TotalWordCount { get; set; }
public int DialogueWordCount { get; set; }
public int ProseWordCount { get; set; }
public int TotalParagraphCount { get; set; }
public int DialogueParagraphCount { get; set; }
public int ProseParagraphCount { get; set; }
public decimal DialoguePercentage => TotalWordCount <= 0
? 0m
: Math.Round(DialogueWordCount * 100m / TotalWordCount, 1);
public decimal? AverageDialogueParagraphLength => DialogueParagraphCount <= 0
? null
: Math.Round(DialogueWordCount * 1m / DialogueParagraphCount, 1);
public decimal? AverageProseParagraphLength => ProseParagraphCount <= 0
? null
: Math.Round(ProseWordCount * 1m / ProseParagraphCount, 1);
}
public sealed class RevisionStatus
{
public int RevisionStatusID { get; set; }
@ -1012,14 +930,6 @@ public sealed class Character
public string? ArchivedReason { get; set; }
}
public sealed class CharacterMergeResult
{
public int SourceCharacterID { get; set; }
public int TargetCharacterID { get; set; }
public string SourceCharacterName { get; set; } = string.Empty;
public string TargetCharacterName { get; set; } = string.Empty;
}
public sealed class CharacterSexValue
{
public int CharacterSexValueID { get; set; }
@ -1087,10 +997,6 @@ public sealed class SceneCharacter
public string? PhysicalCondition { get; set; }
public string? EmotionalState { get; set; }
public string? KnowledgeNotes { get; set; }
public string? MatchSource { get; set; }
public string? MatchedText { get; set; }
public int? OccurrenceCount { get; set; }
public int? FirstOccurrenceIndex { get; set; }
public decimal SceneNumber { get; set; }
public string SceneTitle { get; set; } = string.Empty;
public decimal ChapterNumber { get; set; }

View File

@ -34,7 +34,6 @@ public static class IllustrationLibraryStatuses
public const string Failed = "Failed";
public const string Superseded = "Superseded";
public const string Disabled = "Disabled";
public const string Archived = "Archived";
public static readonly IReadOnlySet<string> All = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
@ -46,8 +45,7 @@ public static class IllustrationLibraryStatuses
Rejected,
Failed,
Superseded,
Disabled,
Archived
Disabled
};
public static bool IsValid(string? status)
@ -62,13 +60,10 @@ public static class IllustrationLibraryStatuses
(Generating, Failed) => true,
(Generated, Approved) => true,
(Generated, Rejected) => true,
(Generated, Archived) => true,
(Approved, Superseded) => true,
(Approved, Archived) => true,
(Rejected, Queued) => true,
(Failed, Queued) => true,
(Approved, Disabled) => true,
(Disabled, Archived) => true,
(Disabled, Queued) => true,
_ when string.Equals(from, to, StringComparison.OrdinalIgnoreCase) => true,
_ => false
@ -100,18 +95,6 @@ public sealed class IllustrationGenerationSpecification
public IReadOnlyList<string> Palette { get; init; } = [];
public IReadOnlyList<string> MetadataTags { get; init; } = [];
public Dictionary<string, string> Metadata { get; init; } = new(StringComparer.OrdinalIgnoreCase);
public string? ImageKind { get; init; }
public string? CanonicalName { get; init; }
public string? BaseType { get; init; }
public string? SpecificityLevel { get; init; }
public bool IsFallbackClassification { get; init; }
public string? ReuseScope { get; init; }
public int? SourceBookId { get; init; }
public int? SourceChapterId { get; init; }
public int? SourceSceneId { get; init; }
public string? LinkedEntityType { get; init; }
public int? LinkedEntityId { get; init; }
public string? GenerationReason { get; init; }
public IReadOnlyList<string> Validate()
{
@ -190,28 +173,6 @@ public sealed class IllustrationLibraryItem
public string? Prompt { get; set; }
public string? PromptTemplateVersion { get; set; }
public string? ProviderRequestID { get; set; }
public string? Quality { get; set; }
public string? Size { get; set; }
public string? OutputFormat { get; set; }
public string? ImageKind { get; set; }
public string? CanonicalName { get; set; }
public string? BaseType { get; set; }
public string? SpecificityLevel { get; set; }
public bool IsFallbackClassification { get; set; }
public string? ReuseScope { get; set; }
public int? SourceProjectID { get; set; }
public int? SourceBookID { get; set; }
public int? SourceChapterID { get; set; }
public int? SourceSceneID { get; set; }
public string? LinkedEntityType { get; set; }
public int? LinkedEntityID { get; set; }
public string? GenerationReason { get; set; }
public string? GenerationStatus { get; set; }
public bool IsArchived { get; set; }
public DateTime? ArchivedAt { get; set; }
public string? ArchiveReason { get; set; }
public int? SupersededByImageID { get; set; }
public int? ReuseOfImageID { get; set; }
public int AttemptCount { get; set; }
public int GenerationAttemptCount { get; set; }
public int? ParentItemID { get; set; }
@ -239,7 +200,6 @@ public sealed class IllustrationLibrarySummary
public int FailedCount { get; set; }
public int SupersededCount { get; set; }
public int DisabledCount { get; set; }
public int ArchivedCount { get; set; }
}
public sealed class IllustrationLibraryFilter
@ -269,9 +229,6 @@ public sealed record IllustrationImageGenerationResult(
string? Provider = null,
string? Model = null,
string? ProviderRequestID = null,
string? Quality = null,
string? Size = null,
string? OutputFormat = null,
string? ErrorMessage = null)
{
[JsonIgnore]

View File

@ -19,7 +19,6 @@ public static class ManuscriptScanReviewStatuses
public const string ReviewInProgress = "ReviewInProgress";
public const string ReviewComplete = "ReviewComplete";
public const string ReadyToImport = "ReadyToImport";
public const string CoreImportComplete = "CoreImportComplete";
}
public sealed class ManuscriptScanCommand
@ -79,26 +78,6 @@ public sealed class ManuscriptScanChapterPreview
public string? ChapterText { get; init; }
public int? StartPosition { get; init; }
public int? ExistingChapterID { get; init; }
public IReadOnlyList<ManuscriptScanParagraphMap> ParagraphMap { get; init; } = [];
public IReadOnlyList<ManuscriptScanSourceParagraph> SourceParagraphs { get; init; } = [];
}
public sealed class ManuscriptScanParagraphMap
{
public int StructuralParagraphOrdinal { get; init; }
public int WordParagraphIndex { get; init; }
public string Text { get; init; } = string.Empty;
}
public sealed class ManuscriptScanSourceParagraph
{
public int WordParagraphIndex { get; init; }
public string Text { get; init; } = string.Empty;
public bool IsBlank { get; init; }
public bool IsChapterHeading { get; init; }
public bool IsSceneHeading { get; init; }
public bool IsSceneSeparator { get; init; }
public bool IncludeInSourceText { get; init; }
}
public sealed class ManuscriptScanScenePreview
@ -123,17 +102,6 @@ public sealed class ManuscriptScanCharacterCandidatePreview
public string? Reason { get; init; }
public bool IsExistingCharacterMatch { get; init; }
public string? SuggestedImportance { get; init; }
public IReadOnlyList<ManuscriptScanCharacterEvidenceScene> EvidenceScenes { get; init; } = [];
}
public sealed class ManuscriptScanCharacterEvidenceScene
{
public int SceneID { get; init; }
public int ChapterID { get; init; }
public int ChapterNumber { get; init; }
public int SceneNumber { get; init; }
public int MentionCount { get; init; }
public string? StructuralSummary { get; init; }
}
public sealed class ManuscriptScanState
@ -154,7 +122,6 @@ public sealed class ManuscriptScanReviewDecision
{
public Guid PreviewID { get; init; }
public string Status { get; init; } = ManuscriptScanReviewStatuses.ScanComplete;
public bool GenerateSceneTitles { get; init; } = true;
public DateTime UpdatedUtc { get; init; } = DateTime.UtcNow;
public IReadOnlyList<ManuscriptScanChapterReviewDecision> Chapters { get; init; } = [];
public IReadOnlyList<ManuscriptScanSceneReviewDecision> Scenes { get; init; } = [];
@ -209,33 +176,9 @@ public sealed class OnboardingManuscriptBuildResult
public IReadOnlyList<OnboardingBuildChapterMap> ChapterMappings { get; init; } = [];
public IReadOnlyList<OnboardingBuildSceneMap> SceneMappings { get; init; } = [];
public IReadOnlyList<OnboardingBuildCharacterMap> CharacterMappings { get; init; } = [];
public IReadOnlyList<OnboardingBuildMarkerInstruction> ChapterMarkerInstructions { get; init; } = [];
public IReadOnlyList<OnboardingBuildMarkerInstruction> SceneMarkerInstructions { get; init; } = [];
public IReadOnlyList<OnboardingSceneStructureSummary> SceneStructure { get; init; } = [];
public string? MarkerWarning { get; init; }
}
public sealed class OnboardingSceneStructureSummary
{
public string TemporaryChapterKey { get; init; } = string.Empty;
public int ChapterNumber { get; init; }
public string ChapterTitle { get; init; } = string.Empty;
public int SceneCount { get; init; }
public int WordCount { get; init; }
public IReadOnlyList<OnboardingSceneStructureDetail> Scenes { get; init; } = [];
}
public sealed class OnboardingSceneStructureDetail
{
public int SceneNumberWithinChapter { get; init; }
public string Title { get; init; } = string.Empty;
public string DisplayTitle => SceneTitleDisplay.Format(SceneNumberWithinChapter, Title);
public int WordCount { get; init; }
public string? StructuralSummary { get; init; }
public int StartParagraph { get; init; }
public int EndParagraph { get; init; }
}
public sealed class OnboardingBuildChapterMap
{
public string TemporaryChapterKey { get; init; } = string.Empty;
@ -246,8 +189,6 @@ public sealed class OnboardingBuildSceneMap
{
public string TemporarySceneKey { get; init; } = string.Empty;
public int SceneID { get; init; }
public string? SceneTitle { get; init; }
public string? SceneTitleSource { get; init; }
}
public sealed class OnboardingBuildCharacterMap
@ -257,16 +198,6 @@ public sealed class OnboardingBuildCharacterMap
public bool Created { get; init; }
}
public sealed class OnboardingBuildMarkerInstruction
{
public string TemporaryKey { get; init; } = string.Empty;
public int EntityID { get; init; }
public int WordParagraphIndex { get; init; }
public string MarkerTag { get; init; } = string.Empty;
public string MarkerTitle { get; init; } = string.Empty;
public string MarkerPrefix { get; init; } = string.Empty;
}
public sealed class OnboardingBuildProgress
{
public string Status { get; init; } = "Running";
@ -277,6 +208,4 @@ public sealed class OnboardingBuildProgress
public int CharactersCreated { get; init; }
public int SceneAppearancesCreated { get; init; }
public Guid? PreviewID { get; init; }
public int? BuildID { get; init; }
public string? MarkerStatus { get; init; }
}

View File

@ -1,161 +0,0 @@
namespace PlotLine.Models;
public static class NameIntelligenceReferencePackage
{
public const string PackageId = "plotdirector-name-intelligence";
public const string Version = "1.0.0";
public const string RelativePath = "Data/Reference/Names/v1.0.0";
}
public sealed class NameLocale
{
public int NameLocaleID { get; set; }
public string LocaleCode { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string IsoCountryCode { get; set; } = string.Empty;
public string EvidenceBasis { get; set; } = string.Empty;
public decimal DefaultConfidence { get; set; }
public int SortOrder { get; set; }
public bool IsActive { get; set; }
}
public sealed class GivenNameReference
{
public int GivenNameID { get; set; }
public string GivenNameKey { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string NormalisedName { get; set; } = string.Empty;
public string NameType { get; set; } = string.Empty;
public bool IsWordAmbiguous { get; set; }
public bool IsActive { get; set; }
public string? Notes { get; set; }
}
public sealed class NameUsageProfile
{
public int NameUsageID { get; set; }
public string UsageKey { get; set; } = string.Empty;
public int GivenNameID { get; set; }
public int NameLocaleID { get; set; }
public string LocaleCode { get; set; } = string.Empty;
public string LocaleDisplayName { get; set; } = string.Empty;
public int BirthYearFrom { get; set; }
public int BirthYearTo { get; set; }
public decimal MaleWeight { get; set; }
public decimal FemaleWeight { get; set; }
public decimal UsageWeight { get; set; }
public decimal Confidence { get; set; }
public string SourceType { get; set; } = string.Empty;
public string? SourceReference { get; set; }
public bool IsActive { get; set; }
public string? Notes { get; set; }
public string YearMatchKind { get; set; } = string.Empty;
}
public sealed class NameRelationshipReference
{
public int NameRelationshipID { get; set; }
public string RelationshipKey { get; set; } = string.Empty;
public int FromGivenNameID { get; set; }
public int ToGivenNameID { get; set; }
public string FromDisplayName { get; set; } = string.Empty;
public string ToDisplayName { get; set; } = string.Empty;
public string RelationshipType { get; set; } = string.Empty;
public decimal Strength { get; set; }
public bool IsBidirectional { get; set; }
public bool IsActive { get; set; }
public string? CurationSource { get; set; }
public string? Notes { get; set; }
}
public sealed class NameUsageDecoration
{
public string MatchedName { get; set; } = string.Empty;
public string MatchType { get; set; } = string.Empty;
public int CharacterID { get; set; }
public string CharacterName { get; set; } = string.Empty;
public int ProjectID { get; set; }
public string ProjectName { get; set; } = string.Empty;
public int? BookID { get; set; }
public string? BookTitle { get; set; }
public string? BookSubtitle { get; set; }
public string Scope { get; set; } = string.Empty;
public string BookDisplayTitle => BookTitleFormatter.DisplayTitle(BookTitle, BookSubtitle);
}
public sealed class NameRecognitionResult
{
public bool Recognised { get; init; }
public GivenNameReference? Name { get; init; }
public NameUsageProfile? Usage { get; init; }
public IReadOnlyList<NameRelationshipReference> RelatedForms { get; init; } = [];
public string Message { get; init; } = "No reference evidence.";
}
public sealed class NameSearchFilter
{
public int ProjectID { get; set; }
public int? BookID { get; set; }
public string? Search { get; set; }
public string? LocaleCode { get; set; }
public int? BirthYear { get; set; }
public string? GenderPreference { get; set; }
public decimal? GenderThreshold { get; set; }
public string? NameType { get; set; }
public decimal? MinimumUsage { get; set; }
public string UsedScope { get; set; } = "Project";
public bool? UsedOnly { get; set; }
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 25;
}
public sealed class NameSearchResult
{
public int GivenNameID { get; set; }
public string GivenNameKey { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string NormalisedName { get; set; } = string.Empty;
public string NameType { get; set; } = string.Empty;
public bool IsWordAmbiguous { get; set; }
public decimal? MaleWeight { get; set; }
public decimal? FemaleWeight { get; set; }
public decimal? UsageWeight { get; set; }
public decimal? Confidence { get; set; }
public int? BirthYearFrom { get; set; }
public int? BirthYearTo { get; set; }
public string? LocaleCode { get; set; }
public string? LocaleDisplayName { get; set; }
public string? SourceType { get; set; }
public string? RelatedForms { get; set; }
public string? UsedSummary { get; set; }
public int UsedCount { get; set; }
public int TotalRows { get; set; }
}
public sealed class NameReferenceImportStatus
{
public int? NameReferenceImportID { get; set; }
public string PackageID { get; set; } = string.Empty;
public string SemanticVersion { get; set; } = string.Empty;
public string? PackageChecksum { get; set; }
public DateTime? StartedUtc { get; set; }
public DateTime? CompletedUtc { get; set; }
public bool Succeeded { get; set; }
public int LocaleRows { get; set; }
public int GivenNameRows { get; set; }
public int RelationshipRows { get; set; }
public int UsageRows { get; set; }
public string? ErrorSummary { get; set; }
}
public sealed record NameReferenceImportResult(
bool Succeeded,
bool AlreadyCurrent,
string Message,
string PackageId,
string Version,
int LocaleRows,
int GivenNameRows,
int RelationshipRows,
int UsageRows,
IReadOnlyList<string> Errors);

View File

@ -18,7 +18,6 @@ public sealed class SceneIntelligenceScene
public List<SceneIntelligenceRelationship>? Relationships { get; init; }
public List<SceneIntelligenceKnowledgeChange>? KnowledgeChanges { get; init; }
public List<SceneIntelligenceTimelineClue>? TimelineClues { get; init; }
public List<SceneIntelligenceNarrativeArc>? NarrativeArcs { get; init; }
public List<SceneIntelligenceQuestionRaised>? QuestionsRaised { get; init; }
public List<SceneIntelligenceQuestionAnswered>? QuestionsAnswered { get; init; }
public List<SceneIntelligenceObservation>? Observations { get; init; }
@ -208,20 +207,6 @@ public sealed class SceneIntelligenceQuestionRaised
public Dictionary<string, JsonElement>? ExtensionData { get; init; }
}
public sealed class SceneIntelligenceNarrativeArc
{
public string? Title { get; init; }
public string? Description { get; init; }
public string? ArcType { get; init; }
public string? Scale { get; init; }
public int? MatchingExistingPlotLineId { get; init; }
public string? Evidence { get; init; }
public decimal? Confidence { get; init; }
[JsonExtensionData]
public Dictionary<string, JsonElement>? ExtensionData { get; init; }
}
public sealed class SceneIntelligenceQuestionAnswered
{
public string? Question { get; init; }

View File

@ -1,34 +0,0 @@
namespace PlotLine.Models;
public static class SceneTitleDisplay
{
public static string Format(int sceneNumber, string? title)
=> Format((decimal)sceneNumber, title);
public static string Format(decimal sceneNumber, string? title)
{
var number = $"Scene {sceneNumber:0.##}";
var cleanTitle = Clean(title);
if (string.IsNullOrWhiteSpace(cleanTitle) || IsDefaultSceneTitle(cleanTitle, sceneNumber))
{
return number;
}
return $"{number} \u00b7 {cleanTitle}";
}
public static string? Clean(string? title)
{
if (string.IsNullOrWhiteSpace(title))
{
return null;
}
var clean = string.Join(' ', title.Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
return string.IsNullOrWhiteSpace(clean) ? null : clean;
}
private static bool IsDefaultSceneTitle(string title, decimal sceneNumber)
=> string.Equals(title, $"Scene {sceneNumber:0.##}", StringComparison.OrdinalIgnoreCase)
|| string.Equals(title, $"Scene {sceneNumber:N0}", StringComparison.OrdinalIgnoreCase);
}

View File

@ -0,0 +1,70 @@
namespace PlotLine.Models;
public sealed class StoryIntelligenceCharacterIllustrationAssignment
{
public int StoryIntelligenceCharacterIllustrationAssignmentID { get; init; }
public int ProjectID { get; init; }
public string CharacterKey { get; init; } = string.Empty;
public string CharacterName { get; init; } = string.Empty;
public int? IllustrationLibraryItemID { get; init; }
public string? StableCode { get; init; }
public decimal MatchConfidence { get; init; }
public int MatchingScore { get; init; }
public string? EvidenceJson { get; init; }
public string? CandidateDiagnosticsJson { get; init; }
public string? ReasonChosen { get; init; }
public string? ReasonReassigned { get; init; }
public int? ImportSessionID { get; init; }
public string AssignmentStatus { get; init; } = "Assigned";
public DateTime? AssignedUtc { get; init; }
public DateTime? LastValidatedUtc { get; init; }
public DateTime? InvalidatedUtc { get; init; }
public string? InvalidationReason { get; init; }
public string EvidenceVersion { get; init; } = "21K";
public bool IsFallback { get; init; }
public bool IsActive { get; init; }
public DateTime CreatedUtc { get; init; }
public DateTime UpdatedUtc { get; init; }
}
public sealed record StoryIntelligenceCharacterIllustrationAssignmentSave(
int ProjectID,
string CharacterKey,
string CharacterName,
int? IllustrationLibraryItemID,
string? StableCode,
decimal MatchConfidence,
int MatchingScore,
string? EvidenceJson,
string? CandidateDiagnosticsJson,
string? ReasonChosen,
string? ReasonReassigned);
public sealed class StoryIntelligenceIllustrationDemand
{
public int StoryIntelligenceIllustrationDemandID { get; init; }
public int? ProjectID { get; init; }
public string DemandKey { get; init; } = string.Empty;
public string AgeBand { get; init; } = string.Empty;
public string Presentation { get; init; } = string.Empty;
public string HairColour { get; init; } = string.Empty;
public string SkinTone { get; init; } = string.Empty;
public string? HairLength { get; init; }
public string? Glasses { get; init; }
public string? FacialHair { get; init; }
public int RequestCount { get; init; }
public DateTime LastRequestedUtc { get; init; }
public DateTime? QueuedUtc { get; init; }
public string GenerationSource { get; init; } = "Demand generation";
}
public sealed record StoryIntelligenceIllustrationDemandRequest(
int? ProjectID,
string DemandKey,
string AgeBand,
string Presentation,
string HairColour,
string SkinTone,
string? HairLength,
string? Glasses,
string? FacialHair);

View File

@ -82,9 +82,7 @@ public sealed class StoryIntelligenceRunProgressEvent
public int? FailedScenes { get; init; }
public int? TotalTokens { get; init; }
public long? TotalDurationMs { get; init; }
public int? CurrentSceneID { get; init; }
public int? CurrentSceneNumber { get; init; }
public string? CurrentSceneDisplayTitle { get; init; }
public string ElapsedTime { get; init; } = "0 sec";
public string? EstimatedRemaining { get; init; }
public string? LatestSceneSummary { get; init; }
@ -101,16 +99,8 @@ public sealed class StoryIntelligenceOptions
public string Model { get; init; } = string.Empty;
public string ChapterStructureModel { get; init; } = string.Empty;
public string SceneIntelligenceModel { get; init; } = string.Empty;
public string ImageGenerationModel { get; init; } = "gpt-image-1.5";
public string ImageGenerationModel { get; init; } = string.Empty;
public string ImageGenerationSize { get; init; } = "1024x1024";
public string ImageGenerationQuality { get; init; } = "medium";
public string ImageGenerationOutputFormat { get; init; } = "webp";
public bool AutomaticImageGenerationDuringImportEnabled { get; init; }
public bool RequireImageGenerationApproval { get; init; } = true;
public int MaxImagesPerImport { get; init; } = 12;
public int MaxCharacterImagesPerImport { get; init; } = 4;
public int MaxLocationImagesPerImport { get; init; } = 6;
public int MaxAssetImagesPerImport { get; init; } = 2;
public int MaxOutputTokens { get; init; } = 4000;
public int? SceneIntelligenceMaxOutputTokens { get; init; }
public int TimeoutSeconds { get; init; } = 120;

View File

@ -1,84 +0,0 @@
namespace PlotLine.Models;
public static class StoryIntelligenceNarrativeCandidateTypes
{
public const string PlotLine = "PlotLine";
public const string Thread = "Thread";
}
public static class StoryIntelligenceNarrativeCandidateStatuses
{
public const string Pending = "Pending";
public const string Accepted = "Accepted";
public const string Rejected = "Rejected";
public const string Merged = "Merged";
}
public static class StoryIntelligenceNarrativeReviewActions
{
public const string AcceptNew = "AcceptNew";
public const string MergeExisting = "MergeExisting";
public const string Reject = "Reject";
}
public sealed class StoryIntelligenceNarrativeCandidate
{
public int NarrativeCandidateID { get; set; }
public int ProjectID { get; set; }
public int BookID { get; set; }
public string CandidateType { get; set; } = string.Empty;
public string CandidateKey { get; set; } = string.Empty;
public string ProposedTitle { get; set; } = string.Empty;
public string? ProposedDescription { get; set; }
public string? Category { get; set; }
public string? InferredState { get; set; }
public int? ExistingPlotLineID { get; set; }
public string? ExistingPlotLineName { get; set; }
public int? ExistingPlotThreadID { get; set; }
public string? ExistingPlotThreadTitle { get; set; }
public int? RelatedPlotLineID { get; set; }
public string? RelatedPlotLineName { get; set; }
public decimal? Confidence { get; set; }
public string Status { get; set; } = StoryIntelligenceNarrativeCandidateStatuses.Pending;
public string? ReviewAction { get; set; }
public int? AcceptedPlotLineID { get; set; }
public string? AcceptedPlotLineName { get; set; }
public int? AcceptedPlotThreadID { get; set; }
public string? AcceptedPlotThreadTitle { get; set; }
public int? ReviewedByUserID { get; set; }
public DateTime? ReviewedUtc { get; set; }
public DateTime CreatedUtc { get; set; }
public DateTime UpdatedUtc { get; set; }
public IReadOnlyList<StoryIntelligenceNarrativeCandidateEvidence> Evidence { get; set; } = [];
}
public sealed class StoryIntelligenceNarrativeCandidateEvidence
{
public int NarrativeCandidateEvidenceID { get; set; }
public int NarrativeCandidateID { get; set; }
public int SceneID { get; set; }
public decimal SceneNumber { get; set; }
public string SceneTitle { get; set; } = string.Empty;
public decimal ChapterNumber { get; set; }
public string ChapterTitle { get; set; } = string.Empty;
public string? ContributionType { get; set; }
public string? Evidence { get; set; }
public int SortOrder { get; set; }
public DateTime CreatedUtc { get; set; }
}
public sealed class StoryIntelligenceNarrativeCandidateSeed
{
public int ProjectID { get; init; }
public int BookID { get; init; }
public string CandidateType { get; init; } = string.Empty;
public string CandidateKey { get; init; } = string.Empty;
public string ProposedTitle { get; init; } = string.Empty;
public string? ProposedDescription { get; init; }
public string? Category { get; init; }
public string? InferredState { get; init; }
public int? ExistingPlotLineID { get; init; }
public int? ExistingPlotThreadID { get; init; }
public int? RelatedPlotLineID { get; init; }
public decimal? Confidence { get; init; }
}

View File

@ -36,8 +36,6 @@ public static class StoryIntelligencePipelineStages
public const string RelationshipImport = "RelationshipImport";
public const string KnowledgeReview = "KnowledgeReview";
public const string KnowledgeImport = "KnowledgeImport";
public const string NarrativeReview = "NarrativeReview";
public const string NarrativeImport = "NarrativeImport";
public const string Complete = "Complete";
}
@ -68,23 +66,7 @@ public sealed class StoryIntelligenceBookPipelineState
public bool IsComplete
=> string.Equals(Status, StoryIntelligencePipelineStatuses.Complete, StringComparison.OrdinalIgnoreCase)
&& string.Equals(LastCompletedStage, StoryIntelligencePipelineStages.NarrativeImport, StringComparison.OrdinalIgnoreCase);
}
public sealed class StoryIntelligenceImportNotificationTarget
{
public int ImportSessionID { get; init; }
public int UserID { get; init; }
public int ProjectID { get; init; }
public string ProjectName { get; init; } = string.Empty;
public int BookID { get; init; }
public string BookTitle { get; init; } = string.Empty;
public string? BookSubtitle { get; init; }
public string BookDisplayTitle => BookTitleFormatter.DisplayTitle(BookTitle, BookSubtitle);
public int ChapterCount { get; init; }
public int CompletedChapterCount { get; init; }
public int FailedChapterCount { get; init; }
public string NotificationKind { get; init; } = "Success";
&& string.Equals(LastCompletedStage, StoryIntelligencePipelineStages.KnowledgeImport, StringComparison.OrdinalIgnoreCase);
}
public sealed class StoryIntelligenceBookPipelineSaveRequest
@ -177,7 +159,6 @@ public sealed class StoryIntelligenceKnownCharacterContext
public string SexHint { get; init; } = "Unknown";
public int? ExistingCharacterID { get; init; }
public IReadOnlyList<string> CandidateKeys { get; init; } = [];
public IReadOnlyList<int> SceneIDs { get; init; } = [];
public int MentionCount { get; init; }
public decimal Confidence { get; init; }
public string Source { get; init; } = "InitialManuscriptScan";
@ -491,7 +472,6 @@ public sealed class StoryIntelligenceImportCommit
public sealed class StoryIntelligenceSceneImportItem
{
public int SceneResultID { get; init; }
public int? SceneID { get; init; }
public int TemporarySceneNumber { get; init; }
public int? StartParagraph { get; init; }
public int? EndParagraph { get; init; }
@ -505,8 +485,6 @@ public sealed class StoryIntelligenceSceneImportItem
public string? PurposeNotes { get; init; }
public string? OutcomeNotes { get; init; }
public string? ImportNoteText { get; init; }
public string AnalysisStatus { get; init; } = "Ready";
public bool HasWarnings { get; init; }
}
public sealed class StoryIntelligenceImportCommitRequest

View File

@ -220,9 +220,6 @@ public sealed record StoryMemoryRebuildOptions
public bool RebuildAll { get; init; }
public bool QueueDemand { get; init; }
public int? MaxDemandToQueue { get; init; }
public int? MaxCharacterDemandToQueue { get; init; }
public int? MaxLocationDemandToQueue { get; init; }
public int? MaxAssetDemandToQueue { get; init; }
public string AppearancePreference { get; init; } = StoryMemoryAppearancePreferences.Balanced;
public string PrimaryAgeGroup { get; init; } = string.Empty;
public string StoryEra { get; init; } = string.Empty;
@ -233,9 +230,6 @@ public sealed record StoryMemoryResolveOptions
{
public bool QueueDemand { get; init; }
public int? MaxDemandToQueue { get; init; }
public int? MaxCharacterDemandToQueue { get; init; }
public int? MaxLocationDemandToQueue { get; init; }
public int? MaxAssetDemandToQueue { get; init; }
public string AppearancePreference { get; init; } = StoryMemoryAppearancePreferences.Balanced;
public string PrimaryAgeGroup { get; init; } = string.Empty;
public string StoryEra { get; init; } = string.Empty;

View File

@ -39,7 +39,6 @@ public sealed class WordCompanionPresenceStatus
public string? CurrentDocumentName { get; init; }
public int? LinkedProjectID { get; init; }
public int? LinkedBookID { get; init; }
public StoryPosition? CurrentStoryPosition { get; init; }
public DateTime? ConnectedUtc { get; init; }
public DateTime? LastHeartbeatUtc { get; init; }
}

View File

@ -25,14 +25,6 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
<Content Include="Data\Reference\Names\**\*.csv;Data\Reference\Names\**\*.md">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
<Content Update="Data\Reference\Names\**\*.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
</ItemGroup>
<ItemGroup>

View File

@ -122,7 +122,6 @@ public class Program
builder.Services.AddScoped<ITimelineSettingsRepository, TimelineSettingsRepository>();
builder.Services.AddScoped<ISceneMetricTypeRepository, SceneMetricTypeRepository>();
builder.Services.AddScoped<IPlotRepository, PlotRepository>();
builder.Services.AddScoped<IStoryIntelligenceNarrativeCandidateRepository, StoryIntelligenceNarrativeCandidateRepository>();
builder.Services.AddScoped<IAssetRepository, AssetRepository>();
builder.Services.AddScoped<ICharacterRepository, CharacterRepository>();
builder.Services.AddScoped<ILocationRepository, LocationRepository>();
@ -145,14 +144,13 @@ public class Program
builder.Services.AddScoped<IWordCompanionRepository, WordCompanionRepository>();
builder.Services.AddScoped<IOnboardingRepository, OnboardingRepository>();
builder.Services.AddScoped<IOnboardingBuildRepository, OnboardingBuildRepository>();
builder.Services.AddScoped<IManuscriptTextStatisticsRepository, ManuscriptTextStatisticsRepository>();
builder.Services.AddScoped<IStoryIntelligenceRepository, StoryIntelligenceRepository>();
builder.Services.AddScoped<IStoryIntelligenceResultRepository, StoryIntelligenceResultRepository>();
builder.Services.AddScoped<IStoryIntelligenceSourceRepository, StoryIntelligenceSourceRepository>();
builder.Services.AddScoped<IStoryIntelligencePipelineRepository, StoryIntelligencePipelineRepository>();
builder.Services.AddScoped<IStoryIntelligenceIllustrationMatchingRepository, StoryIntelligenceIllustrationMatchingRepository>();
builder.Services.AddScoped<IStoryMemoryRepository, StoryMemoryRepository>();
builder.Services.AddScoped<ICharacterIntelligenceRepository, CharacterIntelligenceRepository>();
builder.Services.AddScoped<INameIntelligenceRepository, NameIntelligenceRepository>();
builder.Services.AddScoped<IIllustrationLibraryRepository, IllustrationLibraryRepository>();
builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>();
builder.Services.AddScoped<IProjectCollaborationRepository, ProjectCollaborationRepository>();
@ -217,9 +215,6 @@ public class Program
builder.Services.AddScoped<IStoryIntelligenceDryRunService, StoryIntelligenceDryRunService>();
builder.Services.AddScoped<IChapterStructureDryRunService, ChapterStructureDryRunService>();
builder.Services.AddScoped<IChapterStoryIntelligenceDryRunService, ChapterStoryIntelligenceDryRunService>();
builder.Services.AddScoped<IManuscriptStructureAnalysisService, ManuscriptStructureAnalysisService>();
builder.Services.AddScoped<IManuscriptTextStatisticsService, ManuscriptTextStatisticsService>();
builder.Services.AddScoped<ICoreImportCharacterMappingService, CoreImportCharacterMappingService>();
builder.Services.AddScoped<IStoryIntelligenceResultPersistenceService, StoryIntelligenceResultPersistenceService>();
builder.Services.AddScoped<IStoryIntelligenceExistingChapterQueueService, StoryIntelligenceExistingChapterQueueService>();
builder.Services.AddScoped<IStoryIntelligenceFullChapterTestService, StoryIntelligenceFullChapterTestService>();
@ -229,15 +224,11 @@ public class Program
builder.Services.AddScoped<IStoryIntelligenceAssetImportService, StoryIntelligenceAssetImportService>();
builder.Services.AddScoped<IStoryIntelligenceRelationshipImportService, StoryIntelligenceRelationshipImportService>();
builder.Services.AddScoped<IStoryIntelligenceKnowledgeImportService, StoryIntelligenceKnowledgeImportService>();
builder.Services.AddScoped<IStoryIntelligenceNarrativeReviewService, StoryIntelligenceNarrativeReviewService>();
builder.Services.AddScoped<IStoryIntelligenceSceneContextService, StoryIntelligenceSceneContextService>();
builder.Services.AddScoped<IStoryIntelligenceVisualisationSnapshotService, StoryIntelligenceVisualisationSnapshotService>();
builder.Services.AddScoped<IStoryMemoryService, StoryMemoryService>();
builder.Services.AddScoped<ICharacterIntelligenceService, CharacterIntelligenceService>();
builder.Services.AddScoped<INameIntelligenceService, NameIntelligenceService>();
builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>();
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
builder.Services.AddScoped<IStoryIntelligenceEntitlementService, StoryIntelligenceEntitlementService>();
builder.Services.AddScoped<IStoryIntelligenceCompletionNotificationService, StoryIntelligenceCompletionNotificationService>();
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();
builder.Services.AddScoped<IPersistedStoryIntelligenceRunner, PersistedStoryIntelligenceRunner>();
builder.Services.AddScoped<IIllustrationPromptBuilder, IllustrationPromptBuilder>();
@ -245,6 +236,7 @@ public class Program
builder.Services.AddHttpClient<IIllustrationImageProvider, OpenAIIllustrationImageProvider>();
builder.Services.AddScoped<IIllustrationGenerationRunner, IllustrationGenerationRunner>();
builder.Services.AddScoped<IIllustrationLibraryService, IllustrationLibraryService>();
builder.Services.AddScoped<IStoryIntelligenceIllustrationMatchingService, StoryIntelligenceIllustrationMatchingService>();
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
builder.Services.AddScoped<IStripeBillingService, StripeBillingService>();

View File

@ -50,7 +50,6 @@ public interface IEmailService
Task SendFeatureRequestAdminReplyAsync(FeatureRequest request, string messagePreview, string link, string actorEmail);
Task SendFeatureRequestStatusChangedAsync(FeatureRequest request, string link, string actorEmail);
Task SendFeatureRequestUserReplyAsync(FeatureRequest request, string messagePreview, string link, string actorEmail);
Task SendStoryIntelligenceImportNotificationAsync(string email, string displayName, StoryIntelligenceImportNotificationTarget target);
}
public interface ICurrentUserService

View File

@ -180,38 +180,6 @@ public sealed class EmailService(
}
}
public Task SendStoryIntelligenceImportNotificationAsync(string email, string displayName, StoryIntelligenceImportNotificationTarget target)
{
var failed = string.Equals(target.NotificationKind, "Failure", StringComparison.OrdinalIgnoreCase);
var subject = failed
? $"PlotDirector import needs attention: {target.BookDisplayTitle}"
: $"PlotDirector import complete: {target.BookDisplayTitle}";
var statusLine = failed
? "The manuscript import has stopped with one or more chapters needing attention."
: "The manuscript import has finished and is ready for review.";
var actionLine = failed
? "Please open PlotDirector to review the failed chapter analysis before continuing."
: "Please open PlotDirector to review and commit the imported scenes.";
var body = $"""
Hi {DisplayName(displayName)},
{statusLine}
Project: {target.ProjectName}
Book: {target.BookDisplayTitle}
Chapters processed: {target.CompletedChapterCount:N0} of {target.ChapterCount:N0}
Failed chapters: {target.FailedChapterCount:N0}
{actionLine}
Thanks,
The PlotDirector team
""";
return QueueAsync(email, DisplayName(displayName), subject, body, html: null);
}
private async Task QueueAsync(string toEmail, string displayName, string subject, string plainText, string? html)
{
if (string.IsNullOrWhiteSpace(toEmail))

View File

@ -58,13 +58,7 @@ public sealed class ChapterStoryIntelligenceDryRunService(
cancellationToken,
clientStatus.ChapterStructureModel);
var chapterJson = ExtractOutputText(chapterResult.RawResponseText);
var chapterParse = StoryIntelligenceAiJsonParser.Parse<ChapterStructureModel>(chapterJson, JsonOptions);
if (!chapterParse.Success)
{
throw new JsonException(chapterParse.ErrorMessage ?? "Chapter Structure JSON could not be parsed.");
}
var parsedChapter = chapterParse.Parsed;
var parsedChapter = JsonSerializer.Deserialize<ChapterStructureModel>(chapterJson, JsonOptions);
var chapterNormalisation = ChapterStructureBoundaryNormaliser.Normalise(parsedChapter, paragraphs.Count);
var chapterForProcessing = chapterNormalisation.ChapterStructure;
var chapterValidation = chapterValidator.Validate(chapterForProcessing, paragraphs.Count);

View File

@ -13,7 +13,6 @@ public static class ChapterStructureBoundaryNormaliser
return new ChapterStructureBoundaryNormalisationResult(chapterStructure, []);
}
var issues = new List<ValidationIssue>();
var boundaries = chapterStructure.SceneBoundaries;
var finalIndex = boundaries.Count - 1;
var final = boundaries[finalIndex];
@ -23,47 +22,31 @@ public static class ChapterStructureBoundaryNormaliser
if (IsTrailingEmptyBoundary(final, previous, paragraphCount, phantomParagraph)
|| IsTrailingInvalidPlaceholder(final, previous, paragraphCount, phantomParagraph))
{
boundaries = boundaries
var normalisedBoundaries = boundaries
.Take(finalIndex)
.Select((boundary, index) => CopyBoundary(boundary, index + 1))
.ToList();
issues.Add(new ValidationIssue
var normalised = new ChapterStructureModel
{
SchemaVersion = chapterStructure.SchemaVersion,
ChapterSummary = chapterStructure.ChapterSummary,
SceneBoundaries = normalisedBoundaries,
ExtensionData = chapterStructure.ExtensionData
};
var issue = new ValidationIssue
{
Severity = "Warning",
Path = $"sceneBoundaries[{finalIndex}]",
Message = BuildNormalisationMessage(final, finalIndex, paragraphCount, phantomParagraph),
SuggestedFix = "Do not return a final scene boundary unless it references an actual supplied paragraph."
});
}
else if (IsTrailingTerminalOffByOne(final, previous, paragraphCount, phantomParagraph))
{
boundaries = boundaries
.Take(finalIndex)
.Select((boundary, index) => CopyBoundary(boundary, index + 1))
.ToList();
issues.Add(new ValidationIssue
{
Severity = "Warning",
Path = $"sceneBoundaries[{finalIndex}]",
Message = $"Discarded trailing terminal off-by-one scene boundary {phantomParagraph}-{paragraphCount} because previous scene already ended at the final structural paragraph {paragraphCount}.",
SuggestedFix = $"Do not return a boundary starting after the final supplied paragraph {paragraphCount}."
});
}
var boundaryModel = ReferenceEquals(boundaries, chapterStructure.SceneBoundaries)
? chapterStructure
: new ChapterStructureModel
{
SchemaVersion = chapterStructure.SchemaVersion,
ChapterSummary = chapterStructure.ChapterSummary,
SceneBoundaries = boundaries.ToList(),
ExtensionData = chapterStructure.ExtensionData
};
var continuity = RepairContinuity(boundaryModel, boundaries, paragraphCount);
issues.AddRange(continuity.Issues);
return new ChapterStructureBoundaryNormalisationResult(continuity.ChapterStructure, issues);
return new ChapterStructureBoundaryNormalisationResult(normalised, [issue]);
}
return new ChapterStructureBoundaryNormalisationResult(chapterStructure, []);
}
public static void AddIssuesTo(ValidationResult validation, ChapterStructureBoundaryNormalisationResult normalisation)
@ -91,8 +74,6 @@ public static class ChapterStructureBoundaryNormaliser
{
chapterStructure.SchemaVersion,
chapterStructure.ChapterSummary,
chapterStructure.DefaultPov,
chapterStructure.MixedPov,
chapterStructure.SceneBoundaries,
Normalisation = normalisation.Issues.Select(issue => new
{
@ -111,152 +92,11 @@ public static class ChapterStructureBoundaryNormaliser
SceneNumber = sceneNumber,
StartParagraph = boundary.StartParagraph,
EndParagraph = boundary.EndParagraph,
SuggestedTitle = boundary.SuggestedTitle,
StructuralSummary = boundary.StructuralSummary,
StructuralSetting = boundary.StructuralSetting,
PovOverride = boundary.PovOverride,
Confidence = boundary.Confidence,
Reason = boundary.Reason,
ExtensionData = boundary.ExtensionData
};
private static ChapterStructureBoundaryNormalisationResult RepairContinuity(
ChapterStructureModel chapterStructure,
IReadOnlyList<ChapterSceneBoundary> sourceBoundaries,
int paragraphCount)
{
if (sourceBoundaries.Count == 0)
{
return new ChapterStructureBoundaryNormalisationResult(chapterStructure, []);
}
var issues = new List<ValidationIssue>();
var ordered = sourceBoundaries
.Select((boundary, index) => new { Boundary = boundary, OriginalIndex = index })
.OrderBy(item => item.Boundary.StartParagraph ?? int.MaxValue)
.ThenBy(item => item.Boundary.EndParagraph ?? int.MaxValue)
.ToList();
if (ordered.Where((item, index) => item.OriginalIndex != index).Any())
{
issues.Add(new ValidationIssue
{
Severity = "Warning",
Path = "sceneBoundaries",
Message = "Reordered scene boundaries by paragraph position before validation.",
SuggestedFix = "Return scene boundaries in reading order."
});
}
var changed = issues.Count > 0;
var repaired = new List<ChapterSceneBoundary>();
var expectedStart = 1;
for (var index = 0; index < ordered.Count; index++)
{
var boundary = ordered[index].Boundary;
if (!boundary.StartParagraph.HasValue || !boundary.EndParagraph.HasValue)
{
repaired.Add(CopyBoundary(boundary, index + 1));
continue;
}
var start = boundary.StartParagraph.Value;
var end = boundary.EndParagraph.Value;
if (start != expectedStart)
{
issues.Add(new ValidationIssue
{
Severity = "Warning",
Path = $"sceneBoundaries[{index}].startParagraph",
Message = $"Adjusted scene {index + 1:N0} start paragraph from {start:N0} to {expectedStart:N0} to remove an AI boundary gap or overlap.",
SuggestedFix = $"Use startParagraph {expectedStart:N0} for this boundary."
});
start = expectedStart;
changed = true;
}
if (end < start)
{
issues.Add(new ValidationIssue
{
Severity = "Warning",
Path = $"sceneBoundaries[{index}].endParagraph",
Message = $"Scene {index + 1:N0} has start paragraph {start:N0} after end paragraph {end:N0}; this cannot be safely normalised.",
SuggestedFix = "Return an endParagraph greater than or equal to startParagraph."
});
}
if (end > paragraphCount)
{
issues.Add(new ValidationIssue
{
Severity = "Warning",
Path = $"sceneBoundaries[{index}].endParagraph",
Message = $"Clamped scene {index + 1:N0} end paragraph from {end:N0} to the chapter paragraph count {paragraphCount:N0}.",
SuggestedFix = $"Use an endParagraph from 1 to {paragraphCount:N0}."
});
end = paragraphCount;
changed = true;
}
repaired.Add(new ChapterSceneBoundary
{
SceneNumber = index + 1,
StartParagraph = start,
EndParagraph = end,
SuggestedTitle = boundary.SuggestedTitle,
StructuralSummary = boundary.StructuralSummary,
StructuralSetting = boundary.StructuralSetting,
PovOverride = boundary.PovOverride,
Confidence = boundary.Confidence,
Reason = boundary.Reason,
ExtensionData = boundary.ExtensionData
});
expectedStart = end + 1;
}
if (expectedStart <= paragraphCount && repaired.Count > 0)
{
var final = repaired[^1];
issues.Add(new ValidationIssue
{
Severity = "Warning",
Path = $"sceneBoundaries[{repaired.Count - 1}].endParagraph",
Message = $"Extended the final scene boundary to paragraph {paragraphCount:N0} so detected scenes cover the full chapter.",
SuggestedFix = $"End the final scene at paragraph {paragraphCount:N0}."
});
repaired[^1] = new ChapterSceneBoundary
{
SceneNumber = final.SceneNumber,
StartParagraph = final.StartParagraph,
EndParagraph = paragraphCount,
SuggestedTitle = final.SuggestedTitle,
StructuralSummary = final.StructuralSummary,
StructuralSetting = final.StructuralSetting,
PovOverride = final.PovOverride,
Confidence = final.Confidence,
Reason = final.Reason,
ExtensionData = final.ExtensionData
};
changed = true;
}
if (!changed)
{
return new ChapterStructureBoundaryNormalisationResult(chapterStructure, []);
}
return new ChapterStructureBoundaryNormalisationResult(new ChapterStructureModel
{
SchemaVersion = chapterStructure.SchemaVersion,
ChapterSummary = chapterStructure.ChapterSummary,
DefaultPov = chapterStructure.DefaultPov,
MixedPov = chapterStructure.MixedPov,
SceneBoundaries = repaired,
ExtensionData = chapterStructure.ExtensionData
}, issues);
}
private static bool IsTrailingEmptyBoundary(
ChapterSceneBoundary final,
ChapterSceneBoundary previous,
@ -287,15 +127,6 @@ public static class ChapterStructureBoundaryNormaliser
return invalidTrailingRange && HasPlaceholderSignal(final);
}
private static bool IsTrailingTerminalOffByOne(
ChapterSceneBoundary final,
ChapterSceneBoundary previous,
int paragraphCount,
int phantomParagraph)
=> final.StartParagraph == phantomParagraph
&& final.EndParagraph == paragraphCount
&& previous.EndParagraph == paragraphCount;
private static bool HasPlaceholderSignal(ChapterSceneBoundary boundary)
{
if (boundary.Confidence <= 0.05m)

View File

@ -119,15 +119,6 @@ public sealed class ChapterStructureValidator : IChapterStructureValidator
}
Confidence(boundary.Confidence, $"{path}.confidence", result);
RequiredString(boundary.StructuralSummary, $"{path}.structuralSummary", result);
if (!string.IsNullOrWhiteSpace(boundary.StructuralSummary))
{
var wordCount = boundary.StructuralSummary.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
if (wordCount is < 15 or > 80)
{
AddWarning(result, $"{path}.structuralSummary", "Structural summary should be concise.", "Return a factual scene summary of roughly 25 to 40 words.");
}
}
RequiredString(boundary.Reason, $"{path}.reason", result);
Unknown(boundary.ExtensionData, path, result);
}

View File

@ -1,217 +0,0 @@
using System.Text.RegularExpressions;
using PlotLine.Data;
using PlotLine.Models;
namespace PlotLine.Services;
public interface ICoreImportCharacterMappingService
{
Task<CoreImportCharacterMappingResult> MapBookAsync(int projectId, int bookId);
}
public sealed class CoreImportCharacterMappingService(
IOnboardingBuildRepository builds,
ICharacterRepository characters,
ILogger<CoreImportCharacterMappingService> logger) : ICoreImportCharacterMappingService
{
private const string MatchSource = "CoreImportDeterministic";
private static readonly HashSet<string> GenericReferences = new(StringComparer.OrdinalIgnoreCase)
{
"aunt", "boy", "brother", "child", "children", "dad", "daddy", "doctor", "examiner", "father",
"friend", "girl", "grandfather", "grandmother", "he", "him", "his", "lady", "man", "miss",
"mother", "mum", "mummy", "officer", "parent", "professor", "she", "sister", "sir", "son",
"teacher", "uncle", "woman"
};
public async Task<CoreImportCharacterMappingResult> MapBookAsync(int projectId, int bookId)
{
var sourceScenes = await builds.ListSceneManuscriptSourcesByBookAsync(bookId);
if (sourceScenes.Count == 0)
{
return new CoreImportCharacterMappingResult(0, 0);
}
var projectCharacters = (await characters.ListCharactersAsync(projectId))
.Where(character => !character.IsArchived && !string.IsNullOrWhiteSpace(character.CharacterName))
.ToList();
if (projectCharacters.Count == 0)
{
return new CoreImportCharacterMappingResult(sourceScenes.Count, 0);
}
var aliases = new Dictionary<int, IReadOnlyList<CharacterAlias>>();
foreach (var character in projectCharacters)
{
aliases[character.CharacterID] = await characters.ListAliasesAsync(character.CharacterID);
}
var matchIndex = BuildMatchIndex(projectCharacters, aliases);
var lookup = await characters.GetLookupsAsync(projectId);
var roleTypeId = lookup.RoleTypes.FirstOrDefault(item => string.Equals(item.TypeName, "Mentioned Only", StringComparison.OrdinalIgnoreCase))?.CharacterRoleInSceneTypeID
?? lookup.RoleTypes.FirstOrDefault()?.CharacterRoleInSceneTypeID;
var presenceTypeId = lookup.PresenceTypes.FirstOrDefault(item => string.Equals(item.TypeName, "Mentioned Only", StringComparison.OrdinalIgnoreCase))?.PresenceTypeID
?? lookup.PresenceTypes.FirstOrDefault(item => string.Equals(item.TypeName, "Present", StringComparison.OrdinalIgnoreCase))?.PresenceTypeID
?? lookup.PresenceTypes.FirstOrDefault()?.PresenceTypeID;
var saved = 0;
foreach (var scene in sourceScenes)
{
foreach (var match in MatchScene(scene.SourceText, matchIndex).OrderBy(item => item.CharacterID))
{
await characters.SaveSceneCharacterAsync(new SceneCharacter
{
SceneID = scene.SceneID,
CharacterID = match.CharacterID,
RoleInSceneTypeID = roleTypeId,
PresenceTypeID = presenceTypeId,
MatchSource = MatchSource,
MatchedText = match.MatchedText,
OccurrenceCount = match.OccurrenceCount,
FirstOccurrenceIndex = match.FirstOccurrenceIndex
});
saved++;
}
}
logger.LogInformation(
"Core import deterministic character mapping completed. ProjectID={ProjectID} BookID={BookID} Scenes={SceneCount} LinksSaved={LinksSaved}",
projectId,
bookId,
sourceScenes.Count,
saved);
return new CoreImportCharacterMappingResult(sourceScenes.Count, saved);
}
internal static IReadOnlyList<DeterministicCharacterMatchTerm> BuildMatchIndex(
IReadOnlyList<Character> projectCharacters,
IReadOnlyDictionary<int, IReadOnlyList<CharacterAlias>> aliases)
{
var termsByCharacter = new Dictionary<int, HashSet<string>>();
foreach (var character in projectCharacters)
{
var terms = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
AddTerm(terms, character.CharacterName);
AddTerm(terms, character.ShortName);
foreach (var alias in aliases.GetValueOrDefault(character.CharacterID) ?? [])
{
AddTerm(terms, alias.Alias);
}
termsByCharacter[character.CharacterID] = terms;
}
var tokenOwners = termsByCharacter
.SelectMany(pair => pair.Value.SelectMany(TokeniseName).Select(token => (Token: token, CharacterID: pair.Key)))
.GroupBy(item => item.Token, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
group => group.Key,
group => group.Select(item => item.CharacterID).Distinct().Count(),
StringComparer.OrdinalIgnoreCase);
var index = new List<DeterministicCharacterMatchTerm>();
foreach (var character in projectCharacters)
{
foreach (var term in termsByCharacter[character.CharacterID])
{
var tokens = TokeniseName(term).ToList();
if (tokens.Count == 0)
{
continue;
}
if (tokens.Count == 1 && (GenericReferences.Contains(tokens[0]) || tokenOwners.GetValueOrDefault(tokens[0]) > 1))
{
continue;
}
index.Add(new DeterministicCharacterMatchTerm(character.CharacterID, term, RegexForTerm(term)));
if (tokens.Count > 1)
{
AddUniqueTokenTerm(index, character.CharacterID, tokens[0], tokenOwners);
AddUniqueTokenTerm(index, character.CharacterID, tokens[^1], tokenOwners);
}
}
}
return index
.GroupBy(item => (item.CharacterID, Key: item.Term.ToLowerInvariant()))
.Select(group => group.First())
.OrderByDescending(item => item.Term.Length)
.ThenBy(item => item.Term, StringComparer.OrdinalIgnoreCase)
.ToList();
}
internal static IReadOnlyList<DeterministicSceneCharacterMatch> MatchScene(
string? sourceText,
IReadOnlyList<DeterministicCharacterMatchTerm> matchIndex)
{
if (string.IsNullOrWhiteSpace(sourceText) || matchIndex.Count == 0)
{
return [];
}
var matches = new Dictionary<int, DeterministicSceneCharacterMatch>();
foreach (var term in matchIndex)
{
var regexMatches = term.Regex.Matches(sourceText);
if (regexMatches.Count == 0)
{
continue;
}
if (!matches.TryGetValue(term.CharacterID, out var existing)
|| regexMatches[0].Index < existing.FirstOccurrenceIndex
|| (regexMatches[0].Index == existing.FirstOccurrenceIndex && term.Term.Length > existing.MatchedText.Length))
{
matches[term.CharacterID] = new DeterministicSceneCharacterMatch(
term.CharacterID,
term.Term,
regexMatches.Count,
regexMatches[0].Index);
}
}
return matches.Values.ToList();
}
private static void AddUniqueTokenTerm(
List<DeterministicCharacterMatchTerm> index,
int characterId,
string token,
IReadOnlyDictionary<string, int> tokenOwners)
{
if (!GenericReferences.Contains(token) && tokenOwners.GetValueOrDefault(token) == 1)
{
index.Add(new DeterministicCharacterMatchTerm(characterId, token, RegexForTerm(token)));
}
}
private static void AddTerm(HashSet<string> terms, string? value)
{
var clean = CleanName(value);
if (!string.IsNullOrWhiteSpace(clean) && !GenericReferences.Contains(clean))
{
terms.Add(clean);
}
}
private static IEnumerable<string> TokeniseName(string value)
=> value.Split([' ', '-', '\''], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(CleanName)
.Where(token => token.Length > 1);
private static Regex RegexForTerm(string term)
=> new($@"(?<![\p{{L}}\p{{N}}]){Regex.Escape(term)}(?![\p{{L}}\p{{N}}])", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled);
private static string CleanName(string? value)
=> Regex.Replace(value?.Trim() ?? string.Empty, @"\s+", " ");
}
public sealed record CoreImportCharacterMappingResult(int ScenesScanned, int LinksSaved);
public sealed record DeterministicCharacterMatchTerm(int CharacterID, string Term, Regex Regex);
public sealed record DeterministicSceneCharacterMatch(int CharacterID, string MatchedText, int OccurrenceCount, int FirstOccurrenceIndex);

View File

@ -3,7 +3,6 @@ using System.Net;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Options;
using PlotLine.Data;
using PlotLine.Models;
@ -114,7 +113,6 @@ public interface IChapterService
Task<ChapterEditViewModel?> GetCreateAsync(int bookId);
Task<ChapterEditViewModel?> GetEditAsync(int chapterId);
Task<int> SaveAsync(ChapterEditViewModel model);
Task<int> SetScenesPovAsync(int chapterId, int? povCharacterId, bool overwriteAuthor);
Task ArchiveAsync(int chapterId);
}
@ -123,7 +121,7 @@ public interface ISceneService
Task<SceneEditViewModel?> GetCreateAsync(int chapterId);
Task<SceneEditViewModel?> GetEditAsync(int sceneId);
Task<int> SaveAsync(SceneEditViewModel model);
Task UpdatePovCharacterAsync(int sceneId, int? povCharacterId, string source = "Author", decimal? confidence = null, string? evidence = null, string? suggestedName = null, bool overwriteAuthor = false);
Task UpdatePovCharacterAsync(int sceneId, int? povCharacterId);
Task ArchiveAsync(int sceneId);
Task MoveAsync(int sceneId, string direction);
Task MoveRelativeAsync(int sceneId, int anchorSceneId, string position);
@ -262,10 +260,8 @@ public interface ICharacterService
Task<CharacterEditViewModel?> GetCreateCharacterAsync(int projectId);
Task<CharacterEditViewModel?> GetEditCharacterAsync(int characterId);
Task<CharacterDetailViewModel?> GetCharacterDetailAsync(int characterId);
Task<CharacterMergeViewModel?> GetMergeAsync(int sourceCharacterId);
Task<CharacterAvatarCropViewModel?> GetAvatarCropAsync(int characterImageId);
Task<CharacterSaveResult> SaveCharacterAsync(CharacterEditViewModel model);
Task<CharacterMergeResult?> MergeAsync(CharacterMergeForm form);
Task<VisualIdentityImageResult> UploadCharacterImageAsync(CharacterImageUploadViewModel model);
Task<VisualIdentityImageResult> SaveCharacterAvatarAsync(CharacterAvatarCropViewModel model);
Task RemoveCharacterAvatarAsync(int characterId);
@ -313,7 +309,7 @@ public interface ISceneMetricTypeService
Task<int> SaveAsync(SceneMetricTypeEditViewModel model);
Task<bool> SetActiveAsync(int projectId, int metricTypeId, bool isActive);
Task<bool> MoveAsync(int projectId, int metricTypeId, string direction);
Task<int> AddMissingDefaultsAsync(int projectId);
Task AddMissingDefaultsAsync(int projectId);
Task<bool> RemoveUnusedAsync(int projectId, int metricTypeId);
}
@ -921,13 +917,6 @@ public sealed class ProjectService(
userId,
filePaths.Count);
}
catch (SqlException ex)
{
logger.LogError(ex, "Database hard delete failed for archived project {ProjectID} owned by user {UserID}. File cleanup was not attempted.",
projectId,
userId);
return ProjectHardDeleteResult.Failure($"Project could not be permanently deleted. Blocking database dependency: {ex.Errors[0].Message}");
}
catch (Exception ex)
{
logger.LogError(ex, "Database hard delete failed for archived project {ProjectID} owned by user {UserID}. File cleanup was not attempted.",
@ -1164,7 +1153,6 @@ public sealed class BookService(
IProjectRepository projects,
IBookRepository books,
IChapterRepository chapters,
IOnboardingBuildRepository builds,
IManuscriptDocumentRepository manuscriptDocuments,
IBookCoverService bookCovers,
ISubscriptionService subscriptions,
@ -1186,11 +1174,6 @@ public sealed class BookService(
return null;
}
var pipeline = currentUser.UserId is int pipelineUserId
? await storyIntelligencePipeline.GetForBookAsync(bookId, pipelineUserId)
: null;
var hasCanonicalSceneSources = (await builds.ListSceneManuscriptSourcesByBookAsync(bookId)).Count > 0;
return new BookDetailViewModel
{
Project = project,
@ -1199,8 +1182,9 @@ public sealed class BookService(
? await manuscriptDocuments.GetByBookAsync(bookId, userId)
: null,
Chapters = await chapters.ListByBookAsync(bookId),
StoryIntelligencePipeline = pipeline,
StoryIntelligenceAction = BuildStoryIntelligenceAction(pipeline, hasCanonicalSceneSources)
StoryIntelligencePipeline = currentUser.UserId is int pipelineUserId
? await storyIntelligencePipeline.GetForBookAsync(bookId, pipelineUserId)
: null
};
}
@ -1220,9 +1204,7 @@ public sealed class BookService(
BookNumber = existing.Count + 1,
Status = BookStatuses.Planning,
CoverThumbnailPath = BookCoverService.PlaceholderPath,
StatusOptions = BuildStatusOptions(BookStatuses.Planning),
StoryEraOptions = BuildStoryEraOptions(null),
PrimaryLocaleOptions = BuildPrimaryLocaleOptions(null)
StatusOptions = BuildStatusOptions(BookStatuses.Planning)
};
}
@ -1246,8 +1228,6 @@ public sealed class BookService(
BookNumber = book.BookNumber,
Description = book.Description,
TargetWordCount = book.TargetWordCount,
StoryEra = book.StoryEra,
PrimaryLocaleCode = book.PrimaryLocaleCode,
WritingStartDate = book.WritingStartDate,
TargetCompletionDate = book.TargetCompletionDate,
PublicationDate = book.PublicationDate,
@ -1255,9 +1235,7 @@ public sealed class BookService(
CoverThumbnailPath = book.CoverThumbnailPath ?? BookCoverService.PlaceholderPath,
CoverStandardPath = book.CoverStandardPath,
Project = await projects.GetAsync(book.ProjectID),
StatusOptions = BuildStatusOptions(book.Status),
StoryEraOptions = BuildStoryEraOptions(book.StoryEra),
PrimaryLocaleOptions = BuildPrimaryLocaleOptions(book.PrimaryLocaleCode)
StatusOptions = BuildStatusOptions(book.Status)
};
}
@ -1265,8 +1243,6 @@ public sealed class BookService(
{
model.Project = await projects.GetAsync(model.ProjectID);
model.StatusOptions = BuildStatusOptions(model.Status);
model.StoryEraOptions = BuildStoryEraOptions(model.StoryEra);
model.PrimaryLocaleOptions = BuildPrimaryLocaleOptions(model.PrimaryLocaleCode);
if (model.BookID != 0)
{
var book = await books.GetAsync(model.BookID);
@ -1300,8 +1276,6 @@ public sealed class BookService(
BookNumber = model.BookNumber,
Description = model.Description,
TargetWordCount = model.TargetWordCount,
StoryEra = TrimOptional(model.StoryEra),
PrimaryLocaleCode = TrimOptional(model.PrimaryLocaleCode),
WritingStartDate = model.WritingStartDate,
TargetCompletionDate = model.TargetCompletionDate,
PublicationDate = model.PublicationDate,
@ -1327,31 +1301,6 @@ public sealed class BookService(
return new(bookId, true);
}
private static string? TrimOptional(string? value)
{
var clean = value?.Trim();
return string.IsNullOrWhiteSpace(clean) ? null : clean;
}
private static IReadOnlyList<SelectListItem> BuildStoryEraOptions(string? selected)
=> BuildContextOptions(BookContextOptions.StoryEras, selected, "Not set");
private static IReadOnlyList<SelectListItem> BuildPrimaryLocaleOptions(string? selected)
=> BuildContextOptions(BookContextOptions.PrimaryLocales, selected, "Not set");
private static IReadOnlyList<SelectListItem> BuildContextOptions(IReadOnlyList<BookContextOption> options, string? selected, string emptyLabel)
{
var items = new List<SelectListItem>
{
new(emptyLabel, string.Empty, string.IsNullOrWhiteSpace(selected))
};
items.AddRange(options.Select(option => new SelectListItem(
option.Label,
option.Value,
string.Equals(option.Value, selected, StringComparison.OrdinalIgnoreCase))));
return items;
}
public Task<ManuscriptDocumentUnlinkResult?> UnlinkManuscriptAsync(int bookId)
{
return currentUser.UserId is int userId
@ -1364,34 +1313,6 @@ public sealed class BookService(
.Select(status => new SelectListItem(status, status, string.Equals(status, selectedStatus, StringComparison.OrdinalIgnoreCase)))
.ToList();
private static StoryIntelligenceBookActionViewModel BuildStoryIntelligenceAction(StoryIntelligenceBookPipelineState? pipeline, bool hasCanonicalSceneSources)
{
if (pipeline is null)
{
return new StoryIntelligenceBookActionViewModel
{
HasCanonicalSceneSources = hasCanonicalSceneSources,
CanStart = hasCanonicalSceneSources,
StatusLabel = hasCanonicalSceneSources ? "Not analysed" : "Core import required",
ActionLabel = "Analyse My Book",
Description = hasCanonicalSceneSources
? "Analyse this book from its saved chapters, scenes, characters and manuscript text."
: "Complete the core manuscript import before analysing this book."
};
}
return new StoryIntelligenceBookActionViewModel
{
HasCanonicalSceneSources = hasCanonicalSceneSources,
CanStart = false,
StatusLabel = pipeline.IsComplete ? "Complete" : pipeline.Status,
ActionLabel = pipeline.IsComplete ? "View Story Intelligence Summary" : "Continue Story Intelligence",
Description = pipeline.IsComplete
? "All currently available Story Intelligence stages are complete."
: "Story Intelligence has saved progress for this book."
};
}
public async Task ArchiveAsync(int bookId)
{
var book = await books.GetAsync(bookId);
@ -1414,8 +1335,6 @@ public sealed class ChapterService(
ILookupRepository lookups,
ISubscriptionService subscriptions,
IProjectActivityService activity,
IManuscriptTextStatisticsRepository manuscriptTextStatisticsRepository,
IManuscriptTextStatisticsService manuscriptTextStatistics,
ICurrentUserService currentUser) : IChapterService
{
public async Task<ChapterDetailViewModel?> GetDetailAsync(int chapterId)
@ -1434,12 +1353,6 @@ public sealed class ChapterService(
}
var sceneRows = await scenes.ListByChapterAsync(chapterId);
var textStatisticsByScene = await manuscriptTextStatisticsRepository.ListByChapterAsync(chapterId);
foreach (var scene in sceneRows)
{
scene.TextStatistics = textStatisticsByScene.GetValueOrDefault(scene.SceneID);
}
if (currentUser.UserId is int userId)
{
var suggestionCounts = await characters.CountSceneCharacterSuggestionsByChapterAsync(chapterId, userId);
@ -1462,12 +1375,9 @@ public sealed class ChapterService(
Book = book,
Chapter = chapter,
Scenes = sceneRows,
TextStatistics = manuscriptTextStatistics.Aggregate(sceneRows.Select(scene => scene.TextStatistics)),
PendingCharacterSuggestionCount = sceneRows.Sum(x => x.PendingCharacterSuggestionCount),
PendingAssetSuggestionCount = sceneRows.Sum(x => x.PendingAssetSuggestionCount),
PendingLocationSuggestionCount = sceneRows.Sum(x => x.PendingLocationSuggestionCount),
CharacterOptions = ToOptionalSelectList(await characters.ListCharactersAsync(project.ProjectID), x => x.CharacterID, x => x.CharacterName),
PovDisplayLabel = BuildChapterPovDisplay(chapter, sceneRows)
PendingLocationSuggestionCount = sceneRows.Sum(x => x.PendingLocationSuggestionCount)
};
}
@ -1490,8 +1400,7 @@ public sealed class ChapterService(
ChapterNumber = existing.Count + 1,
RevisionStatusID = DefaultRevisionStatusId(lookupData),
RevisionStatuses = ToSelectList(lookupData.RevisionStatuses, x => x.RevisionStatusID, x => x.StatusName),
ChapterPurposes = ToOptionalSelectList(lookupData.ChapterPurposeTypes, x => x.ChapterPurposeID, x => x.PurposeName),
CharacterOptions = ToOptionalSelectList(await characters.ListCharactersAsync(project.ProjectID), x => x.CharacterID, x => x.CharacterName)
ChapterPurposes = ToOptionalSelectList(lookupData.ChapterPurposeTypes, x => x.ChapterPurposeID, x => x.PurposeName)
};
}
@ -1511,18 +1420,12 @@ public sealed class ChapterService(
}
var lookupData = await lookups.GetAllAsync();
var sceneRows = await scenes.ListByChapterAsync(chapterId);
var derivedPov = DeriveUniformScenePov(sceneRows);
return new ChapterEditViewModel
{
ChapterID = chapter.ChapterID,
BookID = chapter.BookID,
ChapterNumber = chapter.ChapterNumber,
ChapterTitle = chapter.ChapterTitle,
POVCharacterID = derivedPov.CharacterID,
OriginalDerivedPOVCharacterID = derivedPov.CharacterID,
HasCanonicalScenes = sceneRows.Count > 0,
PovDisplayLabel = derivedPov.DisplayLabel,
Summary = chapter.Summary,
ChapterPurposeID = chapter.ChapterPurposeID,
RevisionStatusID = chapter.RevisionStatusID,
@ -1530,8 +1433,7 @@ public sealed class ChapterService(
Book = book,
Project = project,
RevisionStatuses = ToSelectList(lookupData.RevisionStatuses, x => x.RevisionStatusID, x => x.StatusName),
ChapterPurposes = ToOptionalSelectList(lookupData.ChapterPurposeTypes, x => x.ChapterPurposeID, x => x.PurposeName),
CharacterOptions = ToOptionalSelectList(await characters.ListCharactersAsync(project.ProjectID), x => x.CharacterID, x => x.CharacterName)
ChapterPurposes = ToOptionalSelectList(lookupData.ChapterPurposeTypes, x => x.ChapterPurposeID, x => x.PurposeName)
};
}
@ -1543,14 +1445,12 @@ public sealed class ChapterService(
}
var isNew = model.ChapterID == 0;
var hasCanonicalScenes = !isNew && (model.HasCanonicalScenes || (await scenes.ListByChapterAsync(model.ChapterID)).Count > 0);
var chapterId = await chapters.SaveAsync(new Chapter
{
ChapterID = model.ChapterID,
BookID = model.BookID,
ChapterNumber = model.ChapterNumber,
ChapterTitle = model.ChapterTitle,
POVCharacterID = hasCanonicalScenes ? null : model.POVCharacterID,
Summary = model.Summary,
ChapterPurposeID = model.ChapterPurposeID,
RevisionStatusID = model.RevisionStatusID
@ -1560,27 +1460,9 @@ public sealed class ChapterService(
{
await activity.RecordAsync(book.ProjectID, isNew ? "Created" : "Updated", "Chapter", chapterId, model.ChapterTitle);
}
if (hasCanonicalScenes && model.POVCharacterID != model.OriginalDerivedPOVCharacterID)
{
await chapters.SetScenesPovAsync(chapterId, model.POVCharacterID, overwriteAuthor: false);
}
return chapterId;
}
public async Task<int> SetScenesPovAsync(int chapterId, int? povCharacterId, bool overwriteAuthor)
{
var chapter = await chapters.GetAsync(chapterId);
var book = chapter is null ? null : await books.GetAsync(chapter.BookID);
if (chapter is null || book is null)
{
throw new InvalidOperationException("Chapter could not be found.");
}
var updated = await chapters.SetScenesPovAsync(chapterId, povCharacterId, overwriteAuthor);
await activity.RecordAsync(book.ProjectID, "Updated", "Chapter", chapterId, chapter.ChapterTitle, $"Updated POV on {updated:N0} scene{(updated == 1 ? string.Empty : "s")}.");
return updated;
}
public async Task ArchiveAsync(int chapterId)
{
var chapter = await chapters.GetAsync(chapterId);
@ -1601,39 +1483,6 @@ public sealed class ChapterService(
internal static IReadOnlyList<SelectListItem> ToOptionalSelectList<T>(IEnumerable<T> rows, Func<T, int> value, Func<T, string> text)
=> rows.Select(x => new SelectListItem(text(x), value(x).ToString())).Prepend(new SelectListItem("None", string.Empty)).ToList();
private static string BuildChapterPovDisplay(Chapter chapter, IReadOnlyList<Scene> scenes)
{
var derived = DeriveUniformScenePov(scenes);
if (scenes.Count > 0)
{
return derived.DisplayLabel;
}
return FirstNonBlank(chapter.POVCharacterName, chapter.POVSuggestedName) ?? "Not detected";
}
private static ChapterDerivedPov DeriveUniformScenePov(IReadOnlyList<Scene> scenes)
{
var scenePovs = scenes
.Select(scene => new { scene.POVCharacterID, DisplayName = FirstNonBlank(scene.POVCharacterName, scene.POVSuggestedName) })
.Where(scene => scene.POVCharacterID.HasValue || !string.IsNullOrWhiteSpace(scene.DisplayName))
.GroupBy(scene => scene.POVCharacterID?.ToString() ?? scene.DisplayName!, StringComparer.OrdinalIgnoreCase)
.Select(group => new ChapterDerivedPov(group.First().POVCharacterID, group.First().DisplayName ?? "Unresolved POV"))
.ToList();
return scenePovs.Count switch
{
0 => new ChapterDerivedPov(null, "Not detected"),
1 => scenePovs[0],
_ => new ChapterDerivedPov(null, "Mixed")
};
}
private static string? FirstNonBlank(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim();
private sealed record ChapterDerivedPov(int? CharacterID, string DisplayLabel);
}
public sealed class SceneService(
@ -1653,7 +1502,6 @@ public sealed class SceneService(
IProjectActivityService activity,
IStorageService storage,
IStoryStateService storyState,
IManuscriptTextStatisticsRepository manuscriptTextStatistics,
ICurrentUserService currentUser) : ISceneService
{
public async Task<SceneEditViewModel?> GetCreateAsync(int chapterId)
@ -1695,7 +1543,6 @@ public sealed class SceneService(
}
var lookupData = await lookups.GetAllAsync();
var textStatistics = await manuscriptTextStatistics.GetBySceneAsync(sceneId);
return await PopulateWorkspaceDataAsync(PopulateLists(new SceneEditViewModel
{
SceneID = scene.SceneID,
@ -1719,7 +1566,6 @@ public sealed class SceneService(
PrimaryLocationID = scene.PrimaryLocationID,
FloorPlanID = scene.FloorPlanID,
InitialFloorPlanFloorID = scene.InitialFloorPlanFloorID,
TextStatistics = textStatistics,
SelectedPurposeTypeIds = scene.SelectedPurposeTypeIds,
Metrics = scene.Metrics.Select(metric => new SceneMetricInputViewModel
{
@ -1768,7 +1614,6 @@ public sealed class SceneService(
ScenePurposeNotes = model.ScenePurposeNotes,
SceneOutcomeNotes = model.SceneOutcomeNotes,
RevisionStatusID = model.RevisionStatusID,
POVCharacterID = model.POVCharacterID,
PrimaryLocationID = model.PrimaryLocationID,
FloorPlanID = model.FloorPlanID,
InitialFloorPlanFloorID = model.InitialFloorPlanFloorID
@ -1790,8 +1635,8 @@ public sealed class SceneService(
return sceneId;
}
public Task UpdatePovCharacterAsync(int sceneId, int? povCharacterId, string source = "Author", decimal? confidence = null, string? evidence = null, string? suggestedName = null, bool overwriteAuthor = false) =>
scenes.UpdatePovCharacterAsync(sceneId, povCharacterId, source, confidence, evidence, suggestedName, overwriteAuthor);
public Task UpdatePovCharacterAsync(int sceneId, int? povCharacterId) =>
scenes.UpdatePovCharacterAsync(sceneId, povCharacterId);
public async Task ArchiveAsync(int sceneId)
{
@ -5402,11 +5247,7 @@ public sealed class SceneMetricTypeService(IProjectRepository projects, ISceneMe
public Task<bool> MoveAsync(int projectId, int metricTypeId, string direction) => metricTypes.MoveAsync(metricTypeId, projectId, direction);
public async Task<int> AddMissingDefaultsAsync(int projectId)
{
await metricTypes.AddMissingDefaultsAsync(projectId);
return await metricTypes.BackfillDefaultValuesForProjectAsync(projectId);
}
public Task AddMissingDefaultsAsync(int projectId) => metricTypes.AddMissingDefaultsAsync(projectId);
public Task<bool> RemoveUnusedAsync(int projectId, int metricTypeId) => metricTypes.RemoveUnusedAsync(metricTypeId, projectId);
}
@ -9271,35 +9112,6 @@ public sealed class CharacterService(
};
}
public async Task<CharacterMergeViewModel?> GetMergeAsync(int sourceCharacterId)
{
var source = await characters.GetCharacterAsync(sourceCharacterId);
if (source is null)
{
return null;
}
var project = await projects.GetAsync(source.ProjectID);
if (project is null)
{
return null;
}
var targets = (await characters.ListCharactersAsync(source.ProjectID))
.Where(character => character.CharacterID != source.CharacterID)
.OrderBy(character => character.CharacterName, StringComparer.OrdinalIgnoreCase)
.Select(character => new SelectListItem(character.CharacterName, character.CharacterID.ToString()))
.ToList();
return new CharacterMergeViewModel
{
Project = project,
SourceCharacter = source,
Form = new CharacterMergeForm { SourceCharacterID = source.CharacterID },
TargetOptions = targets
};
}
public async Task<CharacterAvatarCropViewModel?> GetAvatarCropAsync(int characterImageId)
{
var image = await characters.GetCharacterImageAsync(characterImageId);
@ -9375,26 +9187,6 @@ public sealed class CharacterService(
return new CharacterSaveResult(characterId, uploadedCharacterImageId);
}
public async Task<CharacterMergeResult?> MergeAsync(CharacterMergeForm form)
{
if (!form.Confirm)
{
throw new InvalidOperationException("Confirm the merge before continuing.");
}
var result = await characters.MergeAsync(form.SourceCharacterID, form.TargetCharacterID, currentUser.UserId);
if (result is not null)
{
var target = await characters.GetCharacterAsync(result.TargetCharacterID);
if (target is not null)
{
await activity.RecordAsync(target.ProjectID, "Merged", "Character", result.TargetCharacterID, result.TargetCharacterName, $"{result.SourceCharacterName} was merged into {result.TargetCharacterName}.");
}
}
return result;
}
private async Task SyncCharacterAliasesAsync(int characterId, IEnumerable<string> postedAliases)
{
var aliases = AliasInput.Clean(postedAliases);

View File

@ -16,7 +16,7 @@ public interface IIllustrationPromptBuilder
public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder
{
public const string CurrentTemplateVersion = "22C.1";
public const string CurrentTemplateVersion = "21J.3";
private static readonly JsonSerializerOptions JsonOptions = new()
{
@ -57,7 +57,7 @@ public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder
var categoryRules = CategoryRules(specification.Category);
var prompt = $"""
Create a realistic cinematic still for PlotDirector's reusable Story Intelligence illustration library.
Create a high-quality editorial illustration for PlotDirector's reusable Story Intelligence illustration library.
Category: {specification.Category}
Library code: {specification.Code}
Title: {specification.Title}
@ -71,16 +71,16 @@ public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder
Category composition rules: {categoryRules}
Requirements:
- realistic grounded visual identifier only, not a canonical depiction of any manuscript
- naturalistic cinematic still, close to believable production photography while remaining safe for a reusable UI library
- subtle 1980s UK period detail where requested, grounded wardrobe and hair, not costume-party exaggeration
- neutral background treatment suitable for PlotDirector UI, with believable anatomy and proportions
- representative visual identifier only, not a canonical depiction of any manuscript
- modern cinematic digital illustration with premium narrative game concept art quality
- subdued atmospheric background treatment with contemporary polished rendering, realistic anatomy and believable proportions
- balanced cinematic lighting, clear facial illumination where faces are present, and strong local contrast
- crisp subject definition with strong foreground-background separation and clean readable silhouettes
- natural controlled colour with rich but restrained accents and warm skin tones where appropriate
- subtle depth of field, clean realistic rendering, and enough exposure to remain clear inside PlotDirector's dark navy interface
- polished promotional artwork for a premium story-driven adventure game, not a literal photograph
- subtle depth of field, clean modern rendering, and enough exposure to remain clear inside PlotDirector's dark navy interface
- no visible text, captions, logos, watermarks, UI, signatures, or typography
- no anime, cartoon, Pixar-like, caricature, celebrity likeness, oil painting, brushwork, painterly texture, fantasy gloss, fashion glamour portrait, or over-stylised concept art
- no anime, cartoon, Pixar-like, caricature, celebrity likeness, or photoreal identity claim
- for character images, the specified hair colour, hair length, apparent age, era, location context and skin tone are hard requirements; make hair colour plainly visible at thumbnail size and do not reinterpret or diversify away from it
- for character images, vary face shape, nose, eyes, mouth, jaw, expression, hairstyle silhouette, and posture so reusable portraits do not look like siblings unless the specification explicitly asks for family resemblance
- no bright flat studio backdrop, no plain colour-block background, no stock-photo lighting, and no cartoon simplicity
@ -103,8 +103,8 @@ public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder
private static string CategoryRules(string category)
=> category switch
{
IllustrationLibraryCategories.Character => "Square image; realistic head-and-shoulders or upper body; one person only; face-forward enough to read at small size; naturally expressive; cleanly lit face; distinct from a subtle grounded story background; suitable for circular cropping; avoid bright flat studio backgrounds, plain colour blocks, dark collars vanishing into black backgrounds, and fashion headshot styling.",
IllustrationLibraryCategories.Location => "One clear focal environment; specific readable place type; realistic light and materials; strong composition for circular and landscape crops; no prominent people; no text or readable signage; do not default every location to nighttime, rain-soaked, blue-black, ominous, haunted-looking, or generic domestic interior.",
IllustrationLibraryCategories.Character => "Square image; head-and-shoulders or upper body; one person only; face-forward enough to read at small size; naturally expressive; cleanly lit face; distinct from a subtle atmospheric story background; suitable for circular cropping; avoid bright flat studio backgrounds, plain colour blocks, dark collars vanishing into black backgrounds, and photographic headshot styling.",
IllustrationLibraryCategories.Location => "One clear focal environment; vivid and readable; clearly lit and visually inviting even when dramatic; rich colour and depth; strong composition for circular and landscape crops; no prominent people; no text or readable signage; do not default every location to nighttime, rain-soaked, blue-black, ominous, or haunted-looking.",
IllustrationLibraryCategories.Asset => "Square image; one dominant centred object; bright clean high-contrast presentation; crisp silhouette; clear separation from background; immediately recognisable at small size; entire object visible where practical.",
_ => string.Empty
};
@ -333,9 +333,6 @@ public interface IIllustrationImageProvider
{
string ProviderName { get; }
string EffectiveModel { get; }
string EffectiveQuality { get; }
string EffectiveSize { get; }
string EffectiveOutputFormat { get; }
bool IsConfigured { get; }
IReadOnlyList<string> MissingConfiguration { get; }
Task<IllustrationImageGenerationResult> GenerateAsync(IllustrationImageGenerationRequest request, CancellationToken cancellationToken);
@ -350,9 +347,6 @@ public sealed class OpenAIIllustrationImageProvider(
public string ProviderName => "OpenAI";
public string EffectiveModel => string.IsNullOrWhiteSpace(_options.ImageGenerationModel) ? "not configured" : _options.ImageGenerationModel;
public string EffectiveQuality => string.IsNullOrWhiteSpace(_options.ImageGenerationQuality) ? "auto" : _options.ImageGenerationQuality;
public string EffectiveSize => string.IsNullOrWhiteSpace(_options.ImageGenerationSize) ? "1024x1024" : _options.ImageGenerationSize;
public string EffectiveOutputFormat => string.IsNullOrWhiteSpace(_options.ImageGenerationOutputFormat) ? "default" : _options.ImageGenerationOutputFormat;
public bool IsConfigured => MissingConfiguration.Count == 0;
public IReadOnlyList<string> MissingConfiguration
{
@ -380,35 +374,21 @@ public sealed class OpenAIIllustrationImageProvider(
return new(false, Provider: ProviderName, Model: EffectiveModel, ErrorMessage: $"OpenAI image generation is not configured. Missing: {string.Join(", ", MissingConfiguration)}.");
}
var size = string.IsNullOrWhiteSpace(_options.ImageGenerationSize) ? "1024x1024" : _options.ImageGenerationSize;
var quality = string.IsNullOrWhiteSpace(_options.ImageGenerationQuality) ? null : _options.ImageGenerationQuality;
var outputFormat = string.IsNullOrWhiteSpace(_options.ImageGenerationOutputFormat) ? null : _options.ImageGenerationOutputFormat;
var body = new Dictionary<string, object?>
{
["model"] = _options.ImageGenerationModel,
["prompt"] = request.Prompt,
["size"] = size,
["n"] = 1
};
if (!string.IsNullOrWhiteSpace(quality))
{
body["quality"] = quality;
}
if (!string.IsNullOrWhiteSpace(outputFormat))
{
body["output_format"] = outputFormat;
}
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "https://api.openai.com/v1/images/generations");
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.ApiKey);
httpRequest.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
httpRequest.Content = new StringContent(JsonSerializer.Serialize(new
{
model = _options.ImageGenerationModel,
prompt = request.Prompt,
size = string.IsNullOrWhiteSpace(_options.ImageGenerationSize) ? "1024x1024" : _options.ImageGenerationSize,
n = 1
}), Encoding.UTF8, "application/json");
using var response = await httpClient.SendAsync(httpRequest, cancellationToken);
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
var body = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
var error = OpenAIImageError.FromResponseBody(responseBody);
var error = OpenAIImageError.FromResponseBody(body);
logger.LogWarning(
"OpenAI image generation failed for illustration {ItemID} with HTTP {StatusCode}, error code {ErrorCode}: {ErrorMessage}",
request.IllustrationLibraryItemID,
@ -418,7 +398,7 @@ public sealed class OpenAIIllustrationImageProvider(
return new(false, Provider: ProviderName, Model: EffectiveModel, ErrorMessage: $"Provider returned {(int)response.StatusCode}: {error.ForStorage()}");
}
using var document = JsonDocument.Parse(responseBody);
using var document = JsonDocument.Parse(body);
var requestId = response.Headers.TryGetValues("x-request-id", out var values) ? values.FirstOrDefault() : null;
var b64 = document.RootElement.GetProperty("data")[0].GetProperty("b64_json").GetString();
if (string.IsNullOrWhiteSpace(b64))
@ -426,7 +406,7 @@ public sealed class OpenAIIllustrationImageProvider(
return new(false, Provider: ProviderName, Model: EffectiveModel, ProviderRequestID: requestId, ErrorMessage: "Provider response did not include image data.");
}
return new(true, Convert.FromBase64String(b64), "image/png", ProviderName, EffectiveModel, requestId, quality, size, outputFormat);
return new(true, Convert.FromBase64String(b64), "image/png", ProviderName, EffectiveModel, requestId);
}
private static string TrimForStorage(string value)
@ -580,16 +560,6 @@ public sealed class IllustrationGenerationRunner(
var prompt = promptBuilder.Build(specification);
var request = new IllustrationImageGenerationRequest(item.IllustrationLibraryItemID, item.StableCode, item.Category, prompt.Prompt, prompt.TemplateVersion);
logger.LogInformation(
"Illustration generation decision: generating queued item. IllustrationLibraryItemID={IllustrationLibraryItemID} Category={Category} StableCode={StableCode} Model={Model} Quality={Quality} Size={Size} OutputFormat={OutputFormat} PromptTemplateVersion={PromptTemplateVersion}",
item.IllustrationLibraryItemID,
item.Category,
item.StableCode,
provider.EffectiveModel,
provider.EffectiveQuality,
provider.EffectiveSize,
provider.EffectiveOutputFormat,
prompt.TemplateVersion);
var result = await provider.GenerateAsync(request, cancellationToken);
if (!result.Succeeded || !result.HasImage)
{
@ -649,12 +619,14 @@ public interface IIllustrationLibraryService
Task<IllustrationLibraryActionResult> RegenerateAsync(int illustrationLibraryItemId, bool confirmed);
Task<IllustrationLibraryActionResult> DisableAsync(int illustrationLibraryItemId);
Task<IllustrationLibraryActionResult> UpdateMetadataAsync(int illustrationLibraryItemId, IllustrationLibraryMetadataForm form);
Task ApplyApprovedIllustrationsAsync(StoryIntelligenceExperiencePrototypeViewModel prototype);
}
public sealed class IllustrationLibraryService(
IIllustrationLibraryRepository repository,
IIllustrationPromptBuilder promptBuilder,
IIllustrationImageProvider provider) : IIllustrationLibraryService
IIllustrationImageProvider provider,
IUploadStorageService uploadStorage) : IIllustrationLibraryService
{
private const int MaxGenerationAttempts = 3;
@ -951,4 +923,113 @@ public sealed class IllustrationLibraryService(
return new(true, "Illustration metadata updated.");
}
public async Task ApplyApprovedIllustrationsAsync(StoryIntelligenceExperiencePrototypeViewModel prototype)
{
var items = await repository.ListAsync(new IllustrationLibraryFilter());
var lookup = items
.Where(item => item.Status is IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Generated)
.OrderBy(item => item.Status == IllustrationLibraryStatuses.Approved ? 0 : 1)
.ThenByDescending(item => item.UpdatedUtc)
.GroupBy(item => item.StableCode, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.ToList(), StringComparer.OrdinalIgnoreCase);
foreach (var scene in prototype.Scenes)
{
var locationFallback = scene.Location.ImagePath;
var locationResolution = ResolvePrototypeImage(scene.Location.Id, scene.Location.LibraryCode, locationFallback, lookup);
scene.Location.ImageResolution = locationResolution;
scene.Location.ImagePath = locationResolution.FinalImageUrl ?? locationFallback;
foreach (var character in scene.Characters)
{
var fallback = character.ImagePath;
var resolution = ResolvePrototypeImage(character.Id, character.LibraryCode, fallback, lookup);
character.ImageResolution = resolution;
character.ImagePath = resolution.FinalImageUrl ?? fallback;
}
foreach (var asset in scene.Assets)
{
var fallback = asset.ImagePath;
var resolution = ResolvePrototypeImage(asset.Id, asset.LibraryCode, fallback, lookup);
asset.ImageResolution = resolution;
asset.ImagePath = resolution.FinalImageUrl ?? fallback;
}
}
}
private StoryIntelligenceExperienceImageResolution ResolvePrototypeImage(
string entityId,
string stableCode,
string fallbackImageUrl,
IReadOnlyDictionary<string, List<IllustrationLibraryItem>> lookup)
{
if (string.IsNullOrWhiteSpace(stableCode) || !lookup.TryGetValue(stableCode, out var candidates))
{
return PrototypeFallback(entityId, stableCode, fallbackImageUrl);
}
foreach (var candidate in candidates)
{
var publicUrl = ResolveExistingPublicUploadUrl(candidate.ThumbnailPath) ?? ResolveExistingPublicUploadUrl(candidate.FilePath);
if (publicUrl is null)
{
continue;
}
return new()
{
EntityId = entityId,
StableCode = stableCode,
IllustrationLibraryItemId = candidate.IllustrationLibraryItemID,
Status = candidate.Status,
FinalImageUrl = publicUrl,
FallbackImageUrl = fallbackImageUrl,
FallbackUsed = false
};
}
var firstCandidate = candidates.FirstOrDefault();
return new()
{
EntityId = entityId,
StableCode = stableCode,
IllustrationLibraryItemId = firstCandidate?.IllustrationLibraryItemID,
Status = firstCandidate?.Status,
FallbackImageUrl = fallbackImageUrl,
FallbackUsed = true
};
}
private string? ResolveExistingPublicUploadUrl(string? storedPath)
{
var physicalPath = uploadStorage.TryResolvePublicPath(storedPath, "uploads/story-intelligence-library");
if (physicalPath is null || !File.Exists(physicalPath))
{
return null;
}
var normalised = storedPath!.Replace('\\', '/').Trim();
if (!normalised.StartsWith('/'))
{
normalised = "/" + normalised.TrimStart('/');
}
return EncodeUrlPath(normalised);
}
private static StoryIntelligenceExperienceImageResolution PrototypeFallback(string entityId, string stableCode, string fallbackImageUrl)
=> new()
{
EntityId = entityId,
StableCode = stableCode,
FallbackImageUrl = fallbackImageUrl,
FallbackUsed = true
};
private static string EncodeUrlPath(string path)
=> string.Join(
'/',
path.Split('/', StringSplitOptions.None)
.Select((segment, index) => index == 0 && segment.Length == 0 ? string.Empty : Uri.EscapeDataString(segment)));
}

View File

@ -16,7 +16,6 @@ public interface IManuscriptScanPreviewStore
Task<ManuscriptScanPreview?> GetPreviewAsync(int userId, Guid previewId);
Task<ManuscriptScanReviewDecision?> GetReviewAsync(int userId, Guid previewId);
Task<ManuscriptScanReviewDecision?> SaveReviewAsync(int userId, ManuscriptScanReviewDecision review);
Task<ManuscriptScanPreview?> SaveCharacterCandidatesAsync(int userId, Guid previewId, IReadOnlyList<ManuscriptScanCharacterCandidatePreview> candidates);
Task<OnboardingBuildProgress> BuildProgressAsync(int userId, Guid previewId, string message, int percentComplete, OnboardingManuscriptBuildResult? result = null);
Task<OnboardingManuscriptBuildResult?> GetBuildResultAsync(int userId, Guid previewId);
Task<OnboardingManuscriptBuildResult?> SaveBuildResultAsync(int userId, OnboardingManuscriptBuildResult result);
@ -93,8 +92,8 @@ public sealed class ManuscriptScanPreviewStore(
session.Message = "Scan complete";
session.PercentComplete = 100;
session.ChapterCount = stored.ChapterCount;
session.SceneCount = 0;
session.CharacterCandidateCount = 0;
session.SceneCount = stored.SceneCount;
session.CharacterCandidateCount = stored.CharacterCandidateCount;
session.TotalWordCount = stored.TotalWordCount;
session.PreviewID = stored.PreviewID;
session.UpdatedUtc = stored.CreatedUtc;
@ -179,7 +178,6 @@ public sealed class ManuscriptScanPreviewStore(
{
PreviewID = review.PreviewID,
Status = review.Status,
GenerateSceneTitles = review.GenerateSceneTitles,
UpdatedUtc = DateTime.UtcNow,
Chapters = review.Chapters,
Scenes = review.Scenes,
@ -195,45 +193,6 @@ public sealed class ManuscriptScanPreviewStore(
return Task.FromResult<ManuscriptScanReviewDecision?>(stored);
}
public Task<ManuscriptScanPreview?> SaveCharacterCandidatesAsync(int userId, Guid previewId, IReadOnlyList<ManuscriptScanCharacterCandidatePreview> candidates)
{
if (!previews.TryGetValue(previewId, out var preview) || preview.UserID != userId)
{
return Task.FromResult<ManuscriptScanPreview?>(null);
}
var cleaned = candidates
.Where(candidate => !string.IsNullOrWhiteSpace(candidate.Name))
.ToList();
var updated = new ManuscriptScanPreview
{
PreviewID = preview.PreviewID,
UserID = preview.UserID,
OnboardingID = preview.OnboardingID,
ProjectID = preview.ProjectID,
BookID = preview.BookID,
Source = preview.Source,
DocumentTitle = preview.DocumentTitle,
CompanionDocumentIdentifier = preview.CompanionDocumentIdentifier,
TotalWordCount = preview.TotalWordCount,
ChapterCount = preview.ChapterCount,
SceneCount = preview.SceneCount,
CharacterCandidateCount = cleaned.Count,
CreatedUtc = preview.CreatedUtc,
Chapters = preview.Chapters,
Scenes = preview.Scenes,
CharacterCandidates = cleaned
};
previews[previewId] = updated;
if (sessions.TryGetValue((userId, preview.OnboardingID), out var session))
{
session.CharacterCandidateCount = cleaned.Count;
session.UpdatedUtc = DateTime.UtcNow;
}
return Task.FromResult<ManuscriptScanPreview?>(updated);
}
public Task<OnboardingBuildProgress> BuildProgressAsync(int userId, Guid previewId, string message, int percentComplete, OnboardingManuscriptBuildResult? result = null)
{
if (!previews.TryGetValue(previewId, out var preview) || preview.UserID != userId)
@ -297,10 +256,10 @@ public sealed class ManuscriptScanPreviewStore(
private async Task<ManuscriptScanReviewDecision> BuildDefaultReviewAsync(ManuscriptScanPreview preview)
{
var aiHints = await GetAiSexHintsAsync(preview.CharacterCandidates);
return new ManuscriptScanReviewDecision
{
PreviewID = preview.PreviewID,
GenerateSceneTitles = true,
Status = ManuscriptScanReviewStatuses.ScanComplete,
Chapters = preview.Chapters
.OrderBy(chapter => chapter.ChapterNumber)
@ -312,7 +271,18 @@ public sealed class ManuscriptScanPreviewStore(
ChapterNumber = chapter.ChapterNumber
})
.ToList(),
Scenes = [],
Scenes = preview.Scenes
.OrderBy(scene => scene.TemporaryChapterKey)
.ThenBy(scene => scene.SceneNumberWithinChapter)
.Select(scene => new ManuscriptScanSceneReviewDecision
{
TemporarySceneKey = scene.TemporarySceneKey,
TemporaryChapterKey = scene.TemporaryChapterKey,
Include = true,
Title = string.IsNullOrWhiteSpace(scene.Title) ? $"Scene {scene.SceneNumberWithinChapter}" : scene.Title,
SceneNumberWithinChapter = scene.SceneNumberWithinChapter
})
.ToList(),
Characters = preview.CharacterCandidates
.Select(candidate => new ManuscriptScanCharacterReviewDecision
{
@ -320,7 +290,9 @@ public sealed class ManuscriptScanPreviewStore(
Include = string.Equals(candidate.Category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase),
Name = candidate.Name,
Category = candidate.Category,
SexHint = CharacterSexHintService.Suggest(candidate.Name)
SexHint = aiHints.TryGetValue(candidate.TemporaryCharacterKey, out var aiHint)
? aiHint
: CharacterSexHintService.Suggest(candidate.Name)
})
.ToList()
};
@ -381,11 +353,11 @@ file static class ManuscriptScanPreviewExtensions
CompanionDocumentIdentifier = preview.CompanionDocumentIdentifier,
TotalWordCount = preview.TotalWordCount,
ChapterCount = preview.ChapterCount,
SceneCount = 0,
CharacterCandidateCount = 0,
SceneCount = preview.SceneCount,
CharacterCandidateCount = preview.CharacterCandidateCount,
CreatedUtc = preview.CreatedUtc == default ? DateTime.UtcNow : preview.CreatedUtc,
Chapters = preview.Chapters,
Scenes = [],
CharacterCandidates = []
Scenes = preview.Scenes,
CharacterCandidates = preview.CharacterCandidates
};
}

View File

@ -1,626 +0,0 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using PlotLine.Models;
using PlotLine.Models.StoryIntelligence;
namespace PlotLine.Services;
public interface IManuscriptStructureAnalysisService
{
Task<ManuscriptStructureAnalysisResult> AnalyseChapterAsync(ManuscriptStructureAnalysisRequest request, CancellationToken cancellationToken = default);
}
public sealed partial class ManuscriptStructureAnalysisService(
IStoryPromptRepository prompts,
IStoryPromptVersionService versions,
IStoryIntelligenceClient client,
IChapterStructureValidator validator,
ILogger<ManuscriptStructureAnalysisService> logger) : IManuscriptStructureAnalysisService
{
private const string ChapterPromptFile = "Chapter-Structure-Prompt-V2.md";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
WriteIndented = true
};
public async Task<ManuscriptStructureAnalysisResult> AnalyseChapterAsync(ManuscriptStructureAnalysisRequest request, CancellationToken cancellationToken = default)
{
var text = request.ChapterText?.Trim() ?? string.Empty;
var paragraphs = StoryIntelligenceParagraphs.Split(text);
if (paragraphs.Count == 0)
{
return ManuscriptStructureAnalysisResult.Failed(
request.TemporaryChapterKey,
"The approved chapter has no readable text for scene-boundary detection.");
}
try
{
var promptVersion = versions.GetPromptVersion(ChapterPromptFile);
var clientStatus = client.GetConfigurationStatus();
var template = await prompts.LoadPromptAsync(ChapterPromptFile, cancellationToken);
var prompt = BuildChapterPrompt(template, BuildChapterContextJson(request), StoryIntelligenceParagraphs.Number(paragraphs));
var result = await client.ExecutePromptAsync(prompt, promptVersion, cancellationToken, clientStatus.ChapterStructureModel);
var chapterJson = ExtractOutputText(result.RawResponseText);
var parseResult = StoryIntelligenceAiJsonParser.Parse<ChapterStructureModel>(chapterJson, JsonOptions);
if (!parseResult.Success)
{
LogStructuralJsonParseFailure(request, parseResult, repairSucceeded: false, retryAttempted: false);
var retryPrompt = BuildJsonRetryPrompt(
prompt,
parseResult.ErrorMessage ?? parseResult.InitialErrorMessage ?? "Chapter Structure JSON could not be parsed.",
chapterJson);
var retryResult = await client.ExecutePromptAsync(retryPrompt, promptVersion, cancellationToken, clientStatus.ChapterStructureModel);
var retryJson = ExtractOutputText(retryResult.RawResponseText);
var retryParseResult = StoryIntelligenceAiJsonParser.Parse<ChapterStructureModel>(retryJson, JsonOptions);
if (!retryParseResult.Success)
{
LogStructuralJsonParseFailure(request, retryParseResult, repairSucceeded: false, retryAttempted: true);
return ManuscriptStructureAnalysisResult.Failed(
request.TemporaryChapterKey,
BuildUserJsonParseFailureMessage(request),
retryParseResult.RawJson);
}
var retryWarnings = new List<string> { "Chapter Structure JSON parse failed once and succeeded after one retry." };
retryWarnings.AddRange(retryParseResult.Warnings);
parseResult = retryParseResult with { Warnings = retryWarnings };
}
if (parseResult.Warnings.Count > 0)
{
logger.LogWarning(
"Repaired structural scene-boundary JSON. ProjectID={ProjectID} BookID={BookID} PreviewID={PreviewID} TemporaryChapterKey={TemporaryChapterKey} SourceLabel={SourceLabel} ParserFailure={ParserFailure} DiagnosticExcerpt={DiagnosticExcerpt} RepairSucceeded={RepairSucceeded} RetryAttempted={RetryAttempted} Warnings={Warnings}",
request.ProjectID,
request.BookID,
request.PreviewID,
request.TemporaryChapterKey,
request.SourceLabel,
parseResult.InitialErrorMessage,
BuildJsonDiagnosticExcerpt(parseResult.RawJson, parseResult.InitialErrorMessage),
parseResult.RepairedJson is not null,
parseResult.Warnings.Any(warning => warning.Contains("retry", StringComparison.OrdinalIgnoreCase)),
string.Join("; ", parseResult.Warnings));
}
var parsed = parseResult.Parsed;
var normalisation = ChapterStructureBoundaryNormaliser.Normalise(parsed, paragraphs.Count);
var chapter = normalisation.ChapterStructure;
var explicitPov = DetectExplicitNarrator(text);
var validation = validator.Validate(chapter, paragraphs.Count);
ChapterStructureBoundaryNormaliser.AddIssuesTo(validation, normalisation);
var persistedJson = normalisation.Issues.Count > 0
? ChapterStructureBoundaryNormaliser.SerialiseParsedModel(chapter, normalisation, JsonOptions)
: parseResult.RepairedJson ?? parseResult.RawJson;
if (!validation.IsValid || chapter?.SceneBoundaries is null || chapter.SceneBoundaries.Count == 0)
{
var diagnostic = BuildBoundaryDiagnostic(chapter, request.ParagraphMap, paragraphs.Count);
logger.LogWarning(
"Structural scene-boundary validation failed. ProjectID={ProjectID} BookID={BookID} TemporaryChapterKey={TemporaryChapterKey} StructuralParagraphCount={StructuralParagraphCount} Diagnostic={Diagnostic}",
request.ProjectID,
request.BookID,
request.TemporaryChapterKey,
paragraphs.Count,
diagnostic);
return ManuscriptStructureAnalysisResult.Failed(
request.TemporaryChapterKey,
$"{BuildValidationMessage(validation)} {diagnostic}",
persistedJson);
}
var scenes = new List<ManuscriptStructureSceneBoundary>();
foreach (var boundary in chapter.SceneBoundaries.OrderBy(item => item.SceneNumber ?? int.MaxValue))
{
var sceneNumber = boundary.SceneNumber ?? 0;
var start = boundary.StartParagraph ?? 0;
var end = boundary.EndParagraph ?? 0;
if (sceneNumber <= 0 || start <= 0 || end <= 0 || start > end || end > paragraphs.Count)
{
return ManuscriptStructureAnalysisResult.Failed(
request.TemporaryChapterKey,
$"Scene {sceneNumber:N0} has an invalid paragraph range.",
persistedJson);
}
var startMap = request.ParagraphMap.FirstOrDefault(item => item.StructuralParagraphOrdinal == start);
var endMap = request.ParagraphMap.FirstOrDefault(item => item.StructuralParagraphOrdinal == end);
if (startMap is null || endMap is null || startMap.WordParagraphIndex < 0 || endMap.WordParagraphIndex < 0)
{
return ManuscriptStructureAnalysisResult.Failed(
request.TemporaryChapterKey,
$"Scene {sceneNumber:N0} could not be mapped back to a Word paragraph.",
persistedJson);
}
var sceneParagraphs = paragraphs.Skip(start - 1).Take(end - start + 1).ToList();
var sourceText = BuildSourceText(request.SourceParagraphs, startMap.WordParagraphIndex, endMap.WordParagraphIndex);
var suggestedTitle = CleanSuggestedTitle(boundary.SuggestedTitle, sceneNumber);
var aiDefaultPov = CleanPovSuggestion(chapter.DefaultPov);
var pov = CleanPovSuggestion(boundary.PovOverride) ?? explicitPov ?? aiDefaultPov;
scenes.Add(new ManuscriptStructureSceneBoundary
{
TemporarySceneKey = $"{request.TemporaryChapterKey}-ai-scene-{sceneNumber}",
TemporaryChapterKey = request.TemporaryChapterKey,
SceneNumberWithinChapter = sceneNumber,
Title = suggestedTitle ?? string.Empty,
WordCount = sceneParagraphs.Sum(CountWords),
SourceText = sourceText,
StartParagraph = start,
EndParagraph = end,
WordStartPosition = startMap.WordParagraphIndex,
WordEndPosition = endMap.WordParagraphIndex,
StructuralSummary = Trim(boundary.StructuralSummary, 600),
StructuralSetting = Trim(boundary.StructuralSetting, 300),
Reason = boundary.Reason,
Confidence = boundary.Confidence,
SuggestedPovName = pov?.CharacterName,
SuggestedPovConfidence = pov?.Confidence,
SuggestedPovEvidence = pov?.Evidence,
SuggestedPovIsOverride = boundary.PovOverride is not null && !string.IsNullOrWhiteSpace(boundary.PovOverride.CharacterName),
ChapterParsedJson = persistedJson,
ExplicitNarratorName = explicitPov?.CharacterName
});
}
var rawPov = CleanPovSuggestion(chapter.DefaultPov);
logger.LogInformation(
"Structural POV audit. ProjectID={ProjectID} BookID={BookID} PreviewID={PreviewID} TemporaryChapterKey={TemporaryChapterKey} ExplicitNarrator={ExplicitNarrator} AiDefaultPov={AiDefaultPov} MixedPov={MixedPov} RawStructuralJson={RawStructuralJson}",
request.ProjectID,
request.BookID,
request.PreviewID,
request.TemporaryChapterKey,
explicitPov?.CharacterName,
rawPov?.CharacterName,
chapter.MixedPov,
parseResult.RawJson);
logger.LogInformation(
"Structural scene-boundary analysis completed. ProjectID={ProjectID} BookID={BookID} TemporaryChapterKey={TemporaryChapterKey} Scenes={SceneCount}",
request.ProjectID,
request.BookID,
request.TemporaryChapterKey,
scenes.Count);
var defaultPov = explicitPov ?? CleanPovSuggestion(chapter.DefaultPov);
return ManuscriptStructureAnalysisResult.Succeeded(
request.TemporaryChapterKey,
scenes,
persistedJson,
defaultPov?.CharacterName,
defaultPov?.Confidence,
defaultPov?.Evidence,
chapter.MixedPov == true);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(
ex,
"Structural scene-boundary analysis failed. ProjectID={ProjectID} BookID={BookID} TemporaryChapterKey={TemporaryChapterKey}",
request.ProjectID,
request.BookID,
request.TemporaryChapterKey);
return ManuscriptStructureAnalysisResult.Failed(request.TemporaryChapterKey, ex.Message);
}
}
private void LogStructuralJsonParseFailure(
ManuscriptStructureAnalysisRequest request,
StoryIntelligenceAiJsonParseResult<ChapterStructureModel> parseResult,
bool repairSucceeded,
bool retryAttempted)
{
var parserFailure = parseResult.InitialErrorMessage ?? parseResult.ErrorMessage;
logger.LogWarning(
"Structural scene-boundary JSON parse failed. ProjectID={ProjectID} BookID={BookID} PreviewID={PreviewID} TemporaryChapterKey={TemporaryChapterKey} SourceLabel={SourceLabel} ParserFailure={ParserFailure} DiagnosticExcerpt={DiagnosticExcerpt} RepairSucceeded={RepairSucceeded} RetryAttempted={RetryAttempted}",
request.ProjectID,
request.BookID,
request.PreviewID,
request.TemporaryChapterKey,
request.SourceLabel,
parserFailure,
BuildJsonDiagnosticExcerpt(parseResult.RawJson, parserFailure),
repairSucceeded,
retryAttempted);
}
private static string BuildChapterContextJson(ManuscriptStructureAnalysisRequest request)
=> JsonSerializer.Serialize(new
{
projectId = request.ProjectID,
bookId = request.BookID,
chapterId = (int?)null,
chapterNumber = request.ChapterNumber,
sourceLabel = request.SourceLabel,
storyEra = request.StoryEra,
primaryLocale = request.PrimaryLocale,
knownCharacters = request.KnownCharacters.Take(80)
}, JsonOptions);
private static string BuildChapterPrompt(string promptTemplate, string chapterContextJson, string chapterText)
{
if (string.IsNullOrWhiteSpace(promptTemplate))
{
throw new InvalidOperationException("Prompt template is empty.");
}
return promptTemplate
.Replace("{{CHAPTER_CONTEXT_JSON}}", chapterContextJson ?? string.Empty, StringComparison.Ordinal)
.Replace("{{CHAPTER_TEXT}}", chapterText ?? string.Empty, StringComparison.Ordinal);
}
private static string BuildJsonRetryPrompt(string originalPrompt, string parseError, string malformedJson)
=> string.Concat(
originalPrompt,
Environment.NewLine,
Environment.NewLine,
"## JSON Repair Retry",
Environment.NewLine,
"Your previous response was not valid JSON. Return the same Chapter Structure object as valid JSON only. Do not include markdown. Do not truncate. Numeric confidence values must be valid JSON numbers such as 0.8 or 0.0, with no spaces inside numbers.",
Environment.NewLine,
"Parse error:",
Environment.NewLine,
parseError,
Environment.NewLine,
"Malformed JSON returned previously:",
Environment.NewLine,
"```json",
Environment.NewLine,
malformedJson,
Environment.NewLine,
"```");
private static string ExtractOutputText(string rawResponseText)
{
var response = JsonSerializer.Deserialize<OpenAIResponseEnvelope>(rawResponseText, JsonOptions)
?? throw new JsonException("OpenAI response envelope was empty.");
var outputText = response.Output?
.SelectMany(item => item.Content ?? [])
.FirstOrDefault(content => string.Equals(content.Type, "output_text", StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrWhiteSpace(content.Text))
?.Text;
if (string.IsNullOrWhiteSpace(outputText))
{
throw new JsonException("OpenAI response did not contain output_text content.");
}
return outputText.Trim();
}
private static string BuildSourceText(IReadOnlyList<ManuscriptScanSourceParagraph> sourceParagraphs, int wordStartPosition, int wordEndPosition)
{
var lines = sourceParagraphs
.Where(item => item.WordParagraphIndex >= wordStartPosition
&& item.WordParagraphIndex <= wordEndPosition
&& item.IncludeInSourceText)
.OrderBy(item => item.WordParagraphIndex)
.Select(item => item.IsBlank ? string.Empty : item.Text.Trim())
.ToList();
while (lines.Count > 0 && string.IsNullOrWhiteSpace(lines[0]))
{
lines.RemoveAt(0);
}
while (lines.Count > 0 && string.IsNullOrWhiteSpace(lines[^1]))
{
lines.RemoveAt(lines.Count - 1);
}
return string.Join(Environment.NewLine + Environment.NewLine, lines);
}
private static string BuildValidationMessage(ValidationResult validation)
{
var first = validation.Errors.FirstOrDefault()?.Message
?? validation.Warnings.FirstOrDefault()?.Message
?? "Chapter Structure validation failed.";
return first;
}
private static string BuildUserJsonParseFailureMessage(ManuscriptStructureAnalysisRequest request)
{
var label = ExtractChapterLabel(request.SourceLabel);
return string.IsNullOrWhiteSpace(label)
? "PlotDirector could not read the scene structure returned by the analysis. The analysis has been stopped and can be retried."
: $"PlotDirector could not read the scene structure returned for \"{label}\". The analysis has been stopped and can be retried.";
}
private static string ExtractChapterLabel(string sourceLabel)
{
if (string.IsNullOrWhiteSpace(sourceLabel))
{
return string.Empty;
}
var separator = sourceLabel.LastIndexOf(':');
return separator >= 0 && separator + 1 < sourceLabel.Length
? sourceLabel[(separator + 1)..].Trim()
: sourceLabel.Trim();
}
private static string BuildJsonDiagnosticExcerpt(string rawJson, string? parseError)
{
if (string.IsNullOrWhiteSpace(rawJson))
{
return "No raw JSON was available.";
}
if (!TryReadJsonErrorLocation(parseError, out var lineNumber, out var bytePosition))
{
return TrimDiagnostic(rawJson.ReplaceLineEndings(" "), 180);
}
var lines = rawJson.ReplaceLineEndings("\n").Split('\n');
if (lineNumber < 0 || lineNumber >= lines.Length)
{
return TrimDiagnostic(rawJson.ReplaceLineEndings(" "), 180);
}
var line = lines[lineNumber].Trim();
if (line.Length <= 180)
{
return line;
}
var start = Math.Max(0, Math.Min(bytePosition, line.Length) - 70);
var length = Math.Min(160, line.Length - start);
return TrimDiagnostic(line.Substring(start, length), 180);
}
private static bool TryReadJsonErrorLocation(string? parseError, out int lineNumber, out int bytePosition)
{
lineNumber = -1;
bytePosition = -1;
if (string.IsNullOrWhiteSpace(parseError))
{
return false;
}
const string lineMarker = "LineNumber: ";
const string byteMarker = "BytePositionInLine: ";
var lineStart = parseError.IndexOf(lineMarker, StringComparison.Ordinal);
var byteStart = parseError.IndexOf(byteMarker, StringComparison.Ordinal);
if (lineStart < 0 || byteStart < 0)
{
return false;
}
lineStart += lineMarker.Length;
byteStart += byteMarker.Length;
var lineEnd = parseError.IndexOfAny([' ', '|', '.'], lineStart);
var byteEnd = parseError.IndexOfAny([' ', '|', '.'], byteStart);
var lineValue = lineEnd > lineStart ? parseError[lineStart..lineEnd] : parseError[lineStart..];
var byteValue = byteEnd > byteStart ? parseError[byteStart..byteEnd] : parseError[byteStart..];
return int.TryParse(lineValue, out lineNumber)
&& int.TryParse(byteValue, out bytePosition);
}
private static string TrimDiagnostic(string value, int maxLength)
{
var clean = value.Trim();
return clean.Length <= maxLength ? clean : clean[..maxLength].TrimEnd();
}
private static string BuildBoundaryDiagnostic(
ChapterStructureModel? chapter,
IReadOnlyList<ManuscriptScanParagraphMap> paragraphMap,
int structuralParagraphCount)
{
if (chapter?.SceneBoundaries is null || chapter.SceneBoundaries.Count == 0)
{
return $"Structural paragraphs: 1..{structuralParagraphCount:N0}; no scene boundaries were returned.";
}
var finalMap = paragraphMap
.Where(item => item.StructuralParagraphOrdinal == structuralParagraphCount)
.Select(item => item.WordParagraphIndex)
.FirstOrDefault();
var ranges = chapter.SceneBoundaries
.OrderBy(boundary => boundary.SceneNumber ?? int.MaxValue)
.Select(boundary =>
{
var number = boundary.SceneNumber?.ToString("N0") ?? "?";
var start = boundary.StartParagraph;
var end = boundary.EndParagraph;
var startWord = start.HasValue
? paragraphMap.FirstOrDefault(item => item.StructuralParagraphOrdinal == start.Value)?.WordParagraphIndex
: null;
var endWord = end.HasValue
? paragraphMap.FirstOrDefault(item => item.StructuralParagraphOrdinal == end.Value)?.WordParagraphIndex
: null;
return $"scene {number}: structural {Display(start)}-{Display(end)}, Word {Display(startWord)}-{Display(endWord)}";
});
return $"Structural paragraphs: 1..{structuralParagraphCount:N0}; final structural paragraph maps to Word paragraph {finalMap:N0}; returned {string.Join("; ", ranges)}.";
}
private static string Display(int? value)
=> value.HasValue ? value.Value.ToString("N0") : "?";
private static int CountWords(string value)
=> string.IsNullOrWhiteSpace(value)
? 0
: value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
private static string? Trim(string? value, int maxLength)
{
var clean = value?.Trim();
if (string.IsNullOrWhiteSpace(clean))
{
return null;
}
return clean.Length <= maxLength ? clean : clean[..maxLength].Trim();
}
private static string? CleanSuggestedTitle(string? value, int sceneNumber)
{
var clean = Trim(value, 80);
if (string.IsNullOrWhiteSpace(clean))
{
return null;
}
clean = clean.Trim(' ', '.', ':', ';', '-', '"', '\'');
if (string.IsNullOrWhiteSpace(clean)
|| string.Equals(clean, $"Scene {sceneNumber}", StringComparison.OrdinalIgnoreCase)
|| clean.StartsWith("Untitled", StringComparison.OrdinalIgnoreCase))
{
return null;
}
var wordCount = clean.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
return wordCount is >= 2 and <= 6 ? clean : null;
}
private static ChapterStructurePovSuggestion? CleanPovSuggestion(ChapterStructurePovSuggestion? suggestion)
{
var name = Trim(suggestion?.CharacterName, 120);
if (string.IsNullOrWhiteSpace(name)
|| string.Equals(name, "Unknown", StringComparison.OrdinalIgnoreCase)
|| string.Equals(name, "Mixed", StringComparison.OrdinalIgnoreCase))
{
return null;
}
return new ChapterStructurePovSuggestion
{
CharacterName = name,
Confidence = suggestion?.Confidence,
Evidence = Trim(suggestion?.Evidence, 400),
IsExplicit = suggestion?.IsExplicit
};
}
public static ChapterStructurePovSuggestion? DetectExplicitNarrator(string? chapterText)
{
if (string.IsNullOrWhiteSpace(chapterText))
{
return null;
}
var paragraphs = StoryIntelligenceParagraphs.Split(chapterText).Take(6);
foreach (var paragraph in paragraphs)
{
var match = ExplicitNarratorRegex().Match(paragraph.Trim());
if (!match.Success)
{
continue;
}
var name = CleanPovName(match.Groups["name"].Value);
if (string.IsNullOrWhiteSpace(name))
{
continue;
}
return new ChapterStructurePovSuggestion
{
CharacterName = name,
Confidence = 0.99m,
Evidence = match.Value.Trim(),
IsExplicit = true
};
}
return null;
}
[GeneratedRegex(@"^\s*(?:narrated\s+by|narrated\s+by\s*:|narrator\s*:|pov\s*:)\s*(?<name>[A-Z][\p{L}'’.-]*(?:\s+[A-Z][\p{L}'’.-]*){0,3})", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex ExplicitNarratorRegex();
private static string? CleanPovName(string? name)
{
var clean = Trim(name, 200);
if (string.IsNullOrWhiteSpace(clean))
{
return null;
}
clean = Regex.Replace(clean, @"\s*[–—-]\s*.*$", string.Empty).Trim();
clean = Regex.Replace(clean, @"\s+\d{1,2}(?:st|nd|rd|th)?\b.*$", string.Empty, RegexOptions.IgnoreCase).Trim();
clean = clean.Trim(':', '-', '–', '—', '.', ' ');
return string.IsNullOrWhiteSpace(clean) ? null : clean;
}
}
public sealed class ManuscriptStructureAnalysisRequest
{
public int ProjectID { get; init; }
public int BookID { get; init; }
public Guid PreviewID { get; init; }
public string TemporaryChapterKey { get; init; } = string.Empty;
public int ChapterNumber { get; init; }
public string SourceLabel { get; init; } = string.Empty;
public string? StoryEra { get; init; }
public string? PrimaryLocale { get; init; }
public IReadOnlyList<string> KnownCharacters { get; init; } = [];
public string ChapterText { get; init; } = string.Empty;
public IReadOnlyList<ManuscriptScanParagraphMap> ParagraphMap { get; init; } = [];
public IReadOnlyList<ManuscriptScanSourceParagraph> SourceParagraphs { get; init; } = [];
}
public sealed class ManuscriptStructureAnalysisResult
{
public string TemporaryChapterKey { get; init; } = string.Empty;
public bool Success { get; init; }
public string? ErrorMessage { get; init; }
public string? ParsedJson { get; init; }
public string? DefaultPovName { get; init; }
public decimal? DefaultPovConfidence { get; init; }
public string? DefaultPovEvidence { get; init; }
public bool MixedPov { get; init; }
public IReadOnlyList<ManuscriptStructureSceneBoundary> Scenes { get; init; } = [];
public static ManuscriptStructureAnalysisResult Succeeded(
string temporaryChapterKey,
IReadOnlyList<ManuscriptStructureSceneBoundary> scenes,
string? parsedJson,
string? defaultPovName = null,
decimal? defaultPovConfidence = null,
string? defaultPovEvidence = null,
bool mixedPov = false)
=> new()
{
TemporaryChapterKey = temporaryChapterKey,
Success = true,
Scenes = scenes,
ParsedJson = parsedJson,
DefaultPovName = defaultPovName,
DefaultPovConfidence = defaultPovConfidence,
DefaultPovEvidence = defaultPovEvidence,
MixedPov = mixedPov
};
public static ManuscriptStructureAnalysisResult Failed(string temporaryChapterKey, string errorMessage, string? parsedJson = null)
=> new() { TemporaryChapterKey = temporaryChapterKey, Success = false, ErrorMessage = errorMessage, ParsedJson = parsedJson };
}
public sealed class ManuscriptStructureSceneBoundary
{
public string TemporarySceneKey { get; init; } = string.Empty;
public string TemporaryChapterKey { get; init; } = string.Empty;
public int SceneNumberWithinChapter { get; init; }
public string Title { get; init; } = string.Empty;
public string? SuggestedTitle => SceneTitleDisplay.Clean(Title);
public int WordCount { get; init; }
public string SourceText { get; init; } = string.Empty;
public int StartParagraph { get; init; }
public int EndParagraph { get; init; }
public int WordStartPosition { get; init; }
public int WordEndPosition { get; init; }
public string? StructuralSummary { get; init; }
public string? StructuralSetting { get; init; }
public string? Reason { get; init; }
public decimal? Confidence { get; init; }
public string? SuggestedPovName { get; init; }
public decimal? SuggestedPovConfidence { get; init; }
public string? SuggestedPovEvidence { get; init; }
public bool SuggestedPovIsOverride { get; init; }
public string? ChapterParsedJson { get; init; }
public string? ExplicitNarratorName { get; init; }
}

View File

@ -1,215 +0,0 @@
using PlotLine.Data;
using PlotLine.Models;
namespace PlotLine.Services;
public interface IManuscriptTextStatisticsService
{
ManuscriptTextStatistics Calculate(string? sourceText);
ManuscriptTextStatistics Aggregate(IEnumerable<ManuscriptTextStatistics?> statistics);
Task RecalculateSceneAsync(int sceneId);
Task RecalculateChapterAsync(int chapterId);
Task RecalculateBookAsync(int bookId);
}
public sealed class ManuscriptTextStatisticsService(
IManuscriptTextStatisticsRepository repository,
IOnboardingBuildRepository manuscriptSources) : IManuscriptTextStatisticsService
{
public ManuscriptTextStatistics Calculate(string? sourceText)
{
var paragraphs = StoryIntelligenceParagraphs.Split(sourceText);
var totalWords = 0;
var dialogueWords = 0;
var proseWords = 0;
var dialogueParagraphs = 0;
var proseParagraphs = 0;
var inDialogue = false;
foreach (var paragraph in paragraphs)
{
var paragraphStartInDialogue = inDialogue;
var paragraphHasDialogue = paragraphStartInDialogue;
var paragraphHasProse = false;
var firstContentIndex = FirstNonWhitespaceIndex(paragraph);
var segmentStart = 0;
for (var index = 0; index < paragraph.Length; index++)
{
var quote = QuoteKind(paragraph[index]);
if (quote == ManuscriptQuoteKind.None)
{
continue;
}
if (inDialogue && IsContinuationOpeningQuote(quote, index, firstContentIndex))
{
AddSegment(paragraph, segmentStart, index, inDialogue, ref totalWords, ref dialogueWords, ref proseWords, ref paragraphHasDialogue, ref paragraphHasProse);
segmentStart = index + 1;
continue;
}
AddSegment(paragraph, segmentStart, index, inDialogue, ref totalWords, ref dialogueWords, ref proseWords, ref paragraphHasDialogue, ref paragraphHasProse);
if (quote == ManuscriptQuoteKind.SmartOpen)
{
inDialogue = true;
paragraphHasDialogue = true;
}
else if (quote == ManuscriptQuoteKind.SmartClose)
{
inDialogue = false;
}
else
{
inDialogue = !inDialogue;
if (inDialogue)
{
paragraphHasDialogue = true;
}
}
segmentStart = index + 1;
}
AddSegment(paragraph, segmentStart, paragraph.Length, inDialogue, ref totalWords, ref dialogueWords, ref proseWords, ref paragraphHasDialogue, ref paragraphHasProse);
if (paragraphHasDialogue)
{
dialogueParagraphs++;
}
if (paragraphHasProse)
{
proseParagraphs++;
}
}
return new ManuscriptTextStatistics
{
TotalWordCount = totalWords,
DialogueWordCount = dialogueWords,
ProseWordCount = proseWords,
TotalParagraphCount = paragraphs.Count,
DialogueParagraphCount = dialogueParagraphs,
ProseParagraphCount = proseParagraphs
};
}
public ManuscriptTextStatistics Aggregate(IEnumerable<ManuscriptTextStatistics?> statistics)
{
var rows = statistics.Where(row => row is not null).Select(row => row!).ToList();
return new ManuscriptTextStatistics
{
TotalWordCount = rows.Sum(row => row.TotalWordCount),
DialogueWordCount = rows.Sum(row => row.DialogueWordCount),
ProseWordCount = rows.Sum(row => row.ProseWordCount),
TotalParagraphCount = rows.Sum(row => row.TotalParagraphCount),
DialogueParagraphCount = rows.Sum(row => row.DialogueParagraphCount),
ProseParagraphCount = rows.Sum(row => row.ProseParagraphCount)
};
}
public async Task RecalculateSceneAsync(int sceneId)
{
var source = await repository.GetSourceBySceneAsync(sceneId);
if (source is null)
{
return;
}
await repository.SaveAsync(source.SceneID, Calculate(source.SourceText));
}
public async Task RecalculateChapterAsync(int chapterId)
{
var sources = await manuscriptSources.ListSceneManuscriptSourcesByChapterAsync(chapterId);
foreach (var source in sources)
{
await repository.SaveAsync(source.SceneID, Calculate(source.SourceText));
}
}
public async Task RecalculateBookAsync(int bookId)
{
var sources = await manuscriptSources.ListSceneManuscriptSourcesByBookAsync(bookId);
foreach (var source in sources)
{
await repository.SaveAsync(source.SceneID, Calculate(source.SourceText));
}
}
private static void AddSegment(
string paragraph,
int start,
int end,
bool isDialogue,
ref int totalWords,
ref int dialogueWords,
ref int proseWords,
ref bool paragraphHasDialogue,
ref bool paragraphHasProse)
{
if (end <= start)
{
return;
}
var words = CountWords(paragraph[start..end]);
if (words == 0)
{
return;
}
totalWords += words;
if (isDialogue)
{
dialogueWords += words;
paragraphHasDialogue = true;
}
else
{
proseWords += words;
paragraphHasProse = true;
}
}
private static int CountWords(string value)
=> string.IsNullOrWhiteSpace(value)
? 0
: value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
private static int FirstNonWhitespaceIndex(string paragraph)
{
for (var index = 0; index < paragraph.Length; index++)
{
if (!char.IsWhiteSpace(paragraph[index]))
{
return index;
}
}
return -1;
}
private static bool IsContinuationOpeningQuote(ManuscriptQuoteKind quote, int index, int firstContentIndex)
=> index == firstContentIndex
&& (quote == ManuscriptQuoteKind.SmartOpen || quote == ManuscriptQuoteKind.Straight);
private static ManuscriptQuoteKind QuoteKind(char value)
=> value switch
{
'"' => ManuscriptQuoteKind.Straight,
'\u201c' => ManuscriptQuoteKind.SmartOpen,
'\u201d' => ManuscriptQuoteKind.SmartClose,
_ => ManuscriptQuoteKind.None
};
private enum ManuscriptQuoteKind
{
None,
Straight,
SmartOpen,
SmartClose
}
}

View File

@ -1,597 +0,0 @@
using System.Data;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Dapper;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface INameIntelligenceService
{
Task<NameReferenceImportResult> EnsureReferenceDataImportedAsync(bool force = false, CancellationToken cancellationToken = default);
Task<NameReferenceImportResult> ValidatePackageAsync(CancellationToken cancellationToken = default);
Task<IReadOnlyList<NameLocale>> ListLocalesAsync(CancellationToken cancellationToken = default);
Task<NameRecognitionResult> RecogniseAsync(string rawName, string? localeCode = null, int? birthYear = null, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NameSearchResult>> SearchAsync(NameSearchFilter filter, CancellationToken cancellationToken = default);
Task<NamesLibraryViewModel?> BuildLibraryAsync(NameSearchFilter filter, CancellationToken cancellationToken = default);
}
public sealed class NameIntelligenceService(
INameIntelligenceRepository repository,
ISqlConnectionFactory connectionFactory,
IProjectRepository projects,
IBookRepository books,
ICurrentUserService currentUser,
IWebHostEnvironment environment,
ILogger<NameIntelligenceService> logger) : INameIntelligenceService
{
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
public async Task<NameReferenceImportResult> EnsureReferenceDataImportedAsync(bool force = false, CancellationToken cancellationToken = default)
{
var package = await ReadAndValidatePackageAsync(cancellationToken);
if (package.Errors.Count > 0)
{
return package.ToResult(false, false, "Name reference package validation failed.");
}
var latest = await repository.GetLatestImportAsync(package.Manifest.PackageId, package.Manifest.Version, cancellationToken);
if (!force && latest is not null && string.Equals(latest.PackageChecksum, package.Checksum, StringComparison.OrdinalIgnoreCase))
{
return package.ToResult(true, true, "Name reference package is already imported.");
}
using var connection = connectionFactory.CreateConnection();
connection.Open();
using var transaction = connection.BeginTransaction();
var importId = 0;
try
{
importId = await repository.StartImportAsync(package.Manifest.PackageId, package.Manifest.Version, package.Checksum, connection, transaction, cancellationToken);
await repository.UpsertLocalesAsync(package.LocaleTable, connection, transaction, cancellationToken);
await repository.UpsertGivenNamesAsync(package.GivenNameTable, connection, transaction, cancellationToken);
await repository.UpsertRelationshipsAsync(package.RelationshipTable, connection, transaction, cancellationToken);
foreach (var batch in package.UsageTables)
{
await repository.UpsertUsagesAsync(batch, connection, transaction, cancellationToken);
}
await repository.CompleteImportAsync(importId, true, package.LocaleRows, package.GivenNameRows, package.RelationshipRows, package.UsageRows, null, connection, transaction, cancellationToken);
transaction.Commit();
return package.ToResult(true, false, "Name reference package imported.");
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "Name reference import failed for package {PackageId} {Version}.", package.Manifest.PackageId, package.Manifest.Version);
try
{
if (importId > 0)
{
await repository.CompleteImportAsync(importId, false, 0, 0, 0, 0, ex.Message, connection, transaction, cancellationToken);
}
}
finally
{
transaction.Rollback();
}
return package.ToResult(false, false, "Name reference package import failed.", [ex.Message]);
}
}
public async Task<NameReferenceImportResult> ValidatePackageAsync(CancellationToken cancellationToken = default)
{
var package = await ReadAndValidatePackageAsync(cancellationToken);
return package.ToResult(package.Errors.Count == 0, false, package.Errors.Count == 0 ? "Name reference package validation succeeded." : "Name reference package validation failed.");
}
public Task<IReadOnlyList<NameLocale>> ListLocalesAsync(CancellationToken cancellationToken = default)
=> repository.ListLocalesAsync(cancellationToken);
public Task<NameRecognitionResult> RecogniseAsync(string rawName, string? localeCode = null, int? birthYear = null, CancellationToken cancellationToken = default)
{
var normalised = NormaliseName(rawName);
return string.IsNullOrWhiteSpace(normalised)
? Task.FromResult(new NameRecognitionResult())
: repository.RecogniseAsync(normalised, localeCode, birthYear, cancellationToken);
}
public async Task<IReadOnlyList<NameSearchResult>> SearchAsync(NameSearchFilter filter, CancellationToken cancellationToken = default)
{
if (currentUser.UserId is not int userId || filter.ProjectID <= 0)
{
return [];
}
filter.Page = Math.Max(1, filter.Page);
filter.PageSize = Math.Clamp(filter.PageSize, 1, 100);
filter.UsedScope = string.IsNullOrWhiteSpace(filter.UsedScope) ? "Project" : filter.UsedScope.Trim();
if (!string.IsNullOrWhiteSpace(filter.GenderPreference) && !filter.GenderThreshold.HasValue)
{
filter.GenderThreshold = string.Equals(filter.GenderPreference, "Mixed", StringComparison.OrdinalIgnoreCase)
? 0.65m
: 0.5m;
}
return await repository.SearchAsync(userId, filter, cancellationToken);
}
public async Task<NamesLibraryViewModel?> BuildLibraryAsync(NameSearchFilter filter, CancellationToken cancellationToken = default)
{
if (currentUser.UserId is not int userId || filter.ProjectID <= 0)
{
return null;
}
var project = await projects.GetForUserAsync(filter.ProjectID, userId);
if (project is null)
{
return null;
}
var projectBooks = await books.ListByProjectAsync(filter.ProjectID);
var locales = await ListLocalesAsync(cancellationToken);
ApplyBookDefaults(filter, projectBooks, locales);
var rows = await SearchAsync(filter, cancellationToken);
return new NamesLibraryViewModel
{
Project = project,
Filter = filter,
Locales = locales,
Books = projectBooks,
Results = rows
};
}
private static void ApplyBookDefaults(NameSearchFilter filter, IReadOnlyList<Book> projectBooks, IReadOnlyList<NameLocale> locales)
{
if (!filter.BookID.HasValue)
{
return;
}
var book = projectBooks.FirstOrDefault(item => item.BookID == filter.BookID.Value);
if (book is null)
{
return;
}
if (string.IsNullOrWhiteSpace(filter.LocaleCode)
&& !string.IsNullOrWhiteSpace(book.PrimaryLocaleCode)
&& locales.Any(locale => string.Equals(locale.LocaleCode, book.PrimaryLocaleCode, StringComparison.OrdinalIgnoreCase)))
{
filter.LocaleCode = book.PrimaryLocaleCode;
}
if (!filter.BirthYear.HasValue)
{
filter.BirthYear = BookContextOptions.RepresentativeBirthYear(book.StoryEra);
}
}
public static string NormaliseName(string? value)
=> string.IsNullOrWhiteSpace(value)
? string.Empty
: value.Trim().Normalize(NormalizationForm.FormKC).ToUpperInvariant();
private async Task<ValidatedNamePackage> ReadAndValidatePackageAsync(CancellationToken cancellationToken)
{
var root = Path.Combine(environment.ContentRootPath, NameIntelligenceReferencePackage.RelativePath);
var errors = new List<string>();
var manifestPath = Path.Combine(root, "manifest.json");
if (!File.Exists(manifestPath))
{
return ValidatedNamePackage.Empty(errors.Append($"Missing manifest: {manifestPath}").ToList());
}
var manifest = JsonSerializer.Deserialize<NamePackageManifest>(await File.ReadAllTextAsync(manifestPath, cancellationToken), JsonOptions)
?? new NamePackageManifest();
if (!string.Equals(manifest.PackageId, NameIntelligenceReferencePackage.PackageId, StringComparison.Ordinal))
{
errors.Add($"Unexpected package id '{manifest.PackageId}'.");
}
if (!string.Equals(manifest.Version, NameIntelligenceReferencePackage.Version, StringComparison.Ordinal))
{
errors.Add($"Unexpected package version '{manifest.Version}'.");
}
var checksum = ComputePackageChecksum(root, manifest);
var locales = ReadCsv(Path.Combine(root, "name_locales.csv"), ["locale_code", "display_name", "iso_country_code", "evidence_basis", "default_confidence", "sort_order", "is_active"], errors);
var names = ReadCsv(Path.Combine(root, "given_names.csv"), ["given_name_key", "display_name", "normalised_name", "name_type", "is_word_ambiguous", "is_active", "notes"], errors);
var relationships = ReadCsv(Path.Combine(root, "name_relationships.csv"), ["relationship_key", "from_given_name_key", "to_given_name_key", "relationship_type", "strength", "is_bidirectional", "is_active", "curation_source", "notes"], errors);
var usages = ReadCsv(Path.Combine(root, "name_usages.csv"), ["usage_key", "given_name_key", "locale_code", "birth_year_from", "birth_year_to", "male_weight", "female_weight", "usage_weight", "confidence", "source_type", "source_reference", "is_active", "notes"], errors);
ValidateCounts(manifest, locales.Count, names.Count, relationships.Count, usages.Count, errors);
ValidatePackageRows(manifest, locales, names, relationships, usages, errors);
return new ValidatedNamePackage(
manifest,
checksum,
BuildLocaleTable(locales),
BuildGivenNameTable(names),
BuildRelationshipTable(relationships),
BuildUsageTables(usages),
locales.Count,
names.Count,
relationships.Count,
usages.Count,
errors);
}
private static void ValidateCounts(NamePackageManifest manifest, int localeRows, int nameRows, int relationshipRows, int usageRows, List<string> errors)
{
if (manifest.Files.TryGetValue("name_locales", out var locales) && locales.Rows != localeRows) errors.Add($"Locale row count mismatch: manifest {locales.Rows}, file {localeRows}.");
if (manifest.Files.TryGetValue("given_names", out var names) && names.Rows != nameRows) errors.Add($"Given name row count mismatch: manifest {names.Rows}, file {nameRows}.");
if (manifest.Files.TryGetValue("name_relationships", out var relationships) && relationships.Rows != relationshipRows) errors.Add($"Relationship row count mismatch: manifest {relationships.Rows}, file {relationshipRows}.");
if (manifest.Files.TryGetValue("name_usages", out var usages) && usages.Rows != usageRows) errors.Add($"Usage row count mismatch: manifest {usages.Rows}, file {usageRows}.");
}
private static void ValidatePackageRows(NamePackageManifest manifest, List<Dictionary<string, string>> locales, List<Dictionary<string, string>> names, List<Dictionary<string, string>> relationships, List<Dictionary<string, string>> usages, List<string> errors)
{
var localeCodes = new HashSet<string>(StringComparer.Ordinal);
var nameKeys = new HashSet<string>(StringComparer.Ordinal);
var normalisedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var relationshipKeys = new HashSet<string>(StringComparer.Ordinal);
var usageKeys = new HashSet<string>(StringComparer.Ordinal);
var usageBands = new Dictionary<string, List<(int From, int To)>>(StringComparer.Ordinal);
var nameTypes = manifest.ValueSets.GetValueOrDefault("nameType") ?? [];
var relationshipTypes = manifest.ValueSets.GetValueOrDefault("relationshipType") ?? [];
var sourceTypes = manifest.ValueSets.GetValueOrDefault("sourceType") ?? [];
foreach (var row in locales)
{
RequireUnique(row["locale_code"], localeCodes, "locale_code", errors);
ValidateDecimal(row["default_confidence"], "default_confidence", errors);
ValidateBool(row["is_active"], "locale is_active", errors);
}
foreach (var row in names)
{
RequireUnique(row["given_name_key"], nameKeys, "given_name_key", errors);
RequireUnique(row["normalised_name"], normalisedNames, "normalised_name", errors);
RequireControlled(row["name_type"], nameTypes, "name_type", errors);
ValidateBool(row["is_word_ambiguous"], "is_word_ambiguous", errors);
ValidateBool(row["is_active"], "given name is_active", errors);
}
foreach (var row in relationships)
{
RequireUnique(row["relationship_key"], relationshipKeys, "relationship_key", errors);
RequireForeign(row["from_given_name_key"], nameKeys, "relationship from_given_name_key", errors);
RequireForeign(row["to_given_name_key"], nameKeys, "relationship to_given_name_key", errors);
if (row["from_given_name_key"] == row["to_given_name_key"]) errors.Add($"Self relationship '{row["relationship_key"]}' is not allowed.");
RequireControlled(row["relationship_type"], relationshipTypes, "relationship_type", errors);
ValidateDecimal(row["strength"], "strength", errors);
ValidateBool(row["is_bidirectional"], "is_bidirectional", errors);
ValidateBool(row["is_active"], "relationship is_active", errors);
}
foreach (var row in usages)
{
RequireUnique(row["usage_key"], usageKeys, "usage_key", errors);
RequireForeign(row["given_name_key"], nameKeys, "usage given_name_key", errors);
RequireForeign(row["locale_code"], localeCodes, "usage locale_code", errors);
var from = ValidateInt(row["birth_year_from"], "birth_year_from", errors);
var to = ValidateInt(row["birth_year_to"], "birth_year_to", errors);
if (from > to) errors.Add($"Usage '{row["usage_key"]}' has invalid year range.");
var male = ValidateDecimal(row["male_weight"], "male_weight", errors);
var female = ValidateDecimal(row["female_weight"], "female_weight", errors);
ValidateDecimal(row["usage_weight"], "usage_weight", errors);
ValidateDecimal(row["confidence"], "confidence", errors);
if (Math.Abs((male + female) - 1m) > 0.0002m) errors.Add($"Usage '{row["usage_key"]}' gender weights do not total 1.");
RequireControlled(row["source_type"], sourceTypes, "source_type", errors);
ValidateBool(row["is_active"], "usage is_active", errors);
var group = row["given_name_key"] + "|" + row["locale_code"];
if (!usageBands.TryGetValue(group, out var bands))
{
bands = [];
usageBands[group] = bands;
}
bands.Add((from, to));
}
foreach (var pair in usageBands)
{
var bands = pair.Value.OrderBy(item => item.From).ToList();
if (bands.Count != 5 || bands[0].From != 1900 || bands[^1].To != 2026)
{
errors.Add($"Usage bands for {pair.Key} must contain five bands covering 1900-2026.");
continue;
}
for (var i = 1; i < bands.Count; i++)
{
if (bands[i].From != bands[i - 1].To + 1)
{
errors.Add($"Usage bands for {pair.Key} are not contiguous.");
break;
}
}
}
}
private static List<Dictionary<string, string>> ReadCsv(string path, IReadOnlyList<string> expectedHeaders, List<string> errors)
{
if (!File.Exists(path))
{
errors.Add($"Missing CSV file: {path}");
return [];
}
using var reader = new StreamReader(path, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
var headerLine = reader.ReadLine();
var headers = ParseCsvLine(headerLine ?? string.Empty);
if (!headers.SequenceEqual(expectedHeaders, StringComparer.Ordinal))
{
errors.Add($"Unexpected headers in {Path.GetFileName(path)}.");
return [];
}
var rows = new List<Dictionary<string, string>>();
string? line;
while ((line = reader.ReadLine()) is not null)
{
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
var values = ParseCsvLine(line);
if (values.Count != headers.Count)
{
errors.Add($"Invalid CSV column count in {Path.GetFileName(path)} at row {rows.Count + 2}.");
continue;
}
rows.Add(headers.Zip(values, (key, value) => new { key, value }).ToDictionary(item => item.key, item => item.value, StringComparer.Ordinal));
}
return rows;
}
private static List<string> ParseCsvLine(string line)
{
var values = new List<string>();
var builder = new StringBuilder();
var quoted = false;
for (var i = 0; i < line.Length; i++)
{
var ch = line[i];
if (quoted)
{
if (ch == '"' && i + 1 < line.Length && line[i + 1] == '"')
{
builder.Append('"');
i++;
}
else if (ch == '"')
{
quoted = false;
}
else
{
builder.Append(ch);
}
}
else if (ch == ',')
{
values.Add(builder.ToString());
builder.Clear();
}
else if (ch == '"')
{
quoted = true;
}
else
{
builder.Append(ch);
}
}
values.Add(builder.ToString());
return values;
}
private static DataTable BuildLocaleTable(IEnumerable<Dictionary<string, string>> rows)
{
var table = new DataTable();
table.Columns.Add("LocaleCode", typeof(string));
table.Columns.Add("DisplayName", typeof(string));
table.Columns.Add("IsoCountryCode", typeof(string));
table.Columns.Add("EvidenceBasis", typeof(string));
table.Columns.Add("DefaultConfidence", typeof(decimal));
table.Columns.Add("SortOrder", typeof(int));
table.Columns.Add("IsActive", typeof(bool));
foreach (var row in rows)
{
table.Rows.Add(row["locale_code"], row["display_name"], row["iso_country_code"], row["evidence_basis"], Decimal(row["default_confidence"]), Int(row["sort_order"]), Bool(row["is_active"]));
}
return table;
}
private static DataTable BuildGivenNameTable(IEnumerable<Dictionary<string, string>> rows)
{
var table = new DataTable();
table.Columns.Add("GivenNameKey", typeof(string));
table.Columns.Add("DisplayName", typeof(string));
table.Columns.Add("NormalisedName", typeof(string));
table.Columns.Add("NameType", typeof(string));
table.Columns.Add("IsWordAmbiguous", typeof(bool));
table.Columns.Add("IsActive", typeof(bool));
table.Columns.Add("Notes", typeof(string));
foreach (var row in rows)
{
table.Rows.Add(row["given_name_key"], row["display_name"], row["normalised_name"], row["name_type"], Bool(row["is_word_ambiguous"]), Bool(row["is_active"]), DbNull(row["notes"]));
}
return table;
}
private static DataTable BuildRelationshipTable(IEnumerable<Dictionary<string, string>> rows)
{
var table = new DataTable();
table.Columns.Add("RelationshipKey", typeof(string));
table.Columns.Add("FromGivenNameKey", typeof(string));
table.Columns.Add("ToGivenNameKey", typeof(string));
table.Columns.Add("RelationshipType", typeof(string));
table.Columns.Add("Strength", typeof(decimal));
table.Columns.Add("IsBidirectional", typeof(bool));
table.Columns.Add("IsActive", typeof(bool));
table.Columns.Add("CurationSource", typeof(string));
table.Columns.Add("Notes", typeof(string));
foreach (var row in rows)
{
table.Rows.Add(row["relationship_key"], row["from_given_name_key"], row["to_given_name_key"], row["relationship_type"], Decimal(row["strength"]), Bool(row["is_bidirectional"]), Bool(row["is_active"]), DbNull(row["curation_source"]), DbNull(row["notes"]));
}
return table;
}
private static IReadOnlyList<DataTable> BuildUsageTables(IEnumerable<Dictionary<string, string>> rows)
{
var tables = new List<DataTable>();
var table = NewUsageTable();
foreach (var row in rows)
{
if (table.Rows.Count >= 5000)
{
tables.Add(table);
table = NewUsageTable();
}
table.Rows.Add(row["usage_key"], row["given_name_key"], row["locale_code"], Int(row["birth_year_from"]), Int(row["birth_year_to"]), Decimal(row["male_weight"]), Decimal(row["female_weight"]), Decimal(row["usage_weight"]), Decimal(row["confidence"]), row["source_type"], DbNull(row["source_reference"]), Bool(row["is_active"]), DbNull(row["notes"]));
}
if (table.Rows.Count > 0)
{
tables.Add(table);
}
return tables;
}
private static DataTable NewUsageTable()
{
var table = new DataTable();
table.Columns.Add("UsageKey", typeof(string));
table.Columns.Add("GivenNameKey", typeof(string));
table.Columns.Add("LocaleCode", typeof(string));
table.Columns.Add("BirthYearFrom", typeof(int));
table.Columns.Add("BirthYearTo", typeof(int));
table.Columns.Add("MaleWeight", typeof(decimal));
table.Columns.Add("FemaleWeight", typeof(decimal));
table.Columns.Add("UsageWeight", typeof(decimal));
table.Columns.Add("Confidence", typeof(decimal));
table.Columns.Add("SourceType", typeof(string));
table.Columns.Add("SourceReference", typeof(string));
table.Columns.Add("IsActive", typeof(bool));
table.Columns.Add("Notes", typeof(string));
return table;
}
private static string ComputePackageChecksum(string root, NamePackageManifest manifest)
{
using var hash = SHA256.Create();
foreach (var fileName in manifest.ImportOrder)
{
var bytes = File.ReadAllBytes(Path.Combine(root, fileName));
hash.TransformBlock(bytes, 0, bytes.Length, null, 0);
}
hash.TransformFinalBlock([], 0, 0);
return Convert.ToHexString(hash.Hash ?? []);
}
private static void RequireUnique(string value, HashSet<string> values, string label, List<string> errors)
{
if (string.IsNullOrWhiteSpace(value) || !values.Add(value))
{
errors.Add($"Duplicate or empty {label}: '{value}'.");
}
}
private static void RequireForeign(string value, HashSet<string> values, string label, List<string> errors)
{
if (!values.Contains(value))
{
errors.Add($"Unknown {label}: '{value}'.");
}
}
private static void RequireControlled(string value, IReadOnlyCollection<string> allowed, string label, List<string> errors)
{
if (!allowed.Contains(value, StringComparer.Ordinal))
{
errors.Add($"Invalid {label}: '{value}'.");
}
}
private static int ValidateInt(string value, string label, List<string> errors)
{
if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result))
{
return result;
}
errors.Add($"Invalid integer {label}: '{value}'.");
return 0;
}
private static decimal ValidateDecimal(string value, string label, List<string> errors)
{
if (decimal.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out var result) && result >= 0 && result <= 1)
{
return result;
}
errors.Add($"Invalid decimal {label}: '{value}'.");
return 0;
}
private static void ValidateBool(string value, string label, List<string> errors)
{
if (!bool.TryParse(value, out _))
{
errors.Add($"Invalid boolean {label}: '{value}'.");
}
}
private static int Int(string value) => int.Parse(value, CultureInfo.InvariantCulture);
private static decimal Decimal(string value) => decimal.Parse(value, CultureInfo.InvariantCulture);
private static bool Bool(string value) => bool.Parse(value);
private static object DbNull(string value) => string.IsNullOrWhiteSpace(value) ? DBNull.Value : value;
private sealed record ValidatedNamePackage(
NamePackageManifest Manifest,
string Checksum,
DataTable LocaleTable,
DataTable GivenNameTable,
DataTable RelationshipTable,
IReadOnlyList<DataTable> UsageTables,
int LocaleRows,
int GivenNameRows,
int RelationshipRows,
int UsageRows,
IReadOnlyList<string> Errors)
{
public static ValidatedNamePackage Empty(IReadOnlyList<string> errors) => new(new(), string.Empty, new(), new(), new(), [], 0, 0, 0, 0, errors);
public NameReferenceImportResult ToResult(bool succeeded, bool alreadyCurrent, string message, IReadOnlyList<string>? extraErrors = null)
=> new(succeeded, alreadyCurrent, message, Manifest.PackageId, Manifest.Version, LocaleRows, GivenNameRows, RelationshipRows, UsageRows, Errors.Concat(extraErrors ?? []).ToList());
}
private sealed class NamePackageManifest
{
public string PackageId { get; set; } = string.Empty;
public string Version { get; set; } = string.Empty;
public Dictionary<string, NamePackageFile> Files { get; set; } = [];
public Dictionary<string, string[]> ValueSets { get; set; } = [];
public string[] ImportOrder { get; set; } = [];
}
private sealed class NamePackageFile
{
public string File { get; set; } = string.Empty;
public int Rows { get; set; }
}
}

File diff suppressed because it is too large Load Diff

View File

@ -24,11 +24,8 @@ public interface IOnboardingStoryIntelligenceService
Task<StoryIntelligenceRelationshipImportResultViewModel?> GetRelationshipImportResultAsync(Guid batchId);
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportKnowledgeAsync(Guid batchId, StoryIntelligenceKnowledgeImportForm form);
Task<StoryIntelligenceKnowledgeImportResultViewModel?> GetKnowledgeImportResultAsync(Guid batchId);
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportNarrativeAsync(Guid batchId, StoryIntelligenceNarrativeImportForm form);
Task<StoryIntelligenceNarrativeImportResultViewModel?> GetNarrativeImportResultAsync(Guid batchId);
Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId);
Task<OnboardingStoryIntelligenceResumeTarget?> ResumeBookAsync(int bookId);
Task<OnboardingStoryIntelligenceBatch?> StartBookAsync(int bookId);
}
public sealed class OnboardingStoryIntelligenceService(
@ -42,23 +39,16 @@ public sealed class OnboardingStoryIntelligenceService(
IStoryIntelligenceAssetImportService assetImport,
IStoryIntelligenceRelationshipImportService relationshipImport,
IStoryIntelligenceKnowledgeImportService knowledgeImport,
IStoryIntelligenceNarrativeReviewService narrativeReview,
IStoryIntelligenceSourceRepository sources,
IStoryIntelligencePipelineStateService pipelineState,
IStoryMemoryRepository storyMemory,
IBookRepository books,
IChapterRepository chapters,
ICharacterRepository characters,
IStoryIntelligenceClient client,
IOnboardingStoryIntelligenceBatchStore batchStore,
IStoryIntelligenceProgressNotifier notifier,
ICurrentUserService currentUser,
IStoryIntelligenceEntitlementService entitlement,
ILogger<OnboardingStoryIntelligenceService> logger) : IOnboardingStoryIntelligenceService
{
private const string PromptVersionsSummary = "Chapter=Chapter-Structure-Prompt-V2; Scene=Scene-Prompt-V2";
private const string SourceType = "OnboardingWordCompanionChapter";
private const string BookSourceType = "BookCanonicalSceneSource";
public async Task<StoryIntelligenceOverviewViewModel> GetOverviewAsync()
{
@ -76,9 +66,9 @@ public sealed class OnboardingStoryIntelligenceService(
var included = IncludedChapters(preview, review);
var existingBatch = await batchStore.GetLatestForPreviewAsync(userId, preview.PreviewID);
var missingTextCount = included.Count(chapter => string.IsNullOrWhiteSpace(chapter.Preview.ChapterText));
const int storySummaryStep = 10;
const int storySummaryStep = 9;
const int storyContextStep = 10;
const int totalWizardSteps = 10;
const int totalWizardSteps = 11;
return new StoryIntelligenceOverviewViewModel
{
PreviewID = preview.PreviewID,
@ -103,7 +93,6 @@ public sealed class OnboardingStoryIntelligenceService(
public async Task<OnboardingStoryIntelligenceBatch?> StartAsync(string? appearancePreference = null, string? primaryAgeGroup = null, string? storyEra = null, string? storyLocation = null)
{
var userId = RequireUserId();
await entitlement.EnsureCanQueueAsync(userId);
var context = await GetContextAsync(userId);
if (context is null)
{
@ -111,17 +100,14 @@ public sealed class OnboardingStoryIntelligenceService(
}
var (state, preview, review, build, wizard) = context.Value;
var book = await books.GetAsync(preview.BookID);
var existing = await batchStore.GetLatestForPreviewAsync(userId, preview.PreviewID);
if (existing is not null)
{
return existing;
}
if (build is null || build.SceneMappings.Count == 0)
{
throw new InvalidOperationException("Import the manuscript structure before starting Story Intelligence.");
}
build ??= await onboarding.BuildApprovedStructureAsync(preview.PreviewID)
?? throw new InvalidOperationException("The approved chapters could not be prepared for analysis.");
var chapterIds = build.ChapterMappings.ToDictionary(chapter => chapter.TemporaryChapterKey, chapter => chapter.ChapterID, StringComparer.Ordinal);
var included = IncludedChapters(preview, review);
@ -130,12 +116,17 @@ public sealed class OnboardingStoryIntelligenceService(
throw new InvalidOperationException("Choose at least one chapter before analysing the manuscript.");
}
if (included.Any(chapter => string.IsNullOrWhiteSpace(chapter.Preview.ChapterText)))
{
throw new InvalidOperationException("The Word Companion scan did not include chapter text. Scan the manuscript again before analysing it.");
}
var clientStatus = client.GetConfigurationStatus();
var modelSummary = StoryIntelligenceOptions.BuildModelSummary(
clientStatus.ChapterStructureModel,
clientStatus.SceneIntelligenceModel);
var batchItems = new List<OnboardingStoryIntelligenceBatchItem>();
var knownCharactersJson = await BuildCanonicalKnownCharacterContextJsonAsync(preview, review, build);
var knownCharactersJson = BuildKnownCharacterContextJson(preview, review);
foreach (var chapter in included)
{
@ -149,20 +140,8 @@ public sealed class OnboardingStoryIntelligenceService(
throw new InvalidOperationException($"PlotDirector could not prepare chapter '{chapter.Decision.Title}' for analysis.");
}
var durableSource = await sources.GetChapterSourceAsync(chapterId);
var text = durableSource?.SourceText?.Trim();
if (string.IsNullOrWhiteSpace(text))
{
text = chapter.Preview.ChapterText?.Trim();
}
if (string.IsNullOrWhiteSpace(text))
{
throw new InvalidOperationException($"PlotDirector does not have persisted scene source text for chapter '{chapter.Decision.Title}'.");
}
var text = chapter.Preview.ChapterText!.Trim();
var paragraphs = StoryIntelligenceParagraphs.Split(text);
var canonicalChapterTitle = CanonicalChapterTitle(await chapters.GetAsync(chapterId), chapter.Decision.ChapterNumber);
var runId = await runs.QueueAdminTextAsync(new StoryIntelligenceRunQueueRequest
{
UserID = userId,
@ -171,11 +150,11 @@ public sealed class OnboardingStoryIntelligenceService(
ChapterID = chapterId,
ChapterNumber = chapter.Decision.ChapterNumber,
SourceType = SourceType,
SourceLabel = $"{wizard.SelectedBookTitle}: {canonicalChapterTitle}",
SourceLabel = $"{wizard.SelectedBookTitle}: {chapter.Decision.Title}",
ChapterText = text,
SourceFileName = preview.DocumentTitle,
SourceWordCount = durableSource?.SourceWordCount ?? CountWords(text),
SourceCharacterCount = durableSource?.SourceCharacterCount ?? text.Length,
SourceWordCount = CountWords(text),
SourceCharacterCount = text.Length,
SourceParagraphCount = paragraphs.Count,
SourceChapterCount = 1,
Model = modelSummary,
@ -187,7 +166,7 @@ public sealed class OnboardingStoryIntelligenceService(
{
TemporaryChapterKey = chapter.Preview.TemporaryChapterKey,
ChapterNumber = chapter.Decision.ChapterNumber,
ChapterTitle = canonicalChapterTitle,
ChapterTitle = chapter.Decision.Title,
ChapterID = chapterId,
RunID = runId
});
@ -222,12 +201,8 @@ public sealed class OnboardingStoryIntelligenceService(
BookTitle = wizard.SelectedBookTitle,
AppearancePreference = NormaliseAppearancePreference(appearancePreference),
PrimaryAgeGroup = CleanContext(primaryAgeGroup),
StoryEra = CleanContext(storyEra) is { Length: > 0 } suppliedEra
? suppliedEra
: CleanContext(BookContextOptions.StoryEraLabel(book?.StoryEra)),
StoryLocation = CleanContext(storyLocation) is { Length: > 0 } suppliedLocation
? suppliedLocation
: CleanContext(BookContextOptions.PrimaryLocaleLabel(book?.PrimaryLocaleCode)),
StoryEra = CleanContext(storyEra),
StoryLocation = CleanContext(storyLocation),
Items = batchItems
};
@ -253,107 +228,6 @@ public sealed class OnboardingStoryIntelligenceService(
return batch;
}
public async Task<OnboardingStoryIntelligenceBatch?> StartBookAsync(int bookId)
{
var userId = RequireUserId();
await entitlement.EnsureCanQueueAsync(userId);
var book = await books.GetAsync(bookId);
if (book is null)
{
return null;
}
var chapterRows = (await chapters.ListByBookAsync(bookId))
.Where(chapter => !chapter.IsArchived)
.OrderBy(chapter => chapter.SortOrder)
.ThenBy(chapter => chapter.ChapterNumber)
.ThenBy(chapter => chapter.ChapterID)
.ToList();
if (chapterRows.Count == 0)
{
throw new InvalidOperationException("Add or import chapters before analysing this book.");
}
var clientStatus = client.GetConfigurationStatus();
var modelSummary = StoryIntelligenceOptions.BuildModelSummary(
clientStatus.ChapterStructureModel,
clientStatus.SceneIntelligenceModel);
var knownCharactersJson = await BuildCanonicalKnownCharacterContextJsonAsync(book.ProjectID, new Dictionary<int, IReadOnlyList<int>>());
var batchItems = new List<OnboardingStoryIntelligenceBatchItem>();
foreach (var chapter in chapterRows)
{
var durableSource = await sources.GetChapterSourceAsync(chapter.ChapterID);
var text = durableSource?.SourceText?.Trim();
if (string.IsNullOrWhiteSpace(text))
{
continue;
}
var paragraphs = StoryIntelligenceParagraphs.Split(text);
var runId = await runs.QueueAdminTextAsync(new StoryIntelligenceRunQueueRequest
{
UserID = userId,
ProjectID = book.ProjectID,
BookID = book.BookID,
ChapterID = chapter.ChapterID,
ChapterNumber = chapter.ChapterNumber,
SourceType = BookSourceType,
SourceLabel = $"{book.BookDisplayTitle}: {CanonicalChapterTitle(chapter, chapter.ChapterNumber)}",
ChapterText = text,
SourceFileName = durableSource?.BookTitle ?? book.BookDisplayTitle,
SourceWordCount = durableSource?.SourceWordCount ?? CountWords(text),
SourceCharacterCount = durableSource?.SourceCharacterCount ?? text.Length,
SourceParagraphCount = paragraphs.Count,
SourceChapterCount = 1,
Model = modelSummary,
PromptVersionsSummary = PromptVersionsSummary,
KnownCharactersJson = knownCharactersJson
});
batchItems.Add(new OnboardingStoryIntelligenceBatchItem
{
TemporaryChapterKey = $"book-{book.BookID}-chapter-{chapter.ChapterID}",
ChapterNumber = Convert.ToInt32(chapter.ChapterNumber),
ChapterTitle = CanonicalChapterTitle(chapter, chapter.ChapterNumber),
ChapterID = chapter.ChapterID,
RunID = runId
});
await pipelineState.RecordChapterAnalysisQueuedAsync(book.ProjectID, book.BookID, runId);
}
if (batchItems.Count == 0)
{
throw new InvalidOperationException("PlotDirector could not find saved manuscript text for this book. Complete the core manuscript import before analysing it.");
}
var batch = new OnboardingStoryIntelligenceBatch
{
UserID = userId,
OnboardingID = 0,
PreviewID = Guid.Empty,
ProjectID = book.ProjectID,
BookID = book.BookID,
ProjectName = string.Empty,
BookTitle = book.BookDisplayTitle,
StoryEra = CleanContext(BookContextOptions.StoryEraLabel(book.StoryEra)),
StoryLocation = CleanContext(BookContextOptions.PrimaryLocaleLabel(book.PrimaryLocaleCode)),
Items = batchItems
};
await batchStore.SaveAsync(batch);
logger.LogInformation(
"Queued book Story Intelligence batch {BatchID}. UserID={UserID} ProjectID={ProjectID} BookID={BookID} Chapters={ChapterCount}",
batch.BatchID,
userId,
book.ProjectID,
book.BookID,
batchItems.Count);
return batch;
}
private static string NormaliseAppearancePreference(string? value)
=> value?.Trim() switch
{
@ -372,106 +246,6 @@ public sealed class OnboardingStoryIntelligenceService(
return string.IsNullOrWhiteSpace(clean) ? string.Empty : clean[..Math.Min(clean.Length, 160)];
}
private async Task<string?> BuildCanonicalKnownCharacterContextJsonAsync(
ManuscriptScanPreview preview,
ManuscriptScanReviewDecision review,
OnboardingManuscriptBuildResult build)
{
if (build.SceneMappings.Count == 0)
{
return BuildKnownCharacterContextJson(preview, review);
}
var allCharacters = (await characters.ListCharactersAsync(preview.ProjectID))
.Where(character => !character.IsArchived)
.OrderBy(character => character.CharacterName, StringComparer.OrdinalIgnoreCase)
.ToList();
if (allCharacters.Count == 0)
{
return BuildKnownCharacterContextJson(preview, review);
}
var sceneIds = build.SceneMappings.Select(mapping => mapping.SceneID).Distinct().ToList();
var sceneIdsByCharacter = new Dictionary<int, HashSet<int>>();
foreach (var sceneId in sceneIds)
{
foreach (var sceneCharacter in await characters.ListSceneCharactersAsync(sceneId))
{
if (!sceneIdsByCharacter.TryGetValue(sceneCharacter.CharacterID, out var mappedScenes))
{
mappedScenes = [];
sceneIdsByCharacter[sceneCharacter.CharacterID] = mappedScenes;
}
mappedScenes.Add(sceneId);
}
}
var context = new List<StoryIntelligenceKnownCharacterContext>();
foreach (var character in allCharacters)
{
var aliases = (await characters.ListAliasesAsync(character.CharacterID))
.Select(alias => alias.Alias)
.Where(alias => !string.IsNullOrWhiteSpace(alias))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(alias => alias, StringComparer.OrdinalIgnoreCase)
.ToList();
var mappedScenes = sceneIdsByCharacter.GetValueOrDefault(character.CharacterID)?.OrderBy(id => id).ToList() ?? [];
context.Add(new StoryIntelligenceKnownCharacterContext
{
CanonicalName = character.CharacterName,
Aliases = aliases,
SexHint = CharacterSexHintService.Normalise(character.Sex, character.CharacterName),
ExistingCharacterID = character.CharacterID,
CandidateKeys = [],
SceneIDs = mappedScenes,
MentionCount = mappedScenes.Count,
Confidence = mappedScenes.Count > 0 ? 1m : 0.8m,
Source = "CanonicalCoreImport"
});
}
return JsonSerializer.Serialize(context, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
}
private async Task<string?> BuildCanonicalKnownCharacterContextJsonAsync(int projectId, IReadOnlyDictionary<int, IReadOnlyList<int>> sceneIdsByCharacter)
{
var allCharacters = (await characters.ListCharactersAsync(projectId))
.Where(character => !character.IsArchived)
.OrderBy(character => character.CharacterName, StringComparer.OrdinalIgnoreCase)
.ToList();
if (allCharacters.Count == 0)
{
return null;
}
var context = new List<StoryIntelligenceKnownCharacterContext>();
foreach (var character in allCharacters)
{
var aliases = (await characters.ListAliasesAsync(character.CharacterID))
.Select(alias => alias.Alias)
.Where(alias => !string.IsNullOrWhiteSpace(alias))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(alias => alias, StringComparer.OrdinalIgnoreCase)
.ToList();
var mappedScenes = sceneIdsByCharacter.GetValueOrDefault(character.CharacterID)?.OrderBy(id => id).ToList() ?? [];
context.Add(new StoryIntelligenceKnownCharacterContext
{
CanonicalName = character.CharacterName,
Aliases = aliases,
SexHint = CharacterSexHintService.Normalise(character.Sex, character.CharacterName),
ExistingCharacterID = character.CharacterID,
CandidateKeys = [],
SceneIDs = mappedScenes,
MentionCount = mappedScenes.Count,
Confidence = mappedScenes.Count > 0 ? 1m : 0.8m,
Source = "CanonicalBook"
});
}
return JsonSerializer.Serialize(context, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
}
public static string? BuildKnownCharacterContextJson(ManuscriptScanPreview preview, ManuscriptScanReviewDecision review)
{
var candidates = preview.CharacterCandidates.ToDictionary(candidate => candidate.TemporaryCharacterKey, StringComparer.Ordinal);
@ -589,17 +363,6 @@ public sealed class OnboardingStoryIntelligenceService(
? await commits.BuildConfirmationAsync(item.RunID)
: null;
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
var canonicalScenes = item.ChapterID > 0
? await runs.ListChapterScenesAsync(item.ChapterID)
: [];
var currentScene = CurrentCanonicalScene(run, canonicalScenes);
var currentSceneNumber = currentScene is null
? CurrentSceneNumber(run)
: Convert.ToInt32(currentScene.SceneNumber);
var currentSceneDisplayTitle = currentScene is null
? (currentSceneNumber.HasValue ? SceneTitleDisplay.Format(currentSceneNumber.Value, null) : null)
: SceneTitleDisplay.Format(currentScene.SceneNumber, currentScene.SceneTitle);
var canonicalSceneSummary = CanonicalSceneSummary(currentScene);
chapters.Add(new StoryIntelligenceOnboardingChapterViewModel
{
@ -622,10 +385,7 @@ public sealed class OnboardingStoryIntelligenceService(
EstimatedCostGBP = run.EstimatedCostGBP,
FailureStage = run.FailureStage,
ErrorMessage = run.ErrorMessage,
CurrentSceneID = currentScene?.SceneID,
CurrentSceneNumber = currentSceneNumber,
CurrentSceneDisplayTitle = currentSceneDisplayTitle,
LatestSceneSummary = currentScene is null ? LatestSceneSummary(sceneResults) : canonicalSceneSummary,
LatestSceneSummary = LatestSceneSummary(sceneResults),
HasCompletedCommit = commit is not null && string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase),
CanCommit = confirmation?.CanCommit == true,
ScenesCreated = commit?.ScenesCreated ?? 0,
@ -643,7 +403,6 @@ public sealed class OnboardingStoryIntelligenceService(
var assetReview = await assetImport.BuildReviewAsync(batch);
var relationshipReview = await relationshipImport.BuildReviewAsync(batch);
var knowledgeReview = await knowledgeImport.BuildReviewAsync(batch);
var narrative = await narrativeReview.GetReviewAsync(batch);
var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID);
return new StoryIntelligenceProgressViewModel
{
@ -660,7 +419,6 @@ public sealed class OnboardingStoryIntelligenceService(
AssetReview = assetReview,
RelationshipReview = relationshipReview,
KnowledgeReview = knowledgeReview,
NarrativeReview = narrative,
PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel
{
ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete),
@ -679,12 +437,7 @@ public sealed class OnboardingStoryIntelligenceService(
RelationshipStageComplete = batch.RelationshipStageComplete || relationshipReview.IsComplete,
KnowledgeIdentified = knowledgeReview.Candidates.Count + batch.KnowledgeDecisions.Count,
KnowledgeCreatedOrLinked = batch.KnowledgeDecisions.Count(decision => decision.CreatedOrLinked),
KnowledgeStageComplete = batch.KnowledgeStageComplete || knowledgeReview.IsComplete,
NarrativeCandidatesIdentified = narrative.Candidates.Count + (batch.LastNarrativeImportResult?.TotalReviewed ?? 0),
NarrativeCandidatesApplied = (batch.LastNarrativeImportResult?.PlotLinesCreated ?? 0)
+ (batch.LastNarrativeImportResult?.PlotLinesMerged ?? 0)
+ (batch.LastNarrativeImportResult?.ThreadsCreated ?? 0),
NarrativeStageComplete = batch.NarrativeStageComplete || narrative.IsComplete
KnowledgeStageComplete = batch.KnowledgeStageComplete || knowledgeReview.IsComplete
}
};
}
@ -918,25 +671,6 @@ public sealed class OnboardingStoryIntelligenceService(
};
}
public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportNarrativeAsync(Guid batchId, StoryIntelligenceNarrativeImportForm form)
{
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
if (batch is null)
{
return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." });
}
var result = await narrativeReview.ImportAsync(batch, form, RequireUserId());
await batchStore.SaveAsync(batch);
return (await GetProgressAsync(batchId), result);
}
public async Task<StoryIntelligenceNarrativeImportResultViewModel?> GetNarrativeImportResultAsync(Guid batchId)
{
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
return batch is null ? null : await narrativeReview.GetResultAsync(batch);
}
public async Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId)
{
var progress = await GetProgressAsync(batchId);
@ -975,7 +709,6 @@ public sealed class OnboardingStoryIntelligenceService(
return null;
}
var chapterLookup = (await chapters.ListByBookAsync(bookId)).ToDictionary(chapter => chapter.ChapterID);
var committedRuns = (await pipelineState.ListCommittedRunsByBookAsync(bookId, userId))
.OrderBy(run => run.ChapterNumber)
.ThenBy(run => run.StoryIntelligenceRunID)
@ -983,7 +716,7 @@ public sealed class OnboardingStoryIntelligenceService(
{
TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID}",
ChapterNumber = Convert.ToInt32(run.ChapterNumber),
ChapterTitle = CanonicalChapterTitle(chapterLookup.GetValueOrDefault(run.ChapterID), run.ChapterNumber),
ChapterTitle = run.ChapterTitle,
ChapterID = run.ChapterID,
RunID = run.StoryIntelligenceRunID
})
@ -998,7 +731,7 @@ public sealed class OnboardingStoryIntelligenceService(
{
TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID ?? run.StoryIntelligenceRunID}",
ChapterNumber = Convert.ToInt32(run.ChapterNumber ?? 0),
ChapterTitle = CanonicalChapterTitle(run.ChapterID.HasValue ? chapterLookup.GetValueOrDefault(run.ChapterID.Value) : null, run.ChapterNumber),
ChapterTitle = FirstConfigured(run.SourceFileName, run.CurrentMessage, run.ChapterNumber.HasValue ? $"Chapter {run.ChapterNumber:0.##}" : $"Run {run.StoryIntelligenceRunID:N0}")!,
ChapterID = run.ChapterID ?? 0,
RunID = run.StoryIntelligenceRunID
})
@ -1030,8 +763,6 @@ public sealed class OnboardingStoryIntelligenceService(
or StoryIntelligencePipelineStages.RelationshipImport
or StoryIntelligencePipelineStages.KnowledgeReview
or StoryIntelligencePipelineStages.KnowledgeImport
or StoryIntelligencePipelineStages.NarrativeReview
or StoryIntelligencePipelineStages.NarrativeImport
or StoryIntelligencePipelineStages.Complete;
var locationStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.LocationImport)
|| state.CurrentStage is StoryIntelligencePipelineStages.AssetReview
@ -1040,39 +771,29 @@ public sealed class OnboardingStoryIntelligenceService(
or StoryIntelligencePipelineStages.RelationshipImport
or StoryIntelligencePipelineStages.KnowledgeReview
or StoryIntelligencePipelineStages.KnowledgeImport
or StoryIntelligencePipelineStages.NarrativeReview
or StoryIntelligencePipelineStages.NarrativeImport
or StoryIntelligencePipelineStages.Complete;
var assetStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.AssetImport)
|| state.CurrentStage is StoryIntelligencePipelineStages.RelationshipReview
or StoryIntelligencePipelineStages.RelationshipImport
or StoryIntelligencePipelineStages.KnowledgeReview
or StoryIntelligencePipelineStages.KnowledgeImport
or StoryIntelligencePipelineStages.NarrativeReview
or StoryIntelligencePipelineStages.NarrativeImport
or StoryIntelligencePipelineStages.Complete;
var relationshipStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.RelationshipImport)
|| state.CurrentStage is StoryIntelligencePipelineStages.KnowledgeReview
or StoryIntelligencePipelineStages.KnowledgeImport
or StoryIntelligencePipelineStages.NarrativeReview
or StoryIntelligencePipelineStages.NarrativeImport
or StoryIntelligencePipelineStages.Complete;
var knowledgeStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.KnowledgeImport);
var narrativeStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.NarrativeImport);
batch.CharacterStageComplete = characterStageComplete;
batch.LocationStageComplete = locationStageComplete;
batch.AssetStageComplete = assetStageComplete;
batch.RelationshipStageComplete = relationshipStageComplete;
batch.KnowledgeStageComplete = knowledgeStageComplete;
batch.NarrativeStageComplete = narrativeStageComplete;
await batchStore.SaveAsync(batch);
var route = narrativeStageComplete
var route = knowledgeStageComplete
? StoryIntelligenceResumeRoutes.Complete
: knowledgeStageComplete
? StoryIntelligenceResumeRoutes.Narrative
: relationshipStageComplete
? StoryIntelligenceResumeRoutes.Knowledge
: assetStageComplete
@ -1087,8 +808,6 @@ public sealed class OnboardingStoryIntelligenceService(
StoryIntelligencePipelineStages.RelationshipImport => StoryIntelligenceResumeRoutes.Relationships,
StoryIntelligencePipelineStages.KnowledgeReview => StoryIntelligenceResumeRoutes.Knowledge,
StoryIntelligencePipelineStages.KnowledgeImport => StoryIntelligenceResumeRoutes.Knowledge,
StoryIntelligencePipelineStages.NarrativeReview => StoryIntelligenceResumeRoutes.Narrative,
StoryIntelligencePipelineStages.NarrativeImport => StoryIntelligenceResumeRoutes.Narrative,
StoryIntelligencePipelineStages.CharacterReview => StoryIntelligenceResumeRoutes.Characters,
StoryIntelligencePipelineStages.CharacterImport => StoryIntelligenceResumeRoutes.Characters,
StoryIntelligencePipelineStages.SceneReview => StoryIntelligenceResumeRoutes.SceneReview,
@ -1117,7 +836,7 @@ public sealed class OnboardingStoryIntelligenceService(
}
var review = await scanStore.GetReviewAsync(userId, preview.PreviewID);
if (review is null || !string.Equals(review.Status, ManuscriptScanReviewStatuses.CoreImportComplete, StringComparison.Ordinal))
if (review is null || !string.Equals(review.Status, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal))
{
return null;
}
@ -1130,19 +849,6 @@ public sealed class OnboardingStoryIntelligenceService(
private int RequireUserId()
=> currentUser.UserId ?? throw new InvalidOperationException("Sign in before using Story Intelligence.");
private static string CanonicalChapterTitle(Chapter? chapter, decimal? chapterNumber)
{
var number = chapterNumber ?? chapter?.ChapterNumber;
var fallback = number.HasValue && number.Value > 0 ? $"Chapter {number.Value:0.##}" : "Chapter";
var title = CleanContext(chapter?.ChapterTitle).TrimStart('-', ' ', '\t');
if (string.IsNullOrWhiteSpace(title))
{
return fallback;
}
return title.StartsWith("Chapter ", StringComparison.OrdinalIgnoreCase) ? title : $"{fallback} - {title}";
}
private static IReadOnlyList<IncludedChapter> IncludedChapters(ManuscriptScanPreview preview, ManuscriptScanReviewDecision review)
{
var chapters = preview.Chapters.ToDictionary(chapter => chapter.TemporaryChapterKey, StringComparer.Ordinal);
@ -1158,53 +864,6 @@ public sealed class OnboardingStoryIntelligenceService(
? 0
: value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
private static Scene? CurrentCanonicalScene(StoryIntelligenceSavedRun run, IReadOnlyList<Scene> scenes)
{
if (scenes.Count == 0)
{
return null;
}
var orderedScenes = scenes.OrderBy(scene => scene.SceneNumber).ToList();
var sceneNumber = CurrentSceneNumber(run);
if (sceneNumber is not > 0)
{
return orderedScenes[0];
}
var byNumber = orderedScenes.FirstOrDefault(scene => Convert.ToInt32(scene.SceneNumber) == sceneNumber.Value);
if (byNumber is not null)
{
return byNumber;
}
return orderedScenes[Math.Clamp(sceneNumber.Value - 1, 0, orderedScenes.Count - 1)];
}
private static int? CurrentSceneNumber(StoryIntelligenceSavedRun run)
{
var completed = Math.Max(0, run.CompletedScenes ?? 0);
var total = Math.Max(0, run.TotalDetectedScenes ?? 0);
if (run.Status == StoryIntelligenceRunStatuses.Running
&& string.Equals(run.CurrentStage, StoryIntelligenceFailureStages.SceneIntelligence, StringComparison.OrdinalIgnoreCase)
&& total > 0)
{
return Math.Clamp(completed + 1, 1, total);
}
if (completed > 0)
{
return Math.Clamp(completed, 1, total > 0 ? total : completed);
}
return run.Status is StoryIntelligenceRunStatuses.Pending or StoryIntelligenceRunStatuses.Running && total > 0
? 1
: null;
}
private static string? CanonicalSceneSummary(Scene? scene)
=> FirstConfigured(scene?.StructuralSummary, scene?.Summary);
private static string? LatestSceneSummary(IReadOnlyList<StoryIntelligenceSavedSceneResult> sceneResults)
{
foreach (var scene in sceneResults
@ -1226,14 +885,12 @@ public sealed class OnboardingStoryIntelligenceService(
.OrderBy(scene => scene.TemporarySceneNumber)
.Select(scene => new StoryIntelligenceOnboardingScenePreviewViewModel
{
SceneID = scene.SceneID,
SceneNumber = scene.TemporarySceneNumber,
Summary = Trim(FirstConfigured(scene.Summary, scene.ParsedScene.Summary?.Short, scene.ParsedScene.Summary?.Detailed, $"Scene {scene.TemporarySceneNumber:N0}")!, 180),
Summary = Trim(FirstConfigured(scene.ParsedScene.Summary?.Short, scene.ParsedScene.Summary?.Detailed, $"Scene {scene.TemporarySceneNumber:N0}")!, 180),
Pov = scene.PovCharacterID.HasValue ? FirstConfigured(scene.ParsedScene.PointOfView?.CharacterName, scene.ParsedScene.PointOfView?.NarrativeMode) : null,
Setting = BuildSetting(scene.ParsedScene.Setting),
Confidence = scene.ParsedScene.Setting?.Confidence ?? scene.ParsedScene.PointOfView?.Confidence,
HasWarnings = scene.HasWarnings,
AnalysisStatus = scene.AnalysisStatus
Confidence = scene.ParsedScene.Summary?.Confidence ?? scene.ParsedScene.Setting?.Confidence,
HasWarnings = false
})
.ToList();
@ -1407,13 +1064,11 @@ public sealed class OnboardingStoryIntelligenceBatch
public StoryIntelligenceAssetImportBatchResult? LastAssetImportResult { get; set; }
public StoryIntelligenceRelationshipImportBatchResult? LastRelationshipImportResult { get; set; }
public StoryIntelligenceKnowledgeImportBatchResult? LastKnowledgeImportResult { get; set; }
public StoryIntelligenceNarrativeImportBatchResult? LastNarrativeImportResult { get; set; }
public bool CharacterStageComplete { get; set; }
public bool LocationStageComplete { get; set; }
public bool AssetStageComplete { get; set; }
public bool RelationshipStageComplete { get; set; }
public bool KnowledgeStageComplete { get; set; }
public bool NarrativeStageComplete { get; set; }
}
public sealed class OnboardingStoryIntelligenceBatchItem
@ -1528,15 +1183,6 @@ public sealed class StoryIntelligenceKnowledgeImportBatchResult
public int KnowledgeIgnored { get; init; }
}
public sealed class StoryIntelligenceNarrativeImportBatchResult
{
public int PlotLinesCreated { get; init; }
public int PlotLinesMerged { get; init; }
public int ThreadsCreated { get; init; }
public int CandidatesRejected { get; init; }
public int TotalReviewed => PlotLinesCreated + PlotLinesMerged + ThreadsCreated + CandidatesRejected;
}
public static class StoryIntelligenceResumeRoutes
{
public const string SceneReview = "SceneReview";
@ -1545,7 +1191,6 @@ public static class StoryIntelligenceResumeRoutes
public const string Assets = "Assets";
public const string Relationships = "Relationships";
public const string Knowledge = "Knowledge";
public const string Narrative = "Narrative";
public const string Complete = "Complete";
}

File diff suppressed because it is too large Load Diff

View File

@ -320,7 +320,6 @@ public sealed class StoryIntelligenceAssetImportService(
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID))
.Where(scene => scene.ImportRunID == item.RunID)
.ToList();
var importedById = importedScenes.ToDictionary(scene => scene.SceneID, scene => scene);
var importedByRange = importedScenes
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
@ -335,7 +334,7 @@ public sealed class StoryIntelligenceAssetImportService(
continue;
}
var importedScene = ResolveImportedScene(sceneResult, importedById, importedByRange, importedByNumber);
var importedScene = ResolveImportedScene(sceneResult, importedByRange, importedByNumber);
if (importedScene is null)
{
continue;
@ -493,15 +492,9 @@ public sealed class StoryIntelligenceAssetImportService(
private static Scene? ResolveImportedScene(
StoryIntelligenceSavedSceneResult sceneResult,
IReadOnlyDictionary<int, Scene> byId,
IReadOnlyDictionary<string, Scene> byRange,
IReadOnlyDictionary<int, Scene> byNumber)
{
if (sceneResult.SceneID.HasValue && byId.TryGetValue(sceneResult.SceneID.Value, out var idMatch))
{
return idMatch;
}
if (sceneResult.StartParagraph.HasValue
&& sceneResult.EndParagraph.HasValue
&& byRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var rangeMatch))
@ -692,18 +685,12 @@ public sealed class StoryIntelligenceAssetImportService(
private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed, string assetName)
{
var summary = Clean(FirstConfigured(scene.StructuralSummary, scene.Summary, parsed.Summary?.Short));
var summary = Clean(parsed.Summary?.Short);
return string.IsNullOrWhiteSpace(summary)
? $"{BuildSceneReference(scene)}: {assetName}"
: $"{BuildSceneReference(scene)}: {summary}";
? $"Scene {scene.SceneNumber:g}: {assetName}"
: $"Scene {scene.SceneNumber:g}: {summary}";
}
private static string? FirstConfigured(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim();
private static string BuildSceneReference(Scene scene)
=> $"Chapter {scene.ChapterNumber:g}, Scene {scene.SceneNumber:g}";
private static void AddDecision(OnboardingStoryIntelligenceBatch batch, string key, string action, string? assetName, int? storyAssetId, bool createdOrLinked)
{
batch.AssetDecisions.RemoveAll(decision => string.Equals(decision.Key, key, StringComparison.OrdinalIgnoreCase));

View File

@ -17,9 +17,6 @@ public sealed class StoryIntelligenceCharacterImportService(
ICharacterRepository characters,
ISceneRepository scenes,
IStoryIntelligencePipelineStateService pipelineState,
INameIntelligenceService nameIntelligence,
ICoreImportCharacterMappingService coreImportCharacterMapping,
ICurrentUserService currentUser,
ILogger<StoryIntelligenceCharacterImportService> logger) : IStoryIntelligenceCharacterImportService
{
private static readonly JsonSerializerOptions JsonOptions = new()
@ -73,112 +70,27 @@ public sealed class StoryIntelligenceCharacterImportService(
var data = await BuildCandidateDataAsync(batch);
var decidedKeys = batch.CharacterDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList();
var existingIndex = await BuildCharacterIndexAsync(batch.ProjectID);
var nameWarnings = await BuildNameIntelligenceWarningsAsync(visibleCandidates, existingIndex);
var existingOptions = existingIndex.Characters
.Select(character => new StoryIntelligenceCharacterExistingOptionViewModel
{
CharacterID = character.CharacterID,
CharacterName = character.CharacterName
})
.OrderBy(character => character.CharacterName, StringComparer.OrdinalIgnoreCase)
.ToList();
var classified = visibleCandidates
.Select(candidate => new { Candidate = candidate, Classification = ClassifyCandidate(candidate) })
.ToList();
return new StoryIntelligenceCharacterReviewViewModel
{
HasCommittedScenes = data.HasCommittedScenes,
CanImport = visibleCandidates.Count > 0,
AlreadyLinkedCount = data.AlreadyLinkedCount,
IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
GenericReferenceCount = classified.Count(item => item.Classification == StoryIntelligenceCharacterClassifications.GenericReference),
ExistingMatchCount = classified.Count(item => item.Classification == StoryIntelligenceCharacterClassifications.ExistingCharacter),
NewCandidateCount = classified.Count(item => item.Classification == StoryIntelligenceCharacterClassifications.NewCharacter || item.Classification == StoryIntelligenceCharacterClassifications.NewAlias),
AmbiguousCount = classified.Count(item => item.Classification == StoryIntelligenceCharacterClassifications.Ambiguous),
AliasTargetOptions = existingOptions,
Candidates = classified.Select(item => new StoryIntelligenceCharacterReviewCandidateViewModel
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceCharacterReviewCandidateViewModel
{
DefaultAction = StoryIntelligenceCharacterReviewDefaults.DetermineAction(item.Candidate.DisplayName, item.Candidate.ExistingCharacterID, item.Classification),
Classification = item.Classification,
Key = item.Candidate.Key,
CharacterName = item.Candidate.DisplayName,
ImportName = item.Candidate.DisplayName,
AppearsInScenes = item.Candidate.PresentAppearances.Concat(item.Candidate.MentionedOnlyAppearances).Select(appearance => appearance.SceneID).Distinct().Count(),
Confidence = ConfidenceBand(item.Candidate.Confidence),
PossibleAliases = item.Candidate.Aliases.OrderBy(value => value).ToList(),
ExampleFirstAppearance = item.Candidate.FirstAppearanceNote,
ExistingCharacterID = item.Candidate.ExistingCharacterID,
ExistingCharacterName = item.Candidate.ExistingCharacterName,
SelectedExistingCharacterID = item.Candidate.ExistingCharacterID,
ExistingCharacterOptions = existingOptions,
NameIntelligenceWarning = nameWarnings.GetValueOrDefault(item.Candidate.Key)
Key = candidate.Key,
CharacterName = candidate.DisplayName,
ImportName = candidate.DisplayName,
AppearsInScenes = candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Select(appearance => appearance.SceneID).Distinct().Count(),
Confidence = ConfidenceBand(candidate.Confidence),
PossibleAliases = candidate.Aliases.OrderBy(value => value).ToList(),
ExampleFirstAppearance = candidate.FirstAppearanceNote,
ExistingCharacterID = candidate.ExistingCharacterID,
ExistingCharacterName = candidate.ExistingCharacterName
}).ToList()
};
}
private async Task<IReadOnlyDictionary<string, string>> BuildNameIntelligenceWarningsAsync(
IReadOnlyList<CharacterCandidate> candidates,
CharacterIndex existingIndex)
{
if (candidates.Count == 0)
{
return new Dictionary<string, string>();
}
var warnings = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var existingForms = existingIndex.Rows
.Select(row => new { row.CharacterName, row.MatchName, FirstName = FirstNameWithoutTitle(row.MatchName) ?? string.Empty })
.Where(row => !string.IsNullOrWhiteSpace(row.FirstName))
.ToList();
foreach (var candidate in candidates)
{
var candidateFirst = FirstNameWithoutTitle(candidate.DisplayName);
if (string.IsNullOrWhiteSpace(candidateFirst))
{
continue;
}
var candidateEvidence = await nameIntelligence.RecogniseAsync(candidateFirst, "en-US");
if (!candidateEvidence.Recognised)
{
continue;
}
foreach (var existing in existingForms)
{
if (string.Equals(existing.FirstName, candidateFirst, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var match = candidateEvidence.RelatedForms.FirstOrDefault(relationship =>
string.Equals(relationship.RelationshipType, "SpellingVariant", StringComparison.OrdinalIgnoreCase)
&& relationship.Strength >= 0.9m
&& (string.Equals(relationship.FromDisplayName, existing.FirstName, StringComparison.OrdinalIgnoreCase)
|| string.Equals(relationship.ToDisplayName, existing.FirstName, StringComparison.OrdinalIgnoreCase)));
if (match is null)
{
continue;
}
var occurrences = candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances)
.Select(appearance => appearance.SceneNumber)
.Distinct()
.OrderBy(value => value)
.Take(5)
.Select(value => value.ToString("0.##"))
.ToList();
var sceneLabel = occurrences.Count == 0 ? "the imported manuscript" : $"scene {string.Join(", ", occurrences)}";
warnings[candidate.Key] = $"Possible inconsistent name: manuscript form '{candidate.DisplayName}' is a strong spelling variant of existing {existing.MatchName} for {existing.CharacterName}; seen in {sceneLabel}.";
break;
}
}
return warnings;
}
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceCharacterImportForm form)
{
var data = await BuildCandidateDataAsync(batch);
@ -232,8 +144,7 @@ public sealed class StoryIntelligenceCharacterImportService(
var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.DisplayName : requestedName;
var linkExisting = string.Equals(choice.Action, StoryIntelligenceCharacterImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase);
var matchedExistingId = linkExisting
? existingIndex.Find(choice.ExistingCharacterID)?.CharacterID
?? candidate.ExistingCharacterID
? candidate.ExistingCharacterID
?? existingIndex.Find(importName)?.CharacterID
?? existingIndex.Find(candidate.DisplayName)?.CharacterID
: null;
@ -288,47 +199,29 @@ public sealed class StoryIntelligenceCharacterImportService(
continue;
}
var target = existingIndex.Find(choice.AliasTargetCharacterID);
CharacterCandidate? targetCandidate = null;
var targetCharacterId = target?.CharacterID;
var targetName = target?.CharacterName;
if (!targetCharacterId.HasValue)
{
var targetKey = Clean(choice.AliasTargetKey);
if (!string.IsNullOrWhiteSpace(targetKey)
&& !string.Equals(targetKey, candidate.Key, StringComparison.OrdinalIgnoreCase)
&& choices.TryGetValue(targetKey, out var targetChoice)
&& !string.Equals(targetChoice.Action, StoryIntelligenceCharacterImportActions.Ignore, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(targetChoice.Action, StoryIntelligenceCharacterImportActions.Alias, StringComparison.OrdinalIgnoreCase)
&& candidateByKey.TryGetValue(targetKey, out targetCandidate)
&& resolvedCandidateIds.TryGetValue(targetKey, out var resolvedTargetCharacterId))
{
targetCharacterId = resolvedTargetCharacterId;
targetName = targetCandidate.ExistingCharacterName ?? targetChoice.ImportName ?? targetCandidate.DisplayName;
}
}
if (!targetCharacterId.HasValue)
if (string.IsNullOrWhiteSpace(targetKey)
|| string.Equals(targetKey, candidate.Key, StringComparison.OrdinalIgnoreCase)
|| !choices.TryGetValue(targetKey, out var targetChoice)
|| string.Equals(targetChoice.Action, StoryIntelligenceCharacterImportActions.Ignore, StringComparison.OrdinalIgnoreCase)
|| string.Equals(targetChoice.Action, StoryIntelligenceCharacterImportActions.Alias, StringComparison.OrdinalIgnoreCase)
|| !candidateByKey.TryGetValue(targetKey, out var targetCandidate)
|| !resolvedCandidateIds.TryGetValue(targetKey, out var targetCharacterId))
{
logger.LogWarning(
"Story Intelligence character alias candidate {CandidateKey} could not be imported because no canonical target was selected. AliasTargetCharacterID={AliasTargetCharacterID}",
"Story Intelligence character alias candidate {CandidateKey} could not be imported because target {TargetKey} was not a resolved create/link target.",
candidate.Key,
choice.AliasTargetCharacterID);
targetKey);
continue;
}
targetName ??= $"Character {targetCharacterId.Value:N0}";
if (candidate.ExistingCharacterID.HasValue && candidate.ExistingCharacterID.Value != targetCharacterId.Value)
{
await characters.MergeAsync(candidate.ExistingCharacterID.Value, targetCharacterId.Value, currentUser.UserId);
}
await TryAddAliasAsync(targetCharacterId.Value, candidate.DisplayName, targetName);
AddResolvedName(resolvedCharacters, candidate.DisplayName, targetCharacterId.Value);
var targetName = targetCandidate.ExistingCharacterName ?? targetChoice.ImportName ?? targetCandidate.DisplayName;
await TryAddAliasAsync(targetCharacterId, candidate.DisplayName, targetName);
AddResolvedName(resolvedCharacters, candidate.DisplayName, targetCharacterId);
foreach (var alias in candidate.Aliases)
{
await TryAddAliasAsync(targetCharacterId.Value, alias, targetName);
AddResolvedName(resolvedCharacters, alias, targetCharacterId.Value);
await TryAddAliasAsync(targetCharacterId, alias, targetName);
AddResolvedName(resolvedCharacters, alias, targetCharacterId);
}
foreach (var appearance in candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Where(appearance => !appearance.AlreadyLinked))
@ -336,16 +229,16 @@ public sealed class StoryIntelligenceCharacterImportService(
await characters.SaveSceneCharacterAsync(new SceneCharacter
{
SceneID = appearance.SceneID,
CharacterID = targetCharacterId.Value,
CharacterID = targetCharacterId,
RoleInSceneTypeID = MatchRole(roleTypes, appearance.RoleInScene),
PresenceTypeID = MatchPresence(presenceTypes, appearance.MentionedOnly),
AppearanceNotes = BuildAliasAppearanceNote(candidate.DisplayName, targetName, appearance.Notes)
AppearanceNotes = BuildAliasAppearanceNote(candidate.DisplayName, targetCandidate.DisplayName, appearance.Notes)
});
linkedAppearances++;
}
aliased++;
AddDecision(batch, candidate.Key, StoryIntelligenceCharacterImportActions.Alias, candidate.DisplayName, targetCharacterId.Value, true);
AddDecision(batch, candidate.Key, StoryIntelligenceCharacterImportActions.Alias, candidate.DisplayName, targetCharacterId, true);
}
foreach (var scene in data.SceneAnalyses)
@ -370,26 +263,23 @@ public sealed class StoryIntelligenceCharacterImportService(
PovLinksResolved = povLinks,
ScenePeoplePanelsUpdated = linkedAppearances
};
var remap = await coreImportCharacterMapping.MapBookAsync(batch.ProjectID, batch.BookID);
await pipelineState.RecordCharacterImportAsync(batch.ProjectID, batch.BookID);
logger.LogInformation(
"Imported Story Intelligence characters for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliased={Aliased} Ignored={Ignored} LinkedAppearances={LinkedAppearances} PovLinks={PovLinks} RemappedScenes={RemappedScenes} RemappedLinks={RemappedLinks}",
"Imported Story Intelligence characters for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliased={Aliased} Ignored={Ignored} LinkedAppearances={LinkedAppearances} PovLinks={PovLinks}",
batch.BatchID,
created,
linkedExisting,
aliased,
ignored,
linkedAppearances,
povLinks,
remap.ScenesScanned,
remap.LinksSaved);
povLinks);
return new StoryIntelligenceImportCommitResult
{
Success = true,
ScenesCreated = linkedAppearances,
Message = $"Characters imported. {created:N0} created, {linkedExisting:N0} linked, {aliased:N0} alias decision(s) applied, {ignored:N0} ignored, {linkedAppearances:N0} scene appearance(s) updated, {povLinks:N0} POV link(s) resolved. Deterministic remapping updated {remap.LinksSaved:N0} link(s)."
Message = $"Characters imported. {created:N0} created, {linkedExisting:N0} linked, {aliased:N0} alias decision(s) applied, {ignored:N0} ignored, {linkedAppearances:N0} scene appearance(s) updated, {povLinks:N0} POV link(s) resolved."
};
}
@ -426,7 +316,6 @@ public sealed class StoryIntelligenceCharacterImportService(
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID))
.Where(scene => scene.ImportRunID == item.RunID)
.ToList();
var importedById = importedScenes.ToDictionary(scene => scene.SceneID, scene => scene);
var importedByRange = importedScenes
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
@ -441,7 +330,7 @@ public sealed class StoryIntelligenceCharacterImportService(
continue;
}
var importedScene = ResolveImportedScene(sceneResult, importedById, importedByRange, importedByNumber);
var importedScene = ResolveImportedScene(sceneResult, importedByRange, importedByNumber);
if (importedScene is null)
{
continue;
@ -543,13 +432,8 @@ public sealed class StoryIntelligenceCharacterImportService(
AddMention(groups, existingIndex, importedScene, parsed, character, existingAppearances);
}
foreach (var appearance in parsed.CharacterAppearance ?? [])
{
AddCharacterAppearanceAliases(groups, existingIndex, importedScene, parsed, appearance, existingAppearances);
}
var povName = CleanCharacterName(parsed.PointOfView?.CharacterName);
if (IsCharacterNameCandidate(povName) && !importedScene.POVCharacterID.HasValue)
if (IsCharacterNameCandidate(povName))
{
AddMention(groups, existingIndex, importedScene, parsed, new SceneIntelligenceCharacter
{
@ -587,31 +471,6 @@ public sealed class StoryIntelligenceCharacterImportService(
}
}
private static void AddCharacterAppearanceAliases(
Dictionary<string, CharacterCandidate> groups,
CharacterIndex existingIndex,
Scene importedScene,
SceneIntelligenceScene parsed,
SceneIntelligenceCharacterAppearance appearance,
IReadOnlyList<SceneCharacter> existingAppearances)
{
var canonicalName = CleanCharacterName(appearance.CanonicalName);
if (!IsCharacterNameCandidate(canonicalName))
{
return;
}
AddMention(groups, existingIndex, importedScene, parsed, new SceneIntelligenceCharacter
{
Name = canonicalName,
RoleInScene = appearance.RoleOrOccupation,
MentionedOnly = false,
Aliases = appearance.Aliases,
Confidence = appearance.Confidence,
Notes = appearance.Evidence
}, existingAppearances);
}
private static void AddObservedName(
Dictionary<string, CharacterCandidate> groups,
CharacterIndex existingIndex,
@ -625,7 +484,7 @@ public sealed class StoryIntelligenceCharacterImportService(
IReadOnlyList<SceneCharacter> existingAppearances)
{
var cleanName = CleanCharacterName(name);
if (!IsCharacterNameCandidate(cleanName) || IsResolvedPovReference(cleanName, importedScene))
if (!IsCharacterNameCandidate(cleanName))
{
return;
}
@ -640,16 +499,11 @@ public sealed class StoryIntelligenceCharacterImportService(
}, existingAppearances);
}
private static bool IsResolvedPovReference(string name, Scene importedScene)
=> importedScene.POVCharacterID.HasValue
&& (IgnoredNames.Contains(Normalise(name)) || string.Equals(name, "Narrator", StringComparison.OrdinalIgnoreCase));
private async Task<CharacterIndex> BuildCharacterIndexAsync(int projectId)
{
var index = new CharacterIndex();
foreach (var character in await characters.ListCharactersAsync(projectId))
{
index.AddCharacter(character.CharacterID, character.CharacterName);
index.Add(character.CharacterName, character.CharacterID, character.CharacterName);
index.Add(FirstName(character.CharacterName), character.CharacterID, character.CharacterName);
index.Add(character.ShortName, character.CharacterID, character.CharacterName);
@ -682,15 +536,9 @@ public sealed class StoryIntelligenceCharacterImportService(
private static Scene? ResolveImportedScene(
StoryIntelligenceSavedSceneResult sceneResult,
IReadOnlyDictionary<int, Scene> byId,
IReadOnlyDictionary<string, Scene> byRange,
IReadOnlyDictionary<int, Scene> byNumber)
{
if (sceneResult.SceneID.HasValue && byId.TryGetValue(sceneResult.SceneID.Value, out var idMatch))
{
return idMatch;
}
if (sceneResult.StartParagraph.HasValue
&& sceneResult.EndParagraph.HasValue
&& byRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var rangeMatch))
@ -768,19 +616,13 @@ public sealed class StoryIntelligenceCharacterImportService(
var note = Clean(character.Notes);
if (!string.IsNullOrWhiteSpace(note))
{
return $"{BuildSceneReference(scene)}: {note}";
return $"Scene {scene.SceneNumber:g}: {note}";
}
var summary = Clean(FirstConfigured(scene.StructuralSummary, scene.Summary, parsed.Summary?.Short));
return string.IsNullOrWhiteSpace(summary) ? $"{BuildSceneReference(scene)}: {scene.SceneTitle}" : $"{BuildSceneReference(scene)}: {summary}";
var summary = Clean(parsed.Summary?.Short);
return string.IsNullOrWhiteSpace(summary) ? $"Scene {scene.SceneNumber:g}: {scene.SceneTitle}" : $"Scene {scene.SceneNumber:g}: {summary}";
}
private static string? FirstConfigured(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim();
private static string BuildSceneReference(Scene scene)
=> $"Chapter {scene.ChapterNumber:g}, Scene {scene.SceneNumber:g}";
private static decimal? Max(decimal? current, decimal? next)
=> current.HasValue && next.HasValue ? Math.Max(current.Value, next.Value) : current ?? next;
@ -829,39 +671,6 @@ public sealed class StoryIntelligenceCharacterImportService(
return !IsGenericGroupReference(name);
}
private static string ClassifyCandidate(CharacterCandidate candidate)
{
var name = CleanCharacterName(candidate.DisplayName);
if (!IsCharacterNameCandidate(name))
{
return StoryIntelligenceCharacterClassifications.NonCharacter;
}
if (candidate.ExistingCharacterID.HasValue)
{
return StoryIntelligenceCharacterClassifications.ExistingCharacter;
}
if (StoryIntelligenceCharacterReviewDefaults.IsGenericContextualReference(name)
|| IsFamilyRoleOnly(name)
|| IsGenericGroupReference(name))
{
return StoryIntelligenceCharacterClassifications.GenericReference;
}
if (candidate.Aliases.Count > 0)
{
return StoryIntelligenceCharacterClassifications.NewAlias;
}
if (candidate.Confidence is null or < 0.65m)
{
return StoryIntelligenceCharacterClassifications.Ambiguous;
}
return StoryIntelligenceCharacterClassifications.NewCharacter;
}
private static bool IsCharacterNameCandidate(string? name)
{
var clean = CleanCharacterName(name);
@ -1068,21 +877,6 @@ public sealed class StoryIntelligenceCharacterImportService(
private sealed class CharacterIndex
{
private readonly Dictionary<string, CharacterMatch> byName = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<int, CharacterMatch> byId = new();
private readonly List<CharacterIndexRow> rows = [];
private readonly List<CharacterIndexCharacter> characters = [];
public IReadOnlyList<CharacterIndexRow> Rows => rows;
public IReadOnlyList<CharacterIndexCharacter> Characters => characters;
public void AddCharacter(int characterId, string characterName)
{
var match = new CharacterMatch(characterId, characterName);
byId.TryAdd(characterId, match);
if (!characters.Any(character => character.CharacterID == characterId))
{
characters.Add(new CharacterIndexCharacter(characterId, characterName));
}
}
public void Add(string? name, int characterId, string characterName)
{
@ -1090,20 +884,14 @@ public sealed class StoryIntelligenceCharacterImportService(
if (!string.IsNullOrWhiteSpace(key))
{
byName.TryAdd(key, new CharacterMatch(characterId, characterName));
rows.Add(new CharacterIndexRow(name!, characterName));
}
}
public CharacterMatch? Find(string name)
=> byName.TryGetValue(Normalise(name), out var match) ? match : null;
public CharacterMatch? Find(int? characterId)
=> characterId.HasValue && byId.TryGetValue(characterId.Value, out var match) ? match : null;
}
private sealed record CharacterMatch(int CharacterID, string CharacterName);
private sealed record CharacterIndexRow(string MatchName, string CharacterName);
private sealed record CharacterIndexCharacter(int CharacterID, string CharacterName);
private sealed class CharacterCandidate(string key, string displayName, int? existingCharacterId, string? existingCharacterName)
{

View File

@ -1,54 +0,0 @@
using System.Text.RegularExpressions;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public static class StoryIntelligenceCharacterReviewDefaults
{
private static readonly HashSet<string> GenericFamilyReferences = CreateSet(
"mum", "mom", "mam", "mother", "dad", "father", "brother", "sister", "grandmother", "grandfather",
"nan", "nanny", "grandma", "grandad", "granddad", "aunt", "auntie", "uncle", "cousin");
private static readonly Regex ContextualIndefiniteReference = new(
@"^(one of (them|his mates|her mates|their mates|the men|the women|the boys|the girls)|his mate|her mate|their mate|his friend|her friend|their friend)$",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
public static string DetermineAction(string? candidateName, int? proposedExistingCharacterId, string? classification = null)
{
if (proposedExistingCharacterId.HasValue)
{
return StoryIntelligenceCharacterImportActions.LinkExisting;
}
if (string.Equals(classification, StoryIntelligenceCharacterClassifications.GenericReference, StringComparison.OrdinalIgnoreCase)
|| string.Equals(classification, StoryIntelligenceCharacterClassifications.NonCharacter, StringComparison.OrdinalIgnoreCase)
|| IsGenericContextualReference(candidateName))
{
return StoryIntelligenceCharacterImportActions.Ignore;
}
return StoryIntelligenceCharacterImportActions.CreateNew;
}
public static bool IsGenericContextualReference(string? candidateName)
{
var normalised = Normalise(candidateName);
if (string.IsNullOrWhiteSpace(normalised))
{
return false;
}
return GenericFamilyReferences.Contains(normalised)
|| ContextualIndefiniteReference.IsMatch(normalised);
}
private static string Normalise(string? value)
{
var cleaned = Regex.Replace(value ?? string.Empty, @"[^\p{L}\p{N}' ]+", " ");
cleaned = cleaned.Replace("’", "'", StringComparison.Ordinal);
return string.Join(' ', cleaned.Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)).ToLowerInvariant();
}
private static HashSet<string> CreateSet(params string[] values)
=> values.Select(Normalise).ToHashSet(StringComparer.OrdinalIgnoreCase);
}

View File

@ -10,21 +10,23 @@ public interface IStoryIntelligenceDevelopmentResetService
public sealed class StoryIntelligenceDevelopmentResetService(
ISqlConnectionFactory connectionFactory,
IProjectRepository projects,
ILogger<StoryIntelligenceDevelopmentResetService> logger) : IStoryIntelligenceDevelopmentResetService
{
public async Task<StoryIntelligenceDevelopmentResetResult> ResetOwnedProjectsAndImportsAsync(int userId, CancellationToken cancellationToken = default)
{
logger.LogWarning("Admin Story Intelligence reset started for user {UserId}.", userId);
using var connection = connectionFactory.CreateConnection();
var command = new CommandDefinition(PrepareResetSql, new { UserID = userId }, cancellationToken: cancellationToken, commandTimeout: 120);
var summary = await connection.QuerySingleAsync<PreparedResetSummary>(command);
foreach (var projectId in summary.ProjectIdList)
{
logger.LogWarning("Admin Story Intelligence reset hard-deleting archived project {ProjectID} for user {UserId}.", projectId, userId);
await projects.HardDeleteForOwnerAsync(projectId, userId);
var deleteCommand = new CommandDefinition(
"dbo.ProjectHardDelete_ForOwner",
new { ProjectID = projectId, UserID = userId },
commandType: System.Data.CommandType.StoredProcedure,
cancellationToken: cancellationToken,
commandTimeout: 120);
await connection.ExecuteAsync(deleteCommand);
}
var verificationCommand = new CommandDefinition(VerifyResetSql, new { UserID = userId }, cancellationToken: cancellationToken, commandTimeout: 120);
@ -142,16 +144,28 @@ WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = St
DELETE FROM dbo.StoryMemoryImportPreferences
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryImportPreferences.ImportSessionID);
IF OBJECT_ID(N'dbo.StoryIntelligenceCharacterIllustrationAssignments', N'U') IS NOT NULL
BEGIN
DELETE FROM dbo.StoryIntelligenceCharacterIllustrationAssignments
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryIntelligenceCharacterIllustrationAssignments.ImportSessionID)
OR EXISTS (SELECT 1 FROM @ProjectIDs p WHERE p.ProjectID = StoryIntelligenceCharacterIllustrationAssignments.ProjectID);
END;
DELETE FROM dbo.StoryIntelligenceIllustrationDemands
WHERE EXISTS (SELECT 1 FROM @ProjectIDs p WHERE p.ProjectID = StoryIntelligenceIllustrationDemands.ProjectID);
DELETE FROM dbo.StoryIntelligenceBookPipelines
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryIntelligenceBookPipelines.StoryIntelligenceBookPipelineID);
DELETE FROM dbo.StoryIntelligenceSceneResults
WHERE EXISTS (SELECT 1 FROM @RunIDs ids WHERE ids.StoryIntelligenceRunID = StoryIntelligenceSceneResults.StoryIntelligenceRunID);
DELETE FROM dbo.StoryIntelligenceChapterResults
WHERE EXISTS (SELECT 1 FROM @RunIDs ids WHERE ids.StoryIntelligenceRunID = StoryIntelligenceChapterResults.StoryIntelligenceRunID);
DELETE FROM dbo.StoryIntelligenceImportCommits
WHERE EXISTS (SELECT 1 FROM @RunIDs ids WHERE ids.StoryIntelligenceRunID = StoryIntelligenceImportCommits.StoryIntelligenceRunID);
DELETE FROM dbo.StoryIntelligenceRuns
WHERE EXISTS (SELECT 1 FROM @RunIDs ids WHERE ids.StoryIntelligenceRunID = StoryIntelligenceRuns.StoryIntelligenceRunID);
UPDATE dbo.Projects
SET IsArchived = 1,
ArchivedDate = COALESCE(ArchivedDate, SYSUTCDATETIME()),

View File

@ -14,7 +14,6 @@ public sealed class StoryIntelligenceExistingChapterQueueService(
IStoryIntelligenceResultRepository runs,
IStoryIntelligenceClient client,
IStoryIntelligencePipelineStateService pipelines,
IStoryIntelligenceEntitlementService entitlement,
ILogger<StoryIntelligenceExistingChapterQueueService> logger) : IStoryIntelligenceExistingChapterQueueService
{
private const string NoStoredChapterTextMessage = "No stored chapter text is currently available for this chapter. A future Word Companion/document extraction phase is required.";
@ -27,7 +26,6 @@ public sealed class StoryIntelligenceExistingChapterQueueService(
public async Task<StoryIntelligenceExistingChapterQueueViewModel> QueueAsync(int userId, StoryIntelligenceExistingChapterQueueForm form)
{
await entitlement.EnsureCanQueueAsync(userId);
if (form.ChapterID is not int chapterId)
{
return await BuildViewModelAsync(

View File

@ -19,7 +19,6 @@ public sealed class StoryIntelligenceFullChapterTestService(
IStoryIntelligencePipelineStateService pipelines,
IOptions<StoryIntelligenceOptions> options,
IOptions<StoryIntelligencePricingOptions> pricingOptions,
IStoryIntelligenceEntitlementService entitlement,
ILogger<StoryIntelligenceFullChapterTestService> logger) : IStoryIntelligenceFullChapterTestService
{
private const int LargeChapterCharacterThreshold = 40000;
@ -75,7 +74,6 @@ public sealed class StoryIntelligenceFullChapterTestService(
public async Task<int> QueueAsync(int userId, StoryIntelligenceFullChapterTestForm form, IFormFile? textFile)
{
await entitlement.EnsureCanQueueAsync(userId);
var source = await ReadSourceAsync(form, textFile);
if (!string.IsNullOrWhiteSpace(source.ErrorMessage))
{

View File

@ -154,37 +154,6 @@ public static class StoryIntelligenceIllustrationCompatibility
FirstMetadata(metadata, "generationSource", "source") ?? string.Empty);
}
public static IllustrationLocationMetadata LocationMetadataFrom(IllustrationLibraryItem item)
{
Dictionary<string, string>? metadata = null;
if (!string.IsNullOrWhiteSpace(item.MetadataJson))
{
try
{
metadata = JsonSerializer.Deserialize<Dictionary<string, string>>(item.MetadataJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (JsonException)
{
}
}
var baseType = FirstConfigured(
item.BaseType,
FirstMetadata(metadata, "baseType", "locationType"),
item.SpecificationJson,
item.StableCode,
item.DisplayTitle,
item.ShortDescription);
var type = LocationType(baseType);
var isFallback = item.IsFallbackClassification
|| BoolMetadata(metadata, "isFallbackClassification")
|| IsFallbackLocationType(type)
|| item.StableCode.Contains("unknowninterior", StringComparison.OrdinalIgnoreCase)
|| item.DisplayTitle.Contains("Unknown Interior", StringComparison.OrdinalIgnoreCase);
var reuseScope = FirstConfigured(item.ReuseScope, FirstMetadata(metadata, "reuseScope"), "Project");
return new(type, isFallback, reuseScope);
}
public static IReadOnlyList<string> CharacterHardRejections(CharacterEvidenceProfile evidence, IllustrationCharacterMetadata candidate, bool isNamedCharacter, bool alreadyAssignedToAnotherSignificantCharacter)
{
var rejected = new List<string>();
@ -476,19 +445,6 @@ public static class StoryIntelligenceIllustrationCompatibility
|| (requestedType == "Note" && candidateType is "Letter" or "Document")
|| (requestedType == "Rucksack" && candidateType == "Rucksack");
public static bool IsFallbackLocationType(string? locationType)
=> string.Equals(locationType, "UnknownInterior", StringComparison.OrdinalIgnoreCase)
|| string.Equals(locationType, "FlatInterior", StringComparison.OrdinalIgnoreCase);
public static bool IsFallbackAssetType(string? assetType)
=> string.Equals(assetType, "UnknownObject", StringComparison.OrdinalIgnoreCase);
public static bool CanAutomaticallyGenerateLocation(string? locationType)
=> !IsFallbackLocationType(locationType);
public static bool CanAutomaticallyGenerateAsset(string? assetType)
=> !IsFallbackAssetType(assetType);
public static bool IsGroupEntity(string? value)
{
var clean = (value ?? string.Empty).Trim().ToLowerInvariant();
@ -698,9 +654,6 @@ public static class StoryIntelligenceIllustrationCompatibility
private static string? FirstMetadata(Dictionary<string, string>? metadata, params string[] keys)
=> keys.Select(key => metadata?.GetValueOrDefault(key)).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
private static string FirstConfigured(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty;
private static bool BoolMetadata(Dictionary<string, string>? metadata, string key)
=> bool.TryParse(metadata?.GetValueOrDefault(key), out var value) && value;
@ -868,8 +821,3 @@ public sealed record IllustrationCharacterMetadata(
bool IsSuitableForNamedCharacter,
string? PromptTemplateVersion,
string GenerationSource);
public sealed record IllustrationLocationMetadata(
string BaseType,
bool IsFallbackClassification,
string ReuseScope);

File diff suppressed because it is too large Load Diff

View File

@ -134,19 +134,22 @@ public sealed class StoryIntelligenceImportCommitService(
blockers.Add("No saved scene results are available to import.");
}
var readiness = run.ChapterID.HasValue
? await storyRuns.GetChapterImportReadinessAsync(run.ChapterID.Value)
: null;
if (readiness is not null && !readiness.CanCommit)
{
blockers.Add(readiness.BlockReason ?? "This chapter already contains scenes.");
}
if (existingCommit is not null && string.Equals(existingCommit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase))
{
blockers.Add("This Story Intelligence run has already been committed.");
}
var lookupData = await lookups.GetAllAsync();
if (run.ProjectID.HasValue)
{
await metricTypes.AddMissingDefaultsAsync(run.ProjectID.Value);
}
var activeMetricTypes = run.ProjectID.HasValue
? (await metricTypes.ListForManagementAsync(run.ProjectID.Value)).Where(metric => metric.IsActive && metric.IsEnabledForProject).ToList()
? (await metricTypes.ListForManagementAsync(run.ProjectID.Value)).Where(metric => metric.IsActive).ToList()
: [];
var noteTypes = await writerWorkspace.ListNoteTypesAsync();
var characterMap = run.ProjectID.HasValue
@ -169,18 +172,13 @@ public sealed class StoryIntelligenceImportCommitService(
var parsedChapter = TryReadChapter(chapterResult);
var chapterSummary = Clean(parsedChapter?.ChapterSummary);
var chapterPurposeId = MatchChapterPurpose(parsedChapter, chapterPurposeMap);
var canonicalScenes = run.ChapterID.HasValue
? await storyRuns.ListChapterScenesAsync(run.ChapterID.Value)
: [];
var canonicalById = canonicalScenes.ToDictionary(scene => scene.SceneID);
var importScenes = new List<StoryIntelligenceSceneImportItem>();
foreach (var sceneResult in sceneResults.OrderBy(scene => scene.TemporarySceneNumber))
{
canonicalById.TryGetValue(sceneResult.SceneID ?? 0, out var canonicalScene);
var parsed = TryReadScene(sceneResult);
if (parsed is null)
{
var fallback = BuildFallbackImportScene(sceneResult, metricMap, canonicalScene);
var fallback = BuildFallbackImportScene(sceneResult, metricMap);
if (fallback is null)
{
warnings.Add($"Scene {sceneResult.TemporarySceneNumber:N0} could not be prepared automatically and needs review.");
@ -192,7 +190,7 @@ public sealed class StoryIntelligenceImportCommitService(
continue;
}
importScenes.Add(BuildImportScene(sceneResult, parsed, characterMap, purposeMap, metricMap, canonicalScene));
importScenes.Add(BuildImportScene(sceneResult, parsed, characterMap, purposeMap, metricMap));
}
if (importScenes.Count == 0)
@ -200,14 +198,6 @@ public sealed class StoryIntelligenceImportCommitService(
blockers.Add("No scenes are ready to create for this chapter.");
}
var readiness = run.ChapterID.HasValue
? await storyRuns.GetChapterImportReadinessAsync(run.ChapterID.Value)
: null;
if (readiness is not null && !readiness.CanCommit && readiness.ActiveSceneCount != importScenes.Count)
{
blockers.Add($"This chapter contains {readiness.ActiveSceneCount:N0} existing scene(s), but Story Intelligence produced {importScenes.Count:N0} scene result(s).");
}
var projectName = run.ProjectID.HasValue ? (await projects.GetAsync(run.ProjectID.Value))?.ProjectName ?? $"Project {run.ProjectID.Value:N0}" : "None";
var bookTitle = run.BookID.HasValue ? (await books.GetAsync(run.BookID.Value))?.BookDisplayTitle ?? $"Book {run.BookID.Value:N0}" : "None";
var chapter = run.ChapterID.HasValue ? await chapters.GetAsync(run.ChapterID.Value) : null;
@ -228,12 +218,12 @@ public sealed class StoryIntelligenceImportCommitService(
ChapterLabel = chapterLabel,
ExistingSceneCount = readiness?.ActiveSceneCount ?? 0,
CommitMode = readiness?.CommitMode ?? string.Empty,
ScenesToCreate = canonicalScenes.Count > 0 ? canonicalScenes.Count : importScenes.Count,
ScenesToCreate = importScenes.Count,
MetricsToImport = importScenes.Sum(scene => scene.Metrics.Count),
MetricNames = importScenes.SelectMany(scene => scene.Metrics.Select(metric => metric.MetricName)).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(name => name).ToList(),
Warnings = warnings,
Blockers = blockers,
Scenes = BuildReviewScenes(canonicalScenes, sceneResults, importScenes)
Scenes = importScenes
},
importScenes,
timeModeId,
@ -249,8 +239,7 @@ public sealed class StoryIntelligenceImportCommitService(
SceneIntelligenceScene parsed,
IReadOnlyDictionary<string, int> characterMap,
IReadOnlyDictionary<string, int> purposeMap,
IReadOnlyDictionary<string, SceneMetricType> metricMap,
Scene? canonicalScene = null)
IReadOnlyDictionary<string, SceneMetricType> metricMap)
{
var povName = Clean(parsed.PointOfView?.CharacterName);
var povCharacterId = !string.IsNullOrWhiteSpace(povName)
@ -258,13 +247,12 @@ public sealed class StoryIntelligenceImportCommitService(
&& characterMap.TryGetValue(povName, out var characterId)
? characterId
: (int?)null;
var summary = CanonicalSummary(canonicalScene) ?? Clean(parsed.Summary?.Short);
var summary = Clean(parsed.Summary?.Short);
var sceneTitle = GenerateTitle(sceneResult.TemporarySceneNumber, summary);
return new StoryIntelligenceSceneImportItem
{
SceneResultID = sceneResult.SceneResultID,
SceneID = sceneResult.SceneID,
TemporarySceneNumber = sceneResult.TemporarySceneNumber,
StartParagraph = sceneResult.StartParagraph,
EndParagraph = sceneResult.EndParagraph,
@ -273,28 +261,24 @@ public sealed class StoryIntelligenceImportCommitService(
PovCharacterID = povCharacterId,
PurposeTypeIDs = MatchPurposes(parsed, purposeMap),
Metrics = BuildMetrics(parsed, metricMap),
SceneTitle = canonicalScene is not null && !string.IsNullOrWhiteSpace(canonicalScene.SceneTitle) ? canonicalScene.SceneTitle : sceneTitle,
SceneTitle = sceneTitle,
Summary = string.IsNullOrWhiteSpace(summary) ? null : summary,
PurposeNotes = BuildPurposeNotes(parsed, sceneResult, povName, povCharacterId),
OutcomeNotes = canonicalScene is null && !string.IsNullOrWhiteSpace(parsed.Summary?.Detailed) ? Clean(parsed.Summary.Detailed) : null,
ImportNoteText = BuildImportNote(parsed, povName, povCharacterId),
AnalysisStatus = sceneResult.ValidationErrorsCount > 0 ? "Needs review" : "Ready",
HasWarnings = sceneResult.ValidationWarningsCount > 0 || sceneResult.ValidationErrorsCount > 0
OutcomeNotes = string.IsNullOrWhiteSpace(parsed.Summary?.Detailed) ? null : Clean(parsed.Summary.Detailed),
ImportNoteText = BuildImportNote(parsed, povName, povCharacterId)
};
}
private static StoryIntelligenceSceneImportItem? BuildFallbackImportScene(
StoryIntelligenceSavedSceneResult sceneResult,
IReadOnlyDictionary<string, SceneMetricType> metricMap,
Scene? canonicalScene = null)
IReadOnlyDictionary<string, SceneMetricType> metricMap)
{
if (!sceneResult.StartParagraph.HasValue || !sceneResult.EndParagraph.HasValue || sceneResult.StartParagraph > sceneResult.EndParagraph)
{
return null;
}
var summary = CanonicalSummary(canonicalScene)
?? ExtractSummaryFromJsonText(sceneResult.OutputTextJson)
var summary = ExtractSummaryFromJsonText(sceneResult.OutputTextJson)
?? ExtractSummaryFromJsonText(sceneResult.ParsedJson)
?? $"Scene {sceneResult.TemporarySceneNumber:N0} from paragraphs {DisplayRange(sceneResult.StartParagraph, sceneResult.EndParagraph)}.";
var parsed = new SceneIntelligenceScene
@ -324,7 +308,6 @@ public sealed class StoryIntelligenceImportCommitService(
return new StoryIntelligenceSceneImportItem
{
SceneResultID = sceneResult.SceneResultID,
SceneID = sceneResult.SceneID,
TemporarySceneNumber = sceneResult.TemporarySceneNumber,
StartParagraph = sceneResult.StartParagraph,
EndParagraph = sceneResult.EndParagraph,
@ -333,82 +316,14 @@ public sealed class StoryIntelligenceImportCommitService(
PovCharacterID = null,
PurposeTypeIDs = [],
Metrics = [],
SceneTitle = canonicalScene is not null && !string.IsNullOrWhiteSpace(canonicalScene.SceneTitle) ? canonicalScene.SceneTitle : GenerateTitle(sceneResult.TemporarySceneNumber, summary),
SceneTitle = GenerateTitle(sceneResult.TemporarySceneNumber, summary),
Summary = summary,
PurposeNotes = $"Story Intelligence import source: run {sceneResult.StoryIntelligenceRunID:N0}, paragraphs {DisplayRange(sceneResult.StartParagraph, sceneResult.EndParagraph)}.{Environment.NewLine}Some optional analysis could not be read, so this scene was created with limited detail.",
OutcomeNotes = null,
ImportNoteText = "Scene created with limited Story Intelligence detail. Review the source manuscript for POV, setting, metrics and timeline notes.",
AnalysisStatus = "Analysis failed",
HasWarnings = true
ImportNoteText = "Scene created with limited Story Intelligence detail. Review the source manuscript for POV, setting, metrics and timeline notes."
};
}
private static IReadOnlyList<StoryIntelligenceSceneImportItem> BuildReviewScenes(
IReadOnlyList<Scene> canonicalScenes,
IReadOnlyList<StoryIntelligenceSavedSceneResult> sceneResults,
IReadOnlyList<StoryIntelligenceSceneImportItem> importScenes)
{
if (canonicalScenes.Count == 0)
{
return importScenes;
}
var resultsBySceneId = sceneResults
.Where(result => result.SceneID.HasValue)
.GroupBy(result => result.SceneID!.Value)
.ToDictionary(
group => group.Key,
group => group
.OrderBy(result => result.ValidationErrorsCount > 0)
.ThenByDescending(result => result.SceneResultID)
.First());
var importsBySceneId = importScenes
.Where(scene => scene.SceneID.HasValue)
.GroupBy(scene => scene.SceneID!.Value)
.ToDictionary(group => group.Key, group => group.First());
return canonicalScenes
.OrderBy(scene => scene.SortOrder)
.ThenBy(scene => scene.SceneNumber)
.ThenBy(scene => scene.SceneID)
.Select((scene, index) =>
{
var sceneNumber = SceneDisplayNumber(scene.SceneNumber, index + 1);
resultsBySceneId.TryGetValue(scene.SceneID, out var result);
importsBySceneId.TryGetValue(scene.SceneID, out var importScene);
var parsed = result is null ? null : TryReadScene(result);
var summary = CanonicalSummary(scene)
?? FirstConfigured(importScene?.Summary, parsed?.Summary?.Short, parsed?.Summary?.Detailed, $"Scene {sceneNumber:N0}")
?? $"Scene {sceneNumber:N0}";
return new StoryIntelligenceSceneImportItem
{
SceneResultID = result?.SceneResultID ?? 0,
SceneID = scene.SceneID,
TemporarySceneNumber = sceneNumber,
StartParagraph = result?.StartParagraph,
EndParagraph = result?.EndParagraph,
SourceLabel = result?.SourceLabel ?? $"Scene {sceneNumber:N0}",
ParsedScene = parsed ?? new SceneIntelligenceScene(),
PovCharacterID = importScene?.PovCharacterID,
PurposeTypeIDs = importScene?.PurposeTypeIDs ?? [],
Metrics = importScene?.Metrics ?? [],
SceneTitle = string.IsNullOrWhiteSpace(scene.SceneTitle) ? $"Scene {sceneNumber:N0}" : scene.SceneTitle,
Summary = summary,
PurposeNotes = importScene?.PurposeNotes,
OutcomeNotes = null,
ImportNoteText = importScene?.ImportNoteText,
AnalysisStatus = result is null
? "Analysis missing"
: result.ValidationErrorsCount > 0
? "Analysis failed"
: "Ready",
HasWarnings = result is null || result.ValidationErrorsCount > 0 || result.ValidationWarningsCount > 0
};
})
.ToList();
}
private async Task<Dictionary<string, int>> BuildCharacterMapAsync(int projectId)
{
var map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
@ -438,16 +353,6 @@ public sealed class StoryIntelligenceImportCommitService(
{
var byName = metricTypes.ToDictionary(metric => Clean(metric.MetricName), metric => metric, StringComparer.OrdinalIgnoreCase);
var map = new Dictionary<string, SceneMetricType>(StringComparer.OrdinalIgnoreCase);
foreach (var metric in metricTypes)
{
map[$"metric_{metric.MetricTypeID}"] = metric;
var cleanedName = Clean(metric.MetricName);
if (!string.IsNullOrWhiteSpace(cleanedName))
{
map.TryAdd(cleanedName, metric);
}
}
foreach (var (schemaName, aliases) in MetricAliases)
{
var metric = aliases.Select(alias => byName.TryGetValue(Clean(alias), out var match) ? match : null).FirstOrDefault(match => match is not null);
@ -950,17 +855,6 @@ public sealed class StoryIntelligenceImportCommitService(
? $"Scene {sceneNumber:N0}"
: $"Scene {sceneNumber:N0} - {TrimTo(summary, 60)}";
private static int SceneDisplayNumber(decimal sceneNumber, int ordinal)
=> decimal.Truncate(sceneNumber) == sceneNumber && sceneNumber > 0 && sceneNumber <= int.MaxValue
? Convert.ToInt32(sceneNumber)
: ordinal;
private static string? CanonicalSummary(Scene? scene)
=> scene is null ? null : FirstConfigured(scene.StructuralSummary, scene.Summary);
private static string? FirstConfigured(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim();
private static string TrimTo(string value, int maxLength)
=> value.Length <= maxLength ? value : value[..maxLength].TrimEnd() + "...";

View File

@ -16,8 +16,7 @@ public static partial class StoryIntelligenceJsonRepair
}
warnings.AddRange(extraction.Warnings);
var numericWhitespaceRepaired = RepairWhitespaceInDecimalNumbers(extraction.Json, warnings);
var numericRepaired = RepairTrailingDecimalNumbers(numericWhitespaceRepaired, warnings);
var numericRepaired = RepairTrailingDecimalNumbers(extraction.Json, warnings);
var slashRepaired = RepairInvalidBackslashesInStrings(numericRepaired, warnings);
if (!IsValidJson(slashRepaired, out var error))
@ -134,162 +133,6 @@ public static partial class StoryIntelligenceJsonRepair
return repaired;
}
private static string RepairWhitespaceInDecimalNumbers(string value, List<string> warnings)
{
var builder = new StringBuilder(value.Length);
var inString = false;
var escaped = false;
var changed = false;
for (var i = 0; i < value.Length; i++)
{
var ch = value[i];
if (inString)
{
builder.Append(ch);
if (escaped)
{
escaped = false;
}
else if (ch == '\\')
{
escaped = true;
}
else if (ch == '"')
{
inString = false;
}
continue;
}
if (ch == '"')
{
inString = true;
builder.Append(ch);
continue;
}
if ((ch == '-' || char.IsDigit(ch))
&& IsJsonNumberValueStart(value, i)
&& TryReadWhitespaceDecimalNumber(value, i, out var repairedNumber, out var endIndex))
{
builder.Append(repairedNumber);
i = endIndex - 1;
changed = true;
continue;
}
builder.Append(ch);
}
if (changed)
{
warnings.Add("Normalised whitespace inside decimal JSON number.");
}
return builder.ToString();
}
private static bool TryReadWhitespaceDecimalNumber(string value, int startIndex, out string repairedNumber, out int endIndex)
{
repairedNumber = string.Empty;
endIndex = startIndex;
var index = startIndex;
var builder = new StringBuilder();
var removedWhitespace = false;
if (value[index] == '-')
{
builder.Append(value[index]);
index++;
if (index >= value.Length || !char.IsDigit(value[index]))
{
return false;
}
}
var integerStart = index;
while (index < value.Length && char.IsDigit(value[index]))
{
builder.Append(value[index]);
index++;
}
if (index == integerStart)
{
return false;
}
var beforeDecimalWhitespaceStart = index;
while (index < value.Length && char.IsWhiteSpace(value[index]))
{
index++;
}
if (index >= value.Length || value[index] != '.')
{
return false;
}
removedWhitespace = index > beforeDecimalWhitespaceStart;
builder.Append('.');
index++;
var afterDecimalWhitespaceStart = index;
while (index < value.Length && char.IsWhiteSpace(value[index]))
{
index++;
}
removedWhitespace = removedWhitespace || index > afterDecimalWhitespaceStart;
var fractionStart = index;
while (index < value.Length && char.IsDigit(value[index]))
{
builder.Append(value[index]);
index++;
}
if (!removedWhitespace || index == fractionStart || !IsJsonValueEnd(value, index))
{
return false;
}
repairedNumber = builder.ToString();
endIndex = index;
return true;
}
private static bool IsJsonNumberValueStart(string value, int startIndex)
{
for (var i = startIndex - 1; i >= 0; i--)
{
if (char.IsWhiteSpace(value[i]))
{
continue;
}
return value[i] is ':' or '[' or ',';
}
return false;
}
private static bool IsJsonValueEnd(string value, int index)
{
for (var i = index; i < value.Length; i++)
{
if (char.IsWhiteSpace(value[i]))
{
continue;
}
return value[i] is ',' or '}' or ']';
}
return true;
}
private static string RepairInvalidBackslashesInStrings(string value, List<string> warnings)
{
var builder = new StringBuilder(value.Length);
@ -397,11 +240,7 @@ public static class StoryIntelligenceAiJsonParser
var repair = StoryIntelligenceJsonRepair.Repair(rawAssistantOutput);
if (!repair.Success)
{
return StoryIntelligenceAiJsonParseResult<T>.Failed(
extraction.Json,
repair.ErrorMessage ?? strictEx.Message,
strictEx.Message,
strictEx.Path);
return StoryIntelligenceAiJsonParseResult<T>.Failed(extraction.Json, repair.ErrorMessage ?? strictEx.Message);
}
try
@ -412,13 +251,11 @@ public static class StoryIntelligenceAiJsonParser
extraction.Json,
repair.RepairedJson,
parsed,
warnings,
strictEx.Message,
strictEx.Path);
warnings);
}
catch (JsonException repairedEx)
{
return StoryIntelligenceAiJsonParseResult<T>.Failed(extraction.Json, repairedEx.Message, strictEx.Message, strictEx.Path);
return StoryIntelligenceAiJsonParseResult<T>.Failed(extraction.Json, repairedEx.Message);
}
}
}
@ -428,8 +265,7 @@ public static class StoryIntelligenceAiJsonParser
var warnings = new List<string>();
foreach (var warning in repairWarnings)
{
if (warning.Contains("trailing decimal", StringComparison.OrdinalIgnoreCase)
|| warning.Contains("whitespace inside decimal", StringComparison.OrdinalIgnoreCase))
if (warning.Contains("trailing decimal", StringComparison.OrdinalIgnoreCase))
{
warnings.Add(string.IsNullOrWhiteSpace(strictErrorPath)
? "Repaired malformed AI JSON numeric value."
@ -474,23 +310,15 @@ public sealed record StoryIntelligenceAiJsonParseResult<T>(
string? RepairedJson,
T? Parsed,
IReadOnlyList<string> Warnings,
string? ErrorMessage,
string? InitialErrorMessage = null,
string? InitialErrorPath = null)
string? ErrorMessage)
{
public static StoryIntelligenceAiJsonParseResult<T> Ok(
string rawJson,
string? repairedJson,
T? parsed,
IReadOnlyList<string> warnings,
string? initialErrorMessage = null,
string? initialErrorPath = null)
=> new(true, rawJson, repairedJson, parsed, warnings, null, initialErrorMessage, initialErrorPath);
IReadOnlyList<string> warnings)
=> new(true, rawJson, repairedJson, parsed, warnings, null);
public static StoryIntelligenceAiJsonParseResult<T> Failed(
string rawJson,
string errorMessage,
string? initialErrorMessage = null,
string? initialErrorPath = null)
=> new(false, rawJson, null, default, [], errorMessage, initialErrorMessage, initialErrorPath);
public static StoryIntelligenceAiJsonParseResult<T> Failed(string rawJson, string errorMessage)
=> new(false, rawJson, null, default, [], errorMessage);
}

View File

@ -79,23 +79,15 @@ public sealed class StoryIntelligenceKnowledgeImportService(
SaveKnowledgeSelections(batch, choices.Values);
if (choices.Count == 0)
{
var decidedKeys = batch.KnowledgeDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
var hasUndecidedCandidates = data.Candidates.Any(candidate => !decidedKeys.Contains(candidate.Key));
if (!hasUndecidedCandidates)
if (data.Candidates.Count == 0)
{
batch.KnowledgeStageComplete = true;
batch.LastKnowledgeImportResult ??= new StoryIntelligenceKnowledgeImportBatchResult
{
KnowledgeCreated = batch.KnowledgeDecisions.Count(decision => string.Equals(decision.Action, StoryIntelligenceKnowledgeImportActions.CreateNew, StringComparison.OrdinalIgnoreCase)),
KnowledgeLinked = batch.KnowledgeDecisions.Count(decision => string.Equals(decision.Action, StoryIntelligenceKnowledgeImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase)),
KnowledgeMerged = batch.KnowledgeDecisions.Count(decision => string.Equals(decision.Action, StoryIntelligenceKnowledgeImportActions.Alias, StringComparison.OrdinalIgnoreCase)),
KnowledgeIgnored = batch.KnowledgeDecisions.Count(decision => string.Equals(decision.Action, StoryIntelligenceKnowledgeImportActions.Ignore, StringComparison.OrdinalIgnoreCase))
};
batch.LastKnowledgeImportResult = new StoryIntelligenceKnowledgeImportBatchResult();
await pipelineState.RecordKnowledgeImportAsync(batch.ProjectID, batch.BookID);
return new StoryIntelligenceImportCommitResult
{
Success = true,
Message = "Knowledge review completed. No knowledge decisions are waiting."
Message = "Knowledge review completed. No knowledge decisions were waiting."
};
}
@ -237,7 +229,6 @@ public sealed class StoryIntelligenceKnowledgeImportService(
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID))
.Where(scene => scene.ImportRunID == item.RunID)
.ToList();
var importedById = importedScenes.ToDictionary(scene => scene.SceneID, scene => scene);
var importedByRange = importedScenes
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
@ -252,7 +243,7 @@ public sealed class StoryIntelligenceKnowledgeImportService(
continue;
}
var importedScene = ResolveImportedScene(sceneResult, importedById, importedByRange, importedByNumber);
var importedScene = ResolveImportedScene(sceneResult, importedByRange, importedByNumber);
if (importedScene is null)
{
continue;
@ -419,15 +410,9 @@ public sealed class StoryIntelligenceKnowledgeImportService(
private static Scene? ResolveImportedScene(
StoryIntelligenceSavedSceneResult sceneResult,
IReadOnlyDictionary<int, Scene> byId,
IReadOnlyDictionary<string, Scene> byRange,
IReadOnlyDictionary<int, Scene> byNumber)
{
if (sceneResult.SceneID.HasValue && byId.TryGetValue(sceneResult.SceneID.Value, out var idMatch))
{
return idMatch;
}
if (sceneResult.StartParagraph.HasValue
&& sceneResult.EndParagraph.HasValue
&& byRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var rangeMatch))
@ -616,14 +601,14 @@ public sealed class StoryIntelligenceKnowledgeImportService(
private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed)
{
var summary = Clean(FirstConfigured(scene.StructuralSummary, scene.Summary, parsed.Summary?.Short));
var summary = Clean(parsed.Summary?.Short);
return string.IsNullOrWhiteSpace(summary)
? BuildSceneReference(scene)
: $"{BuildSceneReference(scene)}: {summary}";
}
private static string BuildSceneReference(Scene scene)
=> $"Chapter {scene.ChapterNumber:g}, Scene {scene.SceneNumber:g}";
=> $"Scene {scene.SceneNumber:g}";
private static bool IsCharacterEntity(string? entityType)
=> string.Equals(Clean(entityType), "Character", StringComparison.OrdinalIgnoreCase);

View File

@ -276,7 +276,6 @@ public sealed class StoryIntelligenceLocationImportService(
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID))
.Where(scene => scene.ImportRunID == item.RunID)
.ToList();
var importedById = importedScenes.ToDictionary(scene => scene.SceneID, scene => scene);
var importedByRange = importedScenes
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
@ -291,7 +290,7 @@ public sealed class StoryIntelligenceLocationImportService(
continue;
}
var importedScene = ResolveImportedScene(sceneResult, importedById, importedByRange, importedByNumber);
var importedScene = ResolveImportedScene(sceneResult, importedByRange, importedByNumber);
if (importedScene is null)
{
continue;
@ -435,15 +434,9 @@ public sealed class StoryIntelligenceLocationImportService(
private static Scene? ResolveImportedScene(
StoryIntelligenceSavedSceneResult sceneResult,
IReadOnlyDictionary<int, Scene> byId,
IReadOnlyDictionary<string, Scene> byRange,
IReadOnlyDictionary<int, Scene> byNumber)
{
if (sceneResult.SceneID.HasValue && byId.TryGetValue(sceneResult.SceneID.Value, out var idMatch))
{
return idMatch;
}
if (sceneResult.StartParagraph.HasValue
&& sceneResult.EndParagraph.HasValue
&& byRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var rangeMatch))
@ -735,15 +728,12 @@ public sealed class StoryIntelligenceLocationImportService(
private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed, string locationName)
{
var summary = Clean(FirstConfigured(scene.StructuralSummary, scene.Summary, parsed.Summary?.Short));
var summary = Clean(parsed.Summary?.Short);
return string.IsNullOrWhiteSpace(summary)
? $"{BuildSceneReference(scene)}: {locationName}"
: $"{BuildSceneReference(scene)}: {summary}";
? $"Scene {scene.SceneNumber:g}: {locationName}"
: $"Scene {scene.SceneNumber:g}: {summary}";
}
private static string BuildSceneReference(Scene scene)
=> $"Chapter {scene.ChapterNumber:g}, Scene {scene.SceneNumber:g}";
private static string FirstConfigured(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty;

View File

@ -1,638 +0,0 @@
using System.Text.Json;
using Microsoft.AspNetCore.Mvc.Rendering;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface IStoryIntelligenceNarrativeReviewService
{
Task<StoryIntelligenceNarrativeReviewViewModel> GetReviewAsync(OnboardingStoryIntelligenceBatch batch);
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceNarrativeImportForm form, int userId);
Task<StoryIntelligenceNarrativeImportResultViewModel?> GetResultAsync(OnboardingStoryIntelligenceBatch batch);
}
public sealed class StoryIntelligenceNarrativeReviewService(
IStoryIntelligenceNarrativeCandidateRepository candidates,
IStoryIntelligenceResultRepository runs,
ISceneRepository scenes,
IPlotRepository plots,
IStoryIntelligencePipelineStateService pipelineState) : IStoryIntelligenceNarrativeReviewService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
public async Task<StoryIntelligenceNarrativeReviewViewModel> GetReviewAsync(OnboardingStoryIntelligenceBatch batch)
{
await EnsureCandidatesAsync(batch);
var rows = await candidates.ListByBookAsync(batch.BookID);
var pending = rows
.Where(candidate => string.Equals(candidate.Status, StoryIntelligenceNarrativeCandidateStatuses.Pending, StringComparison.OrdinalIgnoreCase))
.ToList();
var plotLines = await plots.ListPlotLinesAsync(batch.ProjectID);
return new StoryIntelligenceNarrativeReviewViewModel
{
HasCommittedScenes = await HasCommittedScenesAsync(batch),
CanImport = pending.Count > 0,
IsComplete = rows.Count > 0 && pending.Count == 0,
PlotLineOptions = plotLines
.OrderBy(plotLine => plotLine.SortOrder)
.ThenBy(plotLine => plotLine.PlotLineName)
.Select(plotLine => new SelectListItem(plotLine.PlotLineName, plotLine.PlotLineID.ToString()))
.ToList(),
Candidates = pending.Select(ToViewModel).ToList()
};
}
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceNarrativeImportForm form, int userId)
{
await EnsureCandidatesAsync(batch);
var rows = (await candidates.ListByBookAsync(batch.BookID)).ToDictionary(candidate => candidate.NarrativeCandidateID);
var lookups = await plots.GetLookupsAsync();
var plotLineTypeId = lookups.PlotLineTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Main Plot", StringComparison.OrdinalIgnoreCase))?.PlotLineTypeID
?? lookups.PlotLineTypes.FirstOrDefault()?.PlotLineTypeID
?? 0;
var plotImportanceId = lookups.PlotImportance.FirstOrDefault(importance => string.Equals(importance.ImportanceName, "Primary", StringComparison.OrdinalIgnoreCase))?.PlotImportanceID
?? lookups.PlotImportance.FirstOrDefault()?.PlotImportanceID
?? 0;
var threadTypeId = lookups.ThreadTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Question", StringComparison.OrdinalIgnoreCase))?.ThreadTypeID
?? lookups.ThreadTypes.FirstOrDefault()?.ThreadTypeID
?? 0;
var threadStatusId = lookups.ThreadStatuses.FirstOrDefault(status => string.Equals(status.StatusName, "Open", StringComparison.OrdinalIgnoreCase))?.ThreadStatusID
?? lookups.ThreadStatuses.FirstOrDefault()?.ThreadStatusID
?? 0;
var threadEventTypeId = lookups.ThreadEventTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Raised", StringComparison.OrdinalIgnoreCase))?.ThreadEventTypeID
?? lookups.ThreadEventTypes.FirstOrDefault()?.ThreadEventTypeID
?? 0;
var plotEventTypeId = lookups.PlotEventTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Progress", StringComparison.OrdinalIgnoreCase))?.PlotEventTypeID
?? lookups.PlotEventTypes.FirstOrDefault()?.PlotEventTypeID
?? 0;
var plotLinesCreated = 0;
var plotLinesMerged = 0;
var threadsCreated = 0;
var rejected = 0;
foreach (var choice in form.Candidates.Where(choice => choice.NarrativeCandidateID > 0))
{
if (!rows.TryGetValue(choice.NarrativeCandidateID, out var candidate)
|| !string.Equals(candidate.Status, StoryIntelligenceNarrativeCandidateStatuses.Pending, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var action = string.IsNullOrWhiteSpace(choice.Action) ? StoryIntelligenceNarrativeReviewActions.AcceptNew : choice.Action;
if (string.Equals(action, StoryIntelligenceNarrativeReviewActions.Reject, StringComparison.OrdinalIgnoreCase))
{
await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Rejected, action, userId, null, null);
rejected++;
continue;
}
if (string.Equals(candidate.CandidateType, StoryIntelligenceNarrativeCandidateTypes.PlotLine, StringComparison.OrdinalIgnoreCase))
{
if (string.Equals(action, StoryIntelligenceNarrativeReviewActions.MergeExisting, StringComparison.OrdinalIgnoreCase) && choice.ExistingPlotLineID is > 0)
{
await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Merged, action, userId, choice.ExistingPlotLineID, null);
plotLinesMerged++;
continue;
}
var plotLineId = await plots.SavePlotLineAsync(new PlotLineItem
{
ProjectID = batch.ProjectID,
BookID = batch.BookID,
PlotLineName = CleanTitle(choice.Title, candidate.ProposedTitle),
Description = CleanNullable(choice.Description) ?? candidate.ProposedDescription,
PlotLineTypeID = plotLineTypeId,
PlotImportanceID = plotImportanceId,
SortOrder = 0,
Colour = "#2f6f63",
IsVisibleOnTimeline = true
});
await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Accepted, action, userId, plotLineId, null);
plotLinesCreated++;
continue;
}
var relatedPlotLineId = choice.RelatedPlotLineID ?? candidate.RelatedPlotLineID ?? choice.ExistingPlotLineID;
if (relatedPlotLineId is not > 0)
{
await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Rejected, StoryIntelligenceNarrativeReviewActions.Reject, userId, null, null);
rejected++;
continue;
}
var firstSceneId = candidate.Evidence.OrderBy(evidence => evidence.SortOrder).FirstOrDefault()?.SceneID;
var threadId = await plots.SavePlotThreadAsync(new PlotThread
{
PlotLineID = relatedPlotLineId.Value,
ThreadTitle = CleanTitle(choice.Title, candidate.ProposedTitle),
Summary = CleanNullable(choice.Description) ?? candidate.ProposedDescription,
ThreadTypeID = threadTypeId,
ThreadStatusID = threadStatusId,
Importance = 5,
IntroducedSceneID = firstSceneId
});
foreach (var evidence in candidate.Evidence.Where(evidence => evidence.SceneID > 0))
{
await plots.SaveThreadEventAsync(new ThreadEvent
{
PlotThreadID = threadId,
SceneID = evidence.SceneID,
EventTypeID = threadEventTypeId,
PlotEventTypeID = plotEventTypeId,
EventTitle = string.IsNullOrWhiteSpace(evidence.ContributionType) ? candidate.ProposedTitle : evidence.ContributionType,
EventDescription = evidence.Evidence
});
}
await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Accepted, action, userId, null, threadId);
threadsCreated++;
}
batch.LastNarrativeImportResult = new StoryIntelligenceNarrativeImportBatchResult
{
PlotLinesCreated = plotLinesCreated,
PlotLinesMerged = plotLinesMerged,
ThreadsCreated = threadsCreated,
CandidatesRejected = rejected
};
batch.NarrativeStageComplete = true;
await pipelineState.RecordNarrativeImportAsync(batch.ProjectID, batch.BookID);
return new StoryIntelligenceImportCommitResult
{
Success = true,
Message = $"Narrative review applied. {plotLinesCreated:N0} Plot Line(s) created, {threadsCreated:N0} Thread(s) created, {plotLinesMerged:N0} merged, {rejected:N0} rejected."
};
}
public Task<StoryIntelligenceNarrativeImportResultViewModel?> GetResultAsync(OnboardingStoryIntelligenceBatch batch)
=> Task.FromResult<StoryIntelligenceNarrativeImportResultViewModel?>(new StoryIntelligenceNarrativeImportResultViewModel
{
BatchID = batch.BatchID,
ProjectID = batch.ProjectID,
BookID = batch.BookID,
PlotLinesCreated = batch.LastNarrativeImportResult?.PlotLinesCreated ?? 0,
PlotLinesMerged = batch.LastNarrativeImportResult?.PlotLinesMerged ?? 0,
ThreadsCreated = batch.LastNarrativeImportResult?.ThreadsCreated ?? 0,
CandidatesRejected = batch.LastNarrativeImportResult?.CandidatesRejected ?? 0
});
private async Task EnsureCandidatesAsync(OnboardingStoryIntelligenceBatch batch)
{
var existingPlotLines = await plots.ListPlotLinesAsync(batch.ProjectID);
var existingThreads = await plots.ListPlotThreadsByProjectAsync(batch.ProjectID);
var existingThreadByKey = existingThreads
.GroupBy(thread => Key(thread.ThreadTitle), StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
var signalInputs = new List<StoryIntelligenceNarrativeSignalInput>();
foreach (var item in batch.Items)
{
var commit = await runs.GetImportCommitAsync(item.RunID);
if (commit is null || !string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID))
.Where(scene => scene.ImportRunID == item.RunID)
.ToList();
var importedById = importedScenes.ToDictionary(scene => scene.SceneID, scene => scene);
var importedByRange = importedScenes
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
var importedByNumber = importedScenes.ToDictionary(scene => Convert.ToInt32(scene.SceneNumber), scene => scene);
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
foreach (var sceneResult in sceneResults)
{
var importedScene = ResolveImportedScene(sceneResult, importedById, importedByRange, importedByNumber);
var parsed = TryReadScene(sceneResult);
if (importedScene is null || parsed is null)
{
continue;
}
foreach (var arc in parsed.NarrativeArcs ?? [])
{
var category = string.Equals(arc.Scale, "thread", StringComparison.OrdinalIgnoreCase)
? "Thread"
: "Plot Line";
signalInputs.Add(new StoryIntelligenceNarrativeSignalInput(
importedScene.SceneID,
CleanNullable(arc.Title),
CleanNullable(arc.Description),
category,
CleanNullable(arc.ArcType) ?? "Narrative arc",
arc.Confidence,
arc.MatchingExistingPlotLineId,
arc.Evidence));
}
foreach (var question in parsed.QuestionsRaised ?? [])
{
signalInputs.Add(new StoryIntelligenceNarrativeSignalInput(
importedScene.SceneID,
CleanNullable(question.Question),
null,
"Thread",
"Open",
question.Confidence,
null,
question.Evidence));
}
foreach (var clue in parsed.TimelineClues ?? [])
{
signalInputs.Add(new StoryIntelligenceNarrativeSignalInput(
importedScene.SceneID,
CleanNullable(clue.Clue),
null,
"Thread",
"Introduced",
clue.Confidence,
null,
clue.Evidence));
}
}
}
var signals = StoryIntelligenceNarrativeSignalConsolidator.Consolidate(signalInputs, existingPlotLines);
foreach (var signal in signals.Where(signal => signal.SceneIds.Count > 1).OrderBy(signal => signal.Title))
{
var existingThread = existingThreadByKey.GetValueOrDefault(Key(signal.Title));
var candidateType = string.Equals(signal.Category, "Plot Line", StringComparison.OrdinalIgnoreCase) || (signal.ExistingPlotLineID.HasValue && existingThread is null)
? StoryIntelligenceNarrativeCandidateTypes.PlotLine
: StoryIntelligenceNarrativeCandidateTypes.Thread;
var candidateId = await candidates.SaveAsync(new StoryIntelligenceNarrativeCandidateSeed
{
ProjectID = batch.ProjectID,
BookID = batch.BookID,
CandidateType = candidateType,
CandidateKey = $"{candidateType}:{Key(signal.Title)}",
ProposedTitle = signal.Title,
ProposedDescription = signal.Description,
Category = signal.Category,
InferredState = signal.State,
ExistingPlotLineID = signal.ExistingPlotLineID,
ExistingPlotThreadID = existingThread?.PlotThreadID,
RelatedPlotLineID = existingThread?.PlotLineID,
Confidence = signal.Confidence
});
var sortOrder = 0;
foreach (var item in signal.Evidence.OrderBy(item => item.SceneID))
{
await candidates.SaveEvidenceAsync(candidateId, item.SceneID, item.ContributionType, item.Evidence, sortOrder++);
}
}
}
private async Task<bool> HasCommittedScenesAsync(OnboardingStoryIntelligenceBatch batch)
{
foreach (var item in batch.Items)
{
var commit = await runs.GetImportCommitAsync(item.RunID);
if (commit is not null && string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static StoryIntelligenceNarrativeReviewCandidateViewModel ToViewModel(StoryIntelligenceNarrativeCandidate candidate)
=> new()
{
NarrativeCandidateID = candidate.NarrativeCandidateID,
CandidateType = candidate.CandidateType,
ProposedTitle = candidate.ProposedTitle,
ProposedDescription = candidate.ProposedDescription ?? string.Empty,
Category = candidate.Category ?? string.Empty,
InferredState = candidate.InferredState ?? string.Empty,
Confidence = candidate.Confidence.HasValue ? $"{candidate.Confidence.Value:P0}" : "Unknown",
ExistingPlotLineID = candidate.ExistingPlotLineID,
RelatedPlotLineID = candidate.RelatedPlotLineID,
RelatedPlotLineName = candidate.RelatedPlotLineName,
DefaultAction = candidate.ExistingPlotLineID.HasValue || candidate.ExistingPlotThreadID.HasValue
? StoryIntelligenceNarrativeReviewActions.MergeExisting
: StoryIntelligenceNarrativeReviewActions.AcceptNew,
Evidence = candidate.Evidence.Select(evidence => new StoryIntelligenceNarrativeEvidenceViewModel
{
SceneID = evidence.SceneID,
SceneLabel = $"Chapter {evidence.ChapterNumber:0.##}, Scene {evidence.SceneNumber:0.##}: {evidence.SceneTitle}",
ContributionType = evidence.ContributionType ?? string.Empty,
Evidence = evidence.Evidence ?? string.Empty
}).ToList()
};
private static Scene? ResolveImportedScene(
StoryIntelligenceSavedSceneResult sceneResult,
IReadOnlyDictionary<int, Scene> importedById,
IReadOnlyDictionary<string, Scene> importedByRange,
IReadOnlyDictionary<int, Scene> importedByNumber)
{
if (sceneResult.SceneID.HasValue && importedById.TryGetValue(sceneResult.SceneID.Value, out var byId))
{
return byId;
}
if (sceneResult.StartParagraph.HasValue
&& sceneResult.EndParagraph.HasValue
&& importedByRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var byRange))
{
return byRange;
}
return importedByNumber.TryGetValue(sceneResult.TemporarySceneNumber, out var byNumber) ? byNumber : null;
}
private static SceneIntelligenceScene? TryReadScene(StoryIntelligenceSavedSceneResult result)
{
foreach (var json in new[] { result.ParsedJson, result.OutputTextJson })
{
if (string.IsNullOrWhiteSpace(json))
{
continue;
}
try
{
var direct = JsonSerializer.Deserialize<SceneIntelligenceScene>(json, JsonOptions);
if (direct?.Summary is not null)
{
return direct;
}
using var document = JsonDocument.Parse(json);
if (document.RootElement.TryGetProperty("parsedScene", out var parsedScene))
{
return parsedScene.Deserialize<SceneIntelligenceScene>(JsonOptions);
}
}
catch (JsonException)
{
// Try the next persisted JSON form.
}
}
return null;
}
private static string CleanTitle(string? preferred, string fallback)
=> string.IsNullOrWhiteSpace(preferred) ? fallback.Trim() : preferred.Trim();
private static string? CleanNullable(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static string Key(string? value)
=> StoryIntelligenceNarrativeSignalConsolidator.Key(value);
}
public sealed record StoryIntelligenceNarrativeSignalInput(
int SceneID,
string? Title,
string? Description,
string Category,
string State,
decimal? Confidence,
int? MatchingExistingPlotLineID,
string? Evidence);
public sealed record StoryIntelligenceNarrativeSignalEvidence(int SceneID, string ContributionType, string? Evidence);
public sealed record StoryIntelligenceNarrativeConsolidatedSignal(
string Title,
string Description,
string Category,
string State,
int? ExistingPlotLineID,
decimal? Confidence,
IReadOnlyCollection<int> SceneIds,
IReadOnlyList<StoryIntelligenceNarrativeSignalEvidence> Evidence);
public static class StoryIntelligenceNarrativeSignalConsolidator
{
private static readonly HashSet<string> StopWords = new(StringComparer.OrdinalIgnoreCase)
{
"a", "an", "and", "are", "as", "at", "be", "by", "for", "from", "has", "have", "her", "his", "how", "in", "into",
"is", "it", "its", "of", "on", "or", "she", "he", "that", "the", "their", "them", "they", "this", "to", "what",
"when", "where", "who", "why", "with", "about", "after", "before", "looks", "looking", "case", "plot", "line",
"story", "arc"
};
private static readonly Dictionary<string, string> ArcFamilies = new(StringComparer.OrdinalIgnoreCase)
{
["absence"] = "disappearance",
["absent"] = "disappearance",
["clue"] = "disappearance",
["clues"] = "disappearance",
["disappear"] = "disappearance",
["disappearance"] = "disappearance",
["disappeared"] = "disappearance",
["disappears"] = "disappearance",
["find"] = "disappearance",
["finding"] = "disappearance",
["found"] = "disappearance",
["investigate"] = "disappearance",
["investigates"] = "disappearance",
["investigation"] = "disappearance",
["lead"] = "disappearance",
["leads"] = "disappearance",
["missing"] = "disappearance",
["search"] = "disappearance",
["searches"] = "disappearance",
["searching"] = "disappearance",
["suspect"] = "disappearance",
["suspects"] = "disappearance",
["gone"] = "disappearance",
["went"] = "disappearance",
["conspiracy"] = "conspiracy",
["quest"] = "quest",
["romance"] = "romance",
["secret"] = "secret",
["secrets"] = "secret",
["revenge"] = "revenge",
["rivalry"] = "rivalry"
};
public static IReadOnlyList<StoryIntelligenceNarrativeConsolidatedSignal> Consolidate(
IEnumerable<StoryIntelligenceNarrativeSignalInput> inputs,
IReadOnlyList<PlotLineItem> existingPlotLines)
{
var groups = new Dictionary<string, NarrativeSignal>(StringComparer.OrdinalIgnoreCase);
var plotLinesById = existingPlotLines.Where(plotLine => plotLine.PlotLineID > 0).ToDictionary(plotLine => plotLine.PlotLineID);
foreach (var input in inputs)
{
if (string.IsNullOrWhiteSpace(input.Title) || input.Title.Trim().Length < 8)
{
continue;
}
var matchingPlotLine = ResolveExistingPlotLine(input, existingPlotLines, plotLinesById);
var key = matchingPlotLine is not null
? $"existing:{matchingPlotLine.PlotLineID}"
: FindSimilarGroupKey(groups, input) ?? ArcKey(input.Title, input.Description);
if (!groups.TryGetValue(key, out var signal))
{
var terms = Terms($"{input.Title} {input.Description}");
signal = new NarrativeSignal(
input.Title.Trim(),
string.IsNullOrWhiteSpace(input.Description) ? $"{input.Category} recurring across scenes." : input.Description.Trim(),
matchingPlotLine is not null ? "Plot Line" : input.Category,
input.State,
matchingPlotLine?.PlotLineID,
ArcFamily(terms),
terms.ToHashSet(StringComparer.OrdinalIgnoreCase));
groups[key] = signal;
}
signal.SceneIds.Add(input.SceneID);
signal.Confidence = input.Confidence.HasValue
? Math.Max(signal.Confidence ?? 0m, input.Confidence.Value)
: signal.Confidence;
signal.Evidence.Add(new StoryIntelligenceNarrativeSignalEvidence(input.SceneID, input.Category, input.Evidence));
}
return groups.Values
.Select(signal => new StoryIntelligenceNarrativeConsolidatedSignal(
signal.Title,
signal.Description,
signal.Category,
signal.State,
signal.ExistingPlotLineID,
signal.Confidence,
signal.SceneIds,
signal.Evidence))
.ToList();
}
public static string Key(string? value)
=> new((value ?? string.Empty).Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray());
private static PlotLineItem? ResolveExistingPlotLine(
StoryIntelligenceNarrativeSignalInput input,
IReadOnlyList<PlotLineItem> existingPlotLines,
IReadOnlyDictionary<int, PlotLineItem> plotLinesById)
{
if (input.MatchingExistingPlotLineID is > 0 && plotLinesById.TryGetValue(input.MatchingExistingPlotLineID.Value, out var suppliedMatch))
{
return suppliedMatch;
}
return existingPlotLines.FirstOrDefault(plotLine => HasSharedArcIdentity(input, plotLine));
}
private static bool HasSharedArcIdentity(StoryIntelligenceNarrativeSignalInput input, PlotLineItem plotLine)
{
var signalTerms = Terms($"{input.Title} {input.Description}");
var plotLineTerms = Terms($"{plotLine.PlotLineName} {plotLine.Description}");
if (signalTerms.Count == 0 || plotLineTerms.Count == 0)
{
return false;
}
var shared = signalTerms.Intersect(plotLineTerms, StringComparer.OrdinalIgnoreCase).Count();
var smaller = Math.Min(signalTerms.Count, plotLineTerms.Count);
if (shared >= 2 && shared / (decimal)smaller >= 0.5m)
{
return true;
}
if (ArcFamily(signalTerms) == ArcFamily(plotLineTerms)
&& signalTerms.Intersect(plotLineTerms, StringComparer.OrdinalIgnoreCase).Any())
{
return true;
}
return ArcKey(input.Title, input.Description) == ArcKey(plotLine.PlotLineName, plotLine.Description);
}
private static string? FindSimilarGroupKey(
IReadOnlyDictionary<string, NarrativeSignal> groups,
StoryIntelligenceNarrativeSignalInput input)
{
var terms = Terms($"{input.Title} {input.Description}");
var family = ArcFamily(terms);
if (terms.Count == 0)
{
return null;
}
foreach (var group in groups)
{
if (group.Value.ExistingPlotLineID.HasValue || !string.Equals(group.Value.Family, family, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (group.Value.Terms.Intersect(terms, StringComparer.OrdinalIgnoreCase).Any())
{
return group.Key;
}
}
return null;
}
private static string ArcKey(string? title, string? description)
{
var terms = Terms($"{title} {description}");
var family = ArcFamily(terms);
var anchors = terms
.Where(term => !ArcFamilies.ContainsKey(term))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(term => term)
.Take(3)
.ToList();
if (anchors.Count == 0)
{
anchors = terms.Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(term => term).Take(3).ToList();
}
return $"{family}:{string.Join('-', anchors)}";
}
private static string ArcFamily(IEnumerable<string> terms)
=> terms.Select(term => ArcFamilies.GetValueOrDefault(term)).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? "general";
private static List<string> Terms(string? value)
=> (value ?? string.Empty)
.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(NormalizeTerm)
.Where(term => term.Length > 2 && !StopWords.Contains(term))
.ToList();
private static string NormalizeTerm(string value)
{
var term = new string(value.Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray());
return term.EndsWith('s') && term.Length > 4 ? term[..^1] : term;
}
private sealed record NarrativeSignal(
string Title,
string Description,
string Category,
string State,
int? ExistingPlotLineID,
string Family,
HashSet<string> Terms)
{
public HashSet<int> SceneIds { get; } = [];
public List<StoryIntelligenceNarrativeSignalEvidence> Evidence { get; } = [];
public decimal? Confidence { get; set; }
}
}

View File

@ -711,15 +711,7 @@ public sealed class ChapterStructureDryRunService(
}
private static ChapterStructureModel? DeserializeChapter(string chapterJson)
{
var result = StoryIntelligenceAiJsonParser.Parse<ChapterStructureModel>(chapterJson, JsonOptions);
if (!result.Success)
{
throw new JsonException(result.ErrorMessage ?? "Chapter Structure JSON could not be parsed.");
}
return result.Parsed;
}
=> JsonSerializer.Deserialize<ChapterStructureModel>(chapterJson, JsonOptions);
private static string FormatDuration(TimeSpan duration)
=> duration.TotalSeconds < 1

View File

@ -1,90 +0,0 @@
using PlotLine.Data;
using PlotLine.Models;
namespace PlotLine.Services;
public interface IStoryIntelligenceEntitlementService
{
Task EnsureCanQueueAsync(int userId);
Task EnsureCanExecuteAsync(int userId);
}
public sealed class StoryIntelligenceEntitlementService(ISubscriptionService subscriptions) : IStoryIntelligenceEntitlementService
{
public Task EnsureCanQueueAsync(int userId) => EnsureAllowedAsync(userId);
public Task EnsureCanExecuteAsync(int userId) => EnsureAllowedAsync(userId);
private async Task EnsureAllowedAsync(int userId)
{
var subscription = await subscriptions.GetCurrentSubscriptionAsync(userId);
if (!IsActive(subscription, DateTime.UtcNow))
{
throw new SubscriptionLimitException("Story Intelligence requires an active subscription before paid AI work can be queued or processed.");
}
}
private static bool IsActive(UserSubscriptionDetail? subscription, DateTime utcNow)
{
if (subscription is null || !subscription.IsActive || !subscription.SubscriptionLevelIsActive)
{
return false;
}
if (subscription.EndDateUTC.HasValue && subscription.EndDateUTC.Value <= utcNow)
{
return false;
}
if (subscription.CurrentPeriodEndUTC.HasValue
&& subscription.CurrentPeriodEndUTC.Value <= utcNow
&& !string.IsNullOrWhiteSpace(subscription.StripeSubscriptionId))
{
return false;
}
var status = (subscription.StripeStatus ?? string.Empty).Trim().ToLowerInvariant();
return status is not ("canceled" or "cancelled" or "incomplete_expired" or "ended" or "expired" or "inactive");
}
}
public interface IStoryIntelligenceCompletionNotificationService
{
Task NotifyIfImportTerminalAsync(StoryIntelligenceQueuedRun run, CancellationToken cancellationToken = default);
}
public sealed class StoryIntelligenceCompletionNotificationService(
IStoryIntelligencePipelineRepository pipelines,
IUserRepository users,
IEmailService emails,
ILogger<StoryIntelligenceCompletionNotificationService> logger) : IStoryIntelligenceCompletionNotificationService
{
public async Task NotifyIfImportTerminalAsync(StoryIntelligenceQueuedRun run, CancellationToken cancellationToken = default)
{
if (run.BookID is not int bookId)
{
return;
}
var target = await pipelines.TryBeginCompletionNotificationAsync(bookId, run.UserID);
if (target is null)
{
return;
}
var user = await users.GetByIdAsync(target.UserID);
if (user is null || string.IsNullOrWhiteSpace(user.Email))
{
logger.LogWarning("Story Intelligence import notification skipped because user {UserID} has no email target.", target.UserID);
return;
}
await emails.SendStoryIntelligenceImportNotificationAsync(user.Email, user.DisplayName, target);
logger.LogInformation(
"Queued Story Intelligence import {NotificationKind} notification for import session {ImportSessionID}, book {BookID}, user {UserID}.",
target.NotificationKind,
target.ImportSessionID,
target.BookID,
target.UserID);
}
}

View File

@ -15,7 +15,6 @@ public interface IStoryIntelligencePipelineStateService
Task RecordAssetImportAsync(int projectId, int bookId);
Task RecordRelationshipImportAsync(int projectId, int bookId);
Task RecordKnowledgeImportAsync(int projectId, int bookId);
Task RecordNarrativeImportAsync(int projectId, int bookId);
Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId);
}
@ -156,28 +155,13 @@ public sealed class StoryIntelligencePipelineStateService(
}
public async Task RecordKnowledgeImportAsync(int projectId, int bookId)
{
await pipelines.UpsertAsync(new StoryIntelligenceBookPipelineSaveRequest
{
ProjectID = projectId,
BookID = bookId,
CurrentStage = StoryIntelligencePipelineStages.NarrativeReview,
LastCompletedStage = StoryIntelligencePipelineStages.KnowledgeImport,
CurrentReviewStage = StoryIntelligencePipelineStages.NarrativeReview,
Status = StoryIntelligencePipelineStatuses.NeedsReview,
CompletedUtc = null,
LastRunID = null
});
}
public async Task RecordNarrativeImportAsync(int projectId, int bookId)
{
await pipelines.UpsertAsync(new StoryIntelligenceBookPipelineSaveRequest
{
ProjectID = projectId,
BookID = bookId,
CurrentStage = StoryIntelligencePipelineStages.Complete,
LastCompletedStage = StoryIntelligencePipelineStages.NarrativeImport,
LastCompletedStage = StoryIntelligencePipelineStages.KnowledgeImport,
CurrentReviewStage = null,
Status = StoryIntelligencePipelineStatuses.Complete,
CompletedUtc = DateTime.UtcNow,

View File

@ -270,7 +270,6 @@ public sealed class StoryIntelligenceRelationshipImportService(
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID))
.Where(scene => scene.ImportRunID == item.RunID)
.ToList();
var importedById = importedScenes.ToDictionary(scene => scene.SceneID, scene => scene);
var importedByRange = importedScenes
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
@ -285,7 +284,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
continue;
}
var importedScene = ResolveImportedScene(sceneResult, importedById, importedByRange, importedByNumber);
var importedScene = ResolveImportedScene(sceneResult, importedByRange, importedByNumber);
if (importedScene is null)
{
continue;
@ -416,15 +415,9 @@ public sealed class StoryIntelligenceRelationshipImportService(
private static Scene? ResolveImportedScene(
StoryIntelligenceSavedSceneResult sceneResult,
IReadOnlyDictionary<int, Scene> byId,
IReadOnlyDictionary<string, Scene> byRange,
IReadOnlyDictionary<int, Scene> byNumber)
{
if (sceneResult.SceneID.HasValue && byId.TryGetValue(sceneResult.SceneID.Value, out var idMatch))
{
return idMatch;
}
if (sceneResult.StartParagraph.HasValue
&& sceneResult.EndParagraph.HasValue
&& byRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var rangeMatch))
@ -623,14 +616,14 @@ public sealed class StoryIntelligenceRelationshipImportService(
private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed)
{
var summary = Clean(FirstConfigured(scene.StructuralSummary, scene.Summary, parsed.Summary?.Short));
var summary = Clean(parsed.Summary?.Short);
return string.IsNullOrWhiteSpace(summary)
? BuildSceneReference(scene)
: $"{BuildSceneReference(scene)}: {summary}";
}
private static string BuildSceneReference(Scene scene)
=> $"Chapter {scene.ChapterNumber:g}, Scene {scene.SceneNumber:g}";
=> $"Scene {scene.SceneNumber:g}";
private static void AddDecision(OnboardingStoryIntelligenceBatch batch, string key, string action, int? relationshipId, string? label, bool createdOrLinked)
{

View File

@ -18,7 +18,6 @@ public sealed class StoryIntelligenceResultPersistenceService(
IStoryIntelligenceResultRepository repository,
IStoryIntelligenceClient client,
ISceneImportResolver importResolver,
IStoryIntelligenceEntitlementService entitlement,
ILogger<StoryIntelligenceResultPersistenceService> logger) : IStoryIntelligenceResultPersistenceService
{
private static readonly JsonSerializerOptions JsonOptions = new()
@ -64,7 +63,6 @@ public sealed class StoryIntelligenceResultPersistenceService(
public async Task<int> QueueAdminTextRunAsync(int userId, ChapterStoryIntelligenceDryRunForm form)
{
await entitlement.EnsureCanQueueAsync(userId);
if (string.IsNullOrWhiteSpace(form.ChapterText))
{
throw new InvalidOperationException("Enter chapter text before queueing a persisted Story Intelligence run.");

View File

@ -1,319 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using PlotLine.Data;
using PlotLine.Models;
namespace PlotLine.Services;
public interface IStoryIntelligenceSceneContextService
{
Task<StoryIntelligenceCanonicalSceneContext> BuildAsync(
StoryIntelligenceQueuedRun run,
IReadOnlyList<Scene> orderedChapterScenes,
Scene scene,
SceneManuscriptSourceRecord source,
IReadOnlyList<SceneMetricType> configuredMetrics,
IReadOnlyList<PlotLineItem> existingPlotLines);
}
public sealed class StoryIntelligenceSceneContextService(
IBookRepository books,
IChapterRepository chapters,
ISceneRepository scenes,
ICharacterRepository characters) : IStoryIntelligenceSceneContextService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
WriteIndented = true
};
public async Task<StoryIntelligenceCanonicalSceneContext> BuildAsync(
StoryIntelligenceQueuedRun run,
IReadOnlyList<Scene> orderedChapterScenes,
Scene scene,
SceneManuscriptSourceRecord source,
IReadOnlyList<SceneMetricType> configuredMetrics,
IReadOnlyList<PlotLineItem> existingPlotLines)
{
var book = run.BookID.HasValue ? await books.GetAsync(run.BookID.Value) : null;
var chapter = run.ChapterID.HasValue ? await chapters.GetAsync(run.ChapterID.Value) : null;
var neighbourScenes = await BuildNeighbourScenesAsync(book, scene);
var currentCharacters = await BuildCurrentCharactersAsync(scene);
return new StoryIntelligenceCanonicalSceneContext(
new StoryIntelligenceBookContext(
run.ProjectID ?? book?.ProjectID,
run.BookID ?? book?.BookID,
book?.BookDisplayTitle,
book?.StoryEra,
BookContextOptions.StoryEraLabel(book?.StoryEra),
book?.PrimaryLocaleCode,
BookContextOptions.PrimaryLocaleLabel(book?.PrimaryLocaleCode)),
new StoryIntelligenceChapterContext(
run.ChapterID ?? chapter?.ChapterID,
run.ChapterNumber ?? chapter?.ChapterNumber,
chapter?.SortOrder,
chapter?.ChapterTitle,
chapter?.Summary),
new StoryIntelligenceCurrentSceneContext(
scene.SceneID,
scene.SceneNumber,
scene.SortOrder,
scene.SceneTitle,
FirstNonBlank(scene.StructuralSummary, scene.Summary, source.StructuralSummary),
scene.StructuralSetting,
scene.PrimaryLocationName,
scene.POVCharacterID,
FirstNonBlank(scene.POVCharacterName, scene.POVSuggestedName),
scene.POVSource,
source.SourceStartParagraph,
source.SourceEndParagraph,
source.WordStartParagraphIndex,
source.WordEndParagraphIndex,
source.SourceWordCount,
source.SourceCharacterCount,
source.SourceKind,
source.SourceText),
neighbourScenes,
currentCharacters,
configuredMetrics.Select(metric => new StoryIntelligenceMetricContext(
$"metric_{metric.MetricTypeID}",
metric.MetricTypeID,
metric.MetricName,
metric.Description,
metric.MinValue,
metric.MaxValue,
metric.DefaultValue)).ToList(),
existingPlotLines.Select(plotLine => new StoryIntelligencePlotLineContext(
plotLine.PlotLineID,
plotLine.PlotLineName,
plotLine.Description,
plotLine.PlotImportanceName,
plotLine.BookID)).ToList(),
run.SourceLabel ?? string.Empty);
}
private async Task<IReadOnlyList<StoryIntelligenceNeighbourSceneContext>> BuildNeighbourScenesAsync(Book? book, Scene currentScene)
{
if (book is null)
{
return [];
}
var orderedScenes = new List<Scene>();
foreach (var chapter in await chapters.ListByBookAsync(book.BookID))
{
orderedScenes.AddRange((await scenes.ListByChapterAsync(chapter.ChapterID)).Where(scene => !scene.IsArchived));
}
orderedScenes = orderedScenes
.OrderBy(scene => scene.ChapterSortOrder)
.ThenBy(scene => scene.ChapterNumber)
.ThenBy(scene => scene.SortOrder)
.ThenBy(scene => scene.SceneNumber)
.ThenBy(scene => scene.SceneID)
.ToList();
var currentIndex = orderedScenes.FindIndex(scene => scene.SceneID == currentScene.SceneID);
if (currentIndex < 0)
{
return [];
}
var neighbours = new List<StoryIntelligenceNeighbourSceneContext>();
if (currentIndex > 0)
{
neighbours.Add(BuildNeighbour("previous", orderedScenes[currentIndex - 1]));
}
if (currentIndex < orderedScenes.Count - 1)
{
neighbours.Add(BuildNeighbour("next", orderedScenes[currentIndex + 1]));
}
return neighbours;
}
private static StoryIntelligenceNeighbourSceneContext BuildNeighbour(string position, Scene scene)
=> new(
position,
scene.SceneID,
scene.ChapterID,
scene.ChapterNumber,
scene.SceneNumber,
scene.SceneTitle,
FirstNonBlank(scene.StructuralSummary, scene.Summary),
scene.StructuralSetting,
scene.POVCharacterID,
FirstNonBlank(scene.POVCharacterName, scene.POVSuggestedName));
private async Task<IReadOnlyList<StoryIntelligenceSceneCharacterContext>> BuildCurrentCharactersAsync(Scene scene)
{
var appearances = await characters.ListSceneCharactersAsync(scene.SceneID);
var characterIds = appearances.Select(appearance => appearance.CharacterID).ToHashSet();
if (scene.POVCharacterID.HasValue)
{
characterIds.Add(scene.POVCharacterID.Value);
}
var rows = new List<StoryIntelligenceSceneCharacterContext>();
foreach (var characterId in characterIds.OrderBy(id => id))
{
var appearance = appearances.FirstOrDefault(row => row.CharacterID == characterId);
var aliases = (await characters.ListAliasesAsync(characterId))
.Select(alias => alias.Alias)
.Where(alias => !string.IsNullOrWhiteSpace(alias))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(alias => alias, StringComparer.OrdinalIgnoreCase)
.ToList();
rows.Add(new StoryIntelligenceSceneCharacterContext(
characterId,
appearance?.CharacterName ?? (characterId == scene.POVCharacterID ? FirstNonBlank(scene.POVCharacterName, scene.POVSuggestedName) : null) ?? $"Character {characterId:N0}",
aliases,
characterId == scene.POVCharacterID,
appearance is not null,
appearance?.RoleInSceneTypeName,
appearance?.PresenceTypeName,
appearance?.MatchSource,
appearance?.MatchedText,
appearance?.OccurrenceCount,
appearance?.FirstOccurrenceIndex));
}
return rows;
}
private static string? FirstNonBlank(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim();
public static string ToPromptJson(StoryIntelligenceCanonicalSceneContext context)
=> JsonSerializer.Serialize(new
{
context.SourceLabel,
book = new
{
projectId = context.Book.ProjectID,
bookId = context.Book.BookID,
context.Book.Title,
context.Book.StoryEra,
context.Book.StoryEraLabel,
context.Book.PrimaryLocaleCode,
context.Book.PrimaryLocaleLabel
},
chapter = new
{
chapterId = context.Chapter.ChapterID,
chapterNumber = context.Chapter.ChapterNumber,
context.Chapter.SortOrder,
context.Chapter.Title,
context.Chapter.StructuralSummary
},
currentScene = new
{
sceneId = context.CurrentScene.SceneID,
sceneNumber = context.CurrentScene.SceneNumber,
context.CurrentScene.SortOrder,
context.CurrentScene.Title,
context.CurrentScene.StructuralSummary,
context.CurrentScene.StructuralSetting,
context.CurrentScene.PrimaryLocationName,
context.CurrentScene.POVCharacterID,
context.CurrentScene.POVCharacterName,
context.CurrentScene.POVSource,
sourceParagraphRange = new
{
context.CurrentScene.SourceStartParagraph,
context.CurrentScene.SourceEndParagraph,
context.CurrentScene.WordStartParagraphIndex,
context.CurrentScene.WordEndParagraphIndex,
context.CurrentScene.SourceWordCount,
context.CurrentScene.SourceCharacterCount,
context.CurrentScene.SourceKind
}
},
neighbourScenes = context.NeighbourScenes.Select(scene => new
{
scene.Position,
sceneId = scene.SceneID,
chapterId = scene.ChapterID,
scene.ChapterNumber,
scene.SceneNumber,
scene.Title,
scene.StructuralSummary,
scene.StructuralSetting,
povCharacterId = scene.POVCharacterID,
povCharacterName = scene.POVCharacterName
}),
sceneCharacters = context.SceneCharacters.Select(character => new
{
characterId = character.CharacterID,
character.CanonicalName,
character.Aliases,
character.IsPointOfView,
character.IsSceneMapped,
character.RoleInScene,
character.PresenceType,
character.MatchSource,
character.MatchedText,
character.OccurrenceCount,
character.FirstOccurrenceIndex
}),
configuredMetrics = context.ConfiguredMetrics.Select(metric => new
{
metric.Key,
metricTypeId = metric.MetricTypeID,
metric.Name,
metric.Description,
metric.MinValue,
metric.MaxValue,
metric.DefaultValue
}),
existingPlotLines = context.ExistingPlotLines.Select(plotLine => new
{
plotLineId = plotLine.PlotLineID,
plotLine.Name,
plotLine.Description,
plotLine.Status,
bookId = plotLine.BookID
})
}, JsonOptions);
}
public sealed record StoryIntelligenceCanonicalSceneContext(
StoryIntelligenceBookContext Book,
StoryIntelligenceChapterContext Chapter,
StoryIntelligenceCurrentSceneContext CurrentScene,
IReadOnlyList<StoryIntelligenceNeighbourSceneContext> NeighbourScenes,
IReadOnlyList<StoryIntelligenceSceneCharacterContext> SceneCharacters,
IReadOnlyList<StoryIntelligenceMetricContext> ConfiguredMetrics,
IReadOnlyList<StoryIntelligencePlotLineContext> ExistingPlotLines,
string SourceLabel);
public sealed record StoryIntelligenceBookContext(int? ProjectID, int? BookID, string? Title, string? StoryEra, string? StoryEraLabel, string? PrimaryLocaleCode, string? PrimaryLocaleLabel);
public sealed record StoryIntelligenceChapterContext(int? ChapterID, decimal? ChapterNumber, int? SortOrder, string? Title, string? StructuralSummary);
public sealed record StoryIntelligenceCurrentSceneContext(
int SceneID,
decimal SceneNumber,
int SortOrder,
string Title,
string? StructuralSummary,
string? StructuralSetting,
string? PrimaryLocationName,
int? POVCharacterID,
string? POVCharacterName,
string? POVSource,
int? SourceStartParagraph,
int? SourceEndParagraph,
int? WordStartParagraphIndex,
int? WordEndParagraphIndex,
int SourceWordCount,
int SourceCharacterCount,
string SourceKind,
[property: JsonIgnore] string SourceText);
public sealed record StoryIntelligenceNeighbourSceneContext(string Position, int SceneID, int ChapterID, decimal ChapterNumber, decimal SceneNumber, string Title, string? StructuralSummary, string? StructuralSetting, int? POVCharacterID, string? POVCharacterName);
public sealed record StoryIntelligenceSceneCharacterContext(int CharacterID, string CanonicalName, IReadOnlyList<string> Aliases, bool IsPointOfView, bool IsSceneMapped, string? RoleInScene, string? PresenceType, string? MatchSource, string? MatchedText, int? OccurrenceCount, int? FirstOccurrenceIndex);
public sealed record StoryIntelligenceMetricContext(string Key, int MetricTypeID, string Name, string? Description, int MinValue, int MaxValue, int DefaultValue);
public sealed record StoryIntelligencePlotLineContext(int PlotLineID, string Name, string? Description, string? Status, int? BookID);

File diff suppressed because it is too large Load Diff

View File

@ -80,9 +80,6 @@ public sealed class StoryMemoryService(
{
QueueDemand = options.QueueDemand,
MaxDemandToQueue = options.MaxDemandToQueue,
MaxCharacterDemandToQueue = options.MaxCharacterDemandToQueue,
MaxLocationDemandToQueue = options.MaxLocationDemandToQueue,
MaxAssetDemandToQueue = options.MaxAssetDemandToQueue,
AppearancePreference = options.AppearancePreference,
PrimaryAgeGroup = options.PrimaryAgeGroup,
StoryEra = options.StoryEra,
@ -152,8 +149,6 @@ public sealed class StoryMemoryService(
var assignments = await memory.ListAssignmentsAsync(importSessionId);
var catalogue = (await illustrationLibrary.ListAsync(new IllustrationLibraryFilter()))
.Where(item => item.Status is IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Generated)
.Where(item => item.IsActive && !item.IsArchived)
.Where(item => !item.IsFallbackClassification)
.Where(item => ResolveUploadUrl(item) is not null)
.ToList();
@ -206,7 +201,7 @@ public sealed class StoryMemoryService(
continue;
}
var demand = await RecordDemandAsync(importSessionId, character.ProjectID, StoryMemoryEntityTypes.Character, DemandKey(character.CanonicalIdentityKey, evidence.AgeBand, evidence.Presentation, evidence.HairColour, evidence.HairLength, evidence.SkinTone), options, CharacterDemandSpecification(importSessionId, character, evidence, options), cancellationToken);
var demand = await RecordDemandAsync(importSessionId, character.ProjectID, StoryMemoryEntityTypes.Character, DemandKey(character.CanonicalIdentityKey, evidence.AgeBand, evidence.Presentation, evidence.HairColour, evidence.HairLength, evidence.SkinTone), options, CharacterDemandSpecification(character, evidence, options), cancellationToken);
await memory.UpsertAssignmentAsync(new StoryMemoryIllustrationAssignmentSave(importSessionId, StoryMemoryEntityTypes.Character, character.StoryMemoryCharacterID, demand.IllustrationLibraryItemId, demand.StableCode, demand.AssignmentStatus, true, 0, 0, demand.AssignmentReason, ProcessorVersion));
}
@ -216,26 +211,18 @@ public sealed class StoryMemoryService(
var requestedType = StoryIntelligenceIllustrationCompatibility.LocationType($"{location.DisplayName} {location.SemanticType} {location.Subtype}");
var chosen = catalogue
.Where(item => item.Category == IllustrationLibraryCategories.Location)
.Select(item => (Item: item, Metadata: StoryIntelligenceIllustrationCompatibility.LocationMetadataFrom(item)))
.Where(item => !item.Metadata.IsFallbackClassification)
.Where(item => !string.Equals(item.Metadata.ReuseScope, "None", StringComparison.OrdinalIgnoreCase))
.Where(item => StoryIntelligenceIllustrationCompatibility.LocationCompatible(requestedType, item.Metadata.BaseType))
.OrderByDescending(item => string.Equals(item.Metadata.BaseType, requestedType, StringComparison.OrdinalIgnoreCase) ? 100 : 50)
.Select(item => (Item: item, Type: StoryIntelligenceIllustrationCompatibility.LocationType($"{item.StableCode} {item.DisplayTitle} {item.ShortDescription} {item.MetadataJson} {item.SpecificationJson}")))
.Where(item => StoryIntelligenceIllustrationCompatibility.LocationCompatible(requestedType, item.Type))
.OrderByDescending(item => string.Equals(item.Type, requestedType, StringComparison.OrdinalIgnoreCase) ? 100 : 50)
.ThenBy(item => item.Item.StableCode)
.FirstOrDefault();
if (chosen.Item is not null)
{
await memory.UpsertAssignmentAsync(new StoryMemoryIllustrationAssignmentSave(importSessionId, StoryMemoryEntityTypes.Location, location.StoryMemoryLocationID, chosen.Item.IllustrationLibraryItemID, chosen.Item.StableCode, "Assigned", false, 100, 0.9m, $"Assigned {chosen.Item.StableCode}; location type {requestedType} matched {chosen.Metadata.BaseType}.", ProcessorVersion));
await memory.UpsertAssignmentAsync(new StoryMemoryIllustrationAssignmentSave(importSessionId, StoryMemoryEntityTypes.Location, location.StoryMemoryLocationID, chosen.Item.IllustrationLibraryItemID, chosen.Item.StableCode, "Assigned", false, 100, 0.9m, $"Assigned {chosen.Item.StableCode}; location type {requestedType} matched {chosen.Type}.", ProcessorVersion));
}
else
{
if (!StoryIntelligenceIllustrationCompatibility.CanAutomaticallyGenerateLocation(requestedType))
{
await memory.UpsertAssignmentAsync(new StoryMemoryIllustrationAssignmentSave(importSessionId, StoryMemoryEntityTypes.Location, location.StoryMemoryLocationID, null, null, "DemandRecorded", true, 0, 0, $"Location type {requestedType} is too broad for automatic generation; demand recorded for review.", ProcessorVersion));
continue;
}
var demand = await RecordDemandAsync(importSessionId, location.ProjectID, StoryMemoryEntityTypes.Location, DemandKey(location.CanonicalIdentityKey, requestedType), options, LocationDemandSpecification(importSessionId, location, requestedType, options), cancellationToken);
var demand = await RecordDemandAsync(importSessionId, location.ProjectID, StoryMemoryEntityTypes.Location, DemandKey(location.CanonicalIdentityKey, requestedType), options, LocationDemandSpecification(location, requestedType, options), cancellationToken);
await memory.UpsertAssignmentAsync(new StoryMemoryIllustrationAssignmentSave(importSessionId, StoryMemoryEntityTypes.Location, location.StoryMemoryLocationID, demand.IllustrationLibraryItemId, demand.StableCode, demand.AssignmentStatus, true, 0, 0, demand.AssignmentReason, ProcessorVersion));
}
}
@ -260,13 +247,7 @@ public sealed class StoryMemoryService(
}
else
{
if (!StoryIntelligenceIllustrationCompatibility.CanAutomaticallyGenerateAsset(requestedType))
{
await memory.UpsertAssignmentAsync(new StoryMemoryIllustrationAssignmentSave(importSessionId, StoryMemoryEntityTypes.Asset, asset.StoryMemoryAssetID, null, null, "DemandRecorded", true, 0, 0, $"Asset type {requestedType} is too broad for automatic generation; demand recorded for review.", ProcessorVersion));
continue;
}
var demand = await RecordDemandAsync(importSessionId, asset.ProjectID, StoryMemoryEntityTypes.Asset, DemandKey(asset.CanonicalIdentityKey, requestedType, requestedColour), options, AssetDemandSpecification(importSessionId, asset, requestedType, requestedColour, options), cancellationToken);
var demand = await RecordDemandAsync(importSessionId, asset.ProjectID, StoryMemoryEntityTypes.Asset, DemandKey(asset.CanonicalIdentityKey, requestedType, requestedColour), options, AssetDemandSpecification(asset, requestedType, requestedColour, options), cancellationToken);
await memory.UpsertAssignmentAsync(new StoryMemoryIllustrationAssignmentSave(importSessionId, StoryMemoryEntityTypes.Asset, asset.StoryMemoryAssetID, demand.IllustrationLibraryItemId, demand.StableCode, demand.AssignmentStatus, true, 0, 0, demand.AssignmentReason, ProcessorVersion));
}
}
@ -945,13 +926,6 @@ public sealed class StoryMemoryService(
var recorded = await memory.UpsertDemandAsync(new StoryMemoryIllustrationDemandSave(importSessionId, projectId, entityType, archetypeKey, 1, "Recorded", null, null));
if (!options.QueueDemand)
{
logger.LogInformation(
"Story Memory illustration decision: recorded demand without queueing. ImportSessionID={ImportSessionID} EntityType={EntityType} ArchetypeKey={ArchetypeKey} Category={Category} Reason={Reason}",
importSessionId,
entityType,
archetypeKey,
specification.Category,
"Automatic image generation is disabled or requires approval.");
return DemandResolution.Recorded();
}
@ -962,7 +936,6 @@ public sealed class StoryMemoryService(
.FirstOrDefault(item => item.IllustrationLibraryItemID == existingId);
if (existingItem is not null
&& existingItem.IsActive
&& !existingItem.IsArchived
&& string.Equals(existingItem.StableCode, specification.Code, StringComparison.OrdinalIgnoreCase)
&& existingItem.Status is IllustrationLibraryStatuses.Planned
or IllustrationLibraryStatuses.Queued
@ -970,70 +943,23 @@ public sealed class StoryMemoryService(
or IllustrationLibraryStatuses.Generated
or IllustrationLibraryStatuses.Approved)
{
logger.LogInformation(
"Story Memory illustration decision: reused existing queued/generated demand item. ImportSessionID={ImportSessionID} EntityType={EntityType} EntityID={LinkedEntityID} Category={Category} IllustrationLibraryItemID={IllustrationLibraryItemID} StableCode={StableCode}",
importSessionId,
entityType,
specification.LinkedEntityId,
specification.Category,
existingId,
specification.Code);
return new(existingId, specification.Code, "DemandQueued", $"Demand already has queued illustration item {existingId:N0}.");
}
}
var importCap = options.MaxDemandToQueue.GetValueOrDefault();
var categoryCap = CategoryCap(options, specification.Category);
if (importCap > 0 || categoryCap > 0)
{
var queuedOrGenerated = (await memory.ListDemandsAsync(importSessionId))
.Where(item => item.QueuedIllustrationLibraryItemID.HasValue || item.GeneratedIllustrationLibraryItemID.HasValue)
.ToList();
if (importCap > 0 && queuedOrGenerated.Count >= importCap)
{
logger.LogInformation(
"Story Memory illustration decision: skipped queue because import cap was reached. ImportSessionID={ImportSessionID} EntityType={EntityType} EntityID={LinkedEntityID} Category={Category} Cap={Cap}",
importSessionId,
entityType,
specification.LinkedEntityId,
specification.Category,
importCap);
return new(null, null, "DemandRecorded", $"No compatible illustration exists; demand recorded but import automatic generation cap {importCap:N0} has been reached.");
}
if (categoryCap > 0 && queuedOrGenerated.Count(item => string.Equals(item.EntityType, entityType, StringComparison.OrdinalIgnoreCase)) >= categoryCap)
{
logger.LogInformation(
"Story Memory illustration decision: skipped queue because category cap was reached. ImportSessionID={ImportSessionID} EntityType={EntityType} EntityID={LinkedEntityID} Category={Category} Cap={Cap}",
importSessionId,
entityType,
specification.LinkedEntityId,
specification.Category,
categoryCap);
return new(null, null, "DemandRecorded", $"No compatible illustration exists; demand recorded but {specification.Category.ToLowerInvariant()} automatic generation cap {categoryCap:N0} has been reached.");
}
}
if (options.MaxDemandToQueue is int maxDemandToQueue && maxDemandToQueue > 0)
{
var categoryCap = maxDemandToQueue;
var queuedDemandItems = (await illustrationLibrary.ListAsync(new IllustrationLibraryFilter()))
.Count(item => string.Equals(item.Category, specification.Category, StringComparison.OrdinalIgnoreCase)
&& item.IsActive
&& !item.IsArchived
&& item.StableCode.StartsWith("demand-", StringComparison.OrdinalIgnoreCase)
&& item.Status is IllustrationLibraryStatuses.Planned
or IllustrationLibraryStatuses.Queued
or IllustrationLibraryStatuses.Generating);
if (queuedDemandItems >= maxDemandToQueue)
if (queuedDemandItems >= categoryCap)
{
logger.LogInformation(
"Story Memory illustration decision: skipped queue because in-flight cap was reached. ImportSessionID={ImportSessionID} EntityType={EntityType} EntityID={LinkedEntityID} Category={Category} Cap={Cap}",
importSessionId,
entityType,
specification.LinkedEntityId,
specification.Category,
maxDemandToQueue);
return new(null, null, "DemandRecorded", $"No compatible illustration exists; demand recorded but import in-flight generation cap {maxDemandToQueue:N0} has been reached.");
return new(null, null, "DemandRecorded", $"No compatible illustration exists; demand recorded but {specification.Category.ToLowerInvariant()} in-flight generation cap {categoryCap:N0} has been reached.");
}
}
@ -1049,29 +975,10 @@ public sealed class StoryMemoryService(
"Queued",
planned.IllustrationLibraryItemID,
null));
logger.LogInformation(
"Story Memory illustration decision: queued generated library item. ImportSessionID={ImportSessionID} EntityType={EntityType} EntityID={LinkedEntityID} Category={Category} IllustrationLibraryItemID={IllustrationLibraryItemID} StableCode={StableCode} ReuseScope={ReuseScope} BaseType={BaseType}",
importSessionId,
entityType,
specification.LinkedEntityId,
specification.Category,
queued.QueuedIllustrationLibraryItemID ?? planned.IllustrationLibraryItemID,
planned.StableCode,
specification.ReuseScope,
specification.BaseType);
return new(queued.QueuedIllustrationLibraryItemID ?? planned.IllustrationLibraryItemID, planned.StableCode, "DemandQueued", $"No compatible illustration exists; queued demand-generated library item {planned.StableCode}.");
}
private static int CategoryCap(StoryMemoryResolveOptions options, string category)
=> category switch
{
IllustrationLibraryCategories.Character => options.MaxCharacterDemandToQueue.GetValueOrDefault(),
IllustrationLibraryCategories.Location => options.MaxLocationDemandToQueue.GetValueOrDefault(),
IllustrationLibraryCategories.Asset => options.MaxAssetDemandToQueue.GetValueOrDefault(),
_ => 0
};
private static IllustrationGenerationSpecification CharacterDemandSpecification(int importSessionId, StoryMemoryCharacter character, CharacterEvidenceProfile evidence, StoryMemoryResolveOptions options)
private static IllustrationGenerationSpecification CharacterDemandSpecification(StoryMemoryCharacter character, CharacterEvidenceProfile evidence, StoryMemoryResolveOptions options)
{
var code = DemandStableCode(StoryMemoryEntityTypes.Character, character.CanonicalIdentityKey, evidence.AgeBand, evidence.Presentation, evidence.HairColour, evidence.HairLength, evidence.SkinTone);
var age = UnknownToDefault(evidence.AgeBand, NormaliseAgeContext(options.PrimaryAgeGroup));
@ -1101,22 +1008,11 @@ public sealed class StoryMemoryService(
ClothingStyle = $"{era} {location} everyday naturalistic",
Palette = ["clear blue", "warm amber", "natural skin tones"],
MetadataTags = ["character", "story-memory-demand"],
ImageKind = "Character",
CanonicalName = character.DisplayName,
BaseType = presentation,
SpecificityLevel = character.IsNamed ? "NamedEntity" : "Archetype",
IsFallbackClassification = false,
ReuseScope = character.IsNamed ? "Book" : "None",
SourceBookId = character.BookID,
SourceSceneId = character.LastUpdatedSceneResultID,
LinkedEntityType = StoryMemoryEntityTypes.Character,
LinkedEntityId = character.StoryMemoryCharacterID,
GenerationReason = "Story Memory character demand",
Metadata = DemandMetadata(importSessionId, "Character", character.CanonicalIdentityKey, options, character.ProjectID, character.BookID, character.LastUpdatedSceneResultID, StoryMemoryEntityTypes.Character, character.StoryMemoryCharacterID, character.DisplayName, presentation, character.IsNamed ? "NamedEntity" : "Archetype", character.IsNamed ? "Book" : "None", false)
Metadata = DemandMetadata("Character", character.CanonicalIdentityKey, options)
};
}
private static IllustrationGenerationSpecification LocationDemandSpecification(int importSessionId, StoryMemoryLocation location, string requestedType, StoryMemoryResolveOptions options)
private static IllustrationGenerationSpecification LocationDemandSpecification(StoryMemoryLocation location, string requestedType, StoryMemoryResolveOptions options)
{
var code = DemandStableCode(StoryMemoryEntityTypes.Location, location.CanonicalIdentityKey, requestedType);
var locationType = UnknownToDefault(requestedType, "UnknownInterior");
@ -1137,22 +1033,11 @@ public sealed class StoryMemoryService(
Setting = location.InteriorExterior ?? InferInteriorExterior(locationType),
Palette = ["clear navy", "warm light", "balanced natural colour"],
MetadataTags = ["location", "story-memory-demand"],
ImageKind = "Location",
CanonicalName = location.DisplayName,
BaseType = locationType,
SpecificityLevel = LocationSpecificity(location, locationType),
IsFallbackClassification = StoryIntelligenceIllustrationCompatibility.IsFallbackLocationType(locationType),
ReuseScope = LocationReuseScope(location, locationType),
SourceBookId = location.BookID,
SourceSceneId = location.LastSeenSceneResultID,
LinkedEntityType = StoryMemoryEntityTypes.Location,
LinkedEntityId = location.StoryMemoryLocationID,
GenerationReason = "Story Memory location demand",
Metadata = DemandMetadata(importSessionId, "Location", location.CanonicalIdentityKey, options, location.ProjectID, location.BookID, location.LastSeenSceneResultID, StoryMemoryEntityTypes.Location, location.StoryMemoryLocationID, location.DisplayName, locationType, LocationSpecificity(location, locationType), LocationReuseScope(location, locationType), StoryIntelligenceIllustrationCompatibility.IsFallbackLocationType(locationType))
Metadata = DemandMetadata("Location", location.CanonicalIdentityKey, options)
};
}
private static IllustrationGenerationSpecification AssetDemandSpecification(int importSessionId, StoryMemoryAsset asset, string requestedType, string requestedColour, StoryMemoryResolveOptions options)
private static IllustrationGenerationSpecification AssetDemandSpecification(StoryMemoryAsset asset, string requestedType, string requestedColour, StoryMemoryResolveOptions options)
{
var code = DemandStableCode(StoryMemoryEntityTypes.Asset, asset.CanonicalIdentityKey, requestedType, requestedColour);
var objectType = UnknownToDefault(requestedType, "UnknownObject");
@ -1171,62 +1056,20 @@ public sealed class StoryMemoryService(
Era = asset.Era ?? "1980s",
Palette = ["clean blue", "warm light", "natural object colour"],
MetadataTags = ["asset", "story-memory-demand"],
ImageKind = requestedType.Contains("Car", StringComparison.OrdinalIgnoreCase) || requestedType is "Hatchback" or "SportsCar" or "Van" ? "Vehicle" : "Object",
CanonicalName = asset.DisplayName,
BaseType = objectType,
SpecificityLevel = StoryIntelligenceIllustrationCompatibility.IsFallbackAssetType(objectType) ? "Generic" : "Archetype",
IsFallbackClassification = StoryIntelligenceIllustrationCompatibility.IsFallbackAssetType(objectType),
ReuseScope = StoryIntelligenceIllustrationCompatibility.IsFallbackAssetType(objectType) ? "None" : "Book",
SourceBookId = asset.BookID,
SourceSceneId = asset.LastSeenSceneResultID,
LinkedEntityType = StoryMemoryEntityTypes.Asset,
LinkedEntityId = asset.StoryMemoryAssetID,
GenerationReason = "Story Memory asset demand",
Metadata = DemandMetadata(importSessionId, "Asset", asset.CanonicalIdentityKey, options, asset.ProjectID, asset.BookID, asset.LastSeenSceneResultID, StoryMemoryEntityTypes.Asset, asset.StoryMemoryAssetID, asset.DisplayName, objectType, StoryIntelligenceIllustrationCompatibility.IsFallbackAssetType(objectType) ? "Generic" : "Archetype", StoryIntelligenceIllustrationCompatibility.IsFallbackAssetType(objectType) ? "None" : "Book", StoryIntelligenceIllustrationCompatibility.IsFallbackAssetType(objectType))
Metadata = DemandMetadata("Asset", asset.CanonicalIdentityKey, options)
};
}
private static Dictionary<string, string> DemandMetadata(
int importSessionId,
string category,
string entityKey,
StoryMemoryResolveOptions options,
int projectId,
int bookId,
int? sceneId,
string linkedEntityType,
int linkedEntityId,
string canonicalName,
string baseType,
string specificityLevel,
string reuseScope,
bool isFallbackClassification)
private static Dictionary<string, string> DemandMetadata(string category, string entityKey, StoryMemoryResolveOptions options)
{
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["category"] = category,
["imageKind"] = category,
["canonicalName"] = canonicalName,
["baseType"] = baseType,
["specificityLevel"] = specificityLevel,
["isFallbackClassification"] = isFallbackClassification ? "true" : "false",
["reuseScope"] = reuseScope,
["sourceImportSessionId"] = importSessionId.ToString("N0"),
["sourceProjectId"] = projectId.ToString("N0"),
["sourceBookId"] = bookId.ToString("N0"),
["linkedEntityType"] = linkedEntityType,
["linkedEntityId"] = linkedEntityId.ToString("N0"),
["generationReason"] = $"Story Memory {category.ToLowerInvariant()} demand",
["generationSource"] = "Story Memory demand",
["libraryPurpose"] = "Live import demand fulfilment",
["privacy"] = "generic-no-manuscript-data",
["storyMemoryEntityHash"] = ShortHash(entityKey)
};
if (sceneId.HasValue)
{
metadata["sourceSceneId"] = sceneId.Value.ToString("N0");
}
Add("primaryAgeGroup", options.PrimaryAgeGroup);
Add("storyEra", options.StoryEra);
Add("storyLocation", options.StoryLocation);
@ -1470,29 +1313,6 @@ public sealed class StoryMemoryService(
? "one readable public washroom environment, cubicles or sink area obvious at thumbnail size, no bath, no domestic home decor"
: "one readable focal environment, no prominent people, cinematic but bright enough for a small interface thumbnail";
private static string LocationSpecificity(StoryMemoryLocation location, string locationType)
{
if (StoryIntelligenceIllustrationCompatibility.IsFallbackLocationType(locationType))
{
return "Generic";
}
return !string.IsNullOrWhiteSpace(location.DisplayName)
&& !string.Equals(location.DisplayName, SplitWords(locationType), StringComparison.OrdinalIgnoreCase)
? "NamedLocation"
: "Archetype";
}
private static string LocationReuseScope(StoryMemoryLocation location, string locationType)
{
if (StoryIntelligenceIllustrationCompatibility.IsFallbackLocationType(locationType))
{
return "None";
}
return LocationSpecificity(location, locationType) == "NamedLocation" ? "Book" : "Project";
}
private static string InferInteriorExterior(string semanticType)
=> semanticType is "Road" or "Lane" or "Street" or "Avenue" or "Drive" or "Close" or "Motorway" or "Bridge" or "Flyover" or "Underpass" or "CarPark" or "Beach" or "Canal" or "HouseExterior" or "StreetFurniture" ? "Exterior" : "Interior";

View File

@ -16,6 +16,7 @@ public sealed class StorySceneValidator : IStorySceneValidator
private static readonly HashSet<string> KnowledgeChangeTypes = CreateSet("Learns", "Realises", "IsTold", "Discovers", "Confirms", "Suspects", "Misunderstands");
private static readonly HashSet<string> ObservationTypes = CreateSet("CharacterAction", "CharacterKnowledge", "CharacterLocation", "RelationshipSignal", "AssetInteraction", "LocationSignal", "TimelineSignal", "QuestionSignal");
private static readonly HashSet<string> EntityTypes = CreateSet("Character", "Location", "Asset", "Knowledge", "Relationship", "Timeline", "Scene", "Unknown");
private static readonly HashSet<string> PreferredMetrics = CreateSet("action", "emotion", "tension", "conflict", "mystery", "romance", "humour", "revelation", "pacingIntensity");
private static readonly HashSet<string> RelationshipSignals = CreateSet("trust", "mistrust", "affection", "fear", "authority", "secrecy", "conflict", "dependence", "protection", "rivalry", "obligation");
public ValidationResult Validate(SceneIntelligenceScene? scene)
@ -42,7 +43,6 @@ public sealed class StorySceneValidator : IStorySceneValidator
ValidateRelationships(scene.Relationships, result);
ValidateKnowledgeChanges(scene.KnowledgeChanges, result);
ValidateTimelineClues(scene.TimelineClues, result);
ValidateNarrativeArcs(scene.NarrativeArcs, result);
ValidateQuestionsRaised(scene.QuestionsRaised, result);
ValidateQuestionsAnswered(scene.QuestionsAnswered, result);
ValidateObservations(scene.Observations, result);
@ -71,6 +71,7 @@ public sealed class StorySceneValidator : IStorySceneValidator
}
RequiredObject(scene.SceneReference, "sceneReference", result);
RequiredObject(scene.Summary, "summary", result);
RequiredObject(scene.ScenePurpose, "scenePurpose", result);
RequiredObject(scene.PointOfView, "pointOfView", result);
RequiredObject(scene.Setting, "setting", result);
@ -80,7 +81,6 @@ public sealed class StorySceneValidator : IStorySceneValidator
RequiredArray(scene.Relationships, "relationships", result);
RequiredArray(scene.KnowledgeChanges, "knowledgeChanges", result);
RequiredArray(scene.TimelineClues, "timelineClues", result);
RequiredArray(scene.NarrativeArcs, "narrativeArcs", result);
RequiredArray(scene.QuestionsRaised, "questionsRaised", result);
RequiredArray(scene.QuestionsAnswered, "questionsAnswered", result);
RequiredArray(scene.Observations, "observations", result);
@ -341,28 +341,6 @@ public sealed class StorySceneValidator : IStorySceneValidator
}
}
private static void ValidateNarrativeArcs(List<SceneIntelligenceNarrativeArc>? values, ValidationResult result)
{
if (values is null)
{
return;
}
for (var i = 0; i < values.Count; i++)
{
var item = values[i];
var path = $"narrativeArcs[{i}]";
RequiredString(item.Title, $"{path}.title", result);
RequiredString(item.Description, $"{path}.description", result);
RequiredString(item.ArcType, $"{path}.arcType", result);
RequiredString(item.Scale, $"{path}.scale", result);
RequiredScalar(item.MatchingExistingPlotLineId, $"{path}.matchingExistingPlotLineId", result, allowNull: true);
RequiredString(item.Evidence, $"{path}.evidence", result);
Confidence(item.Confidence, $"{path}.confidence", result);
Unknown(item.ExtensionData, path, result);
}
}
private static void ValidateQuestionsAnswered(List<SceneIntelligenceQuestionAnswered>? values, ValidationResult result)
{
if (values is null)
@ -421,15 +399,24 @@ public sealed class StorySceneValidator : IStorySceneValidator
foreach (var (name, metric) in metrics)
{
var path = $"metrics.{name}";
if (!PreferredMetrics.Contains(name))
{
AddWarning(result, path, "Metric is outside the preferred V1 metric set.", "Use preferred V1 metrics unless the schema is intentionally expanded.");
}
if (metric is null)
{
AddError(result, path, "Metric object is required when a metric is present.", "Return { \"score\": configured-scale-number, \"confidence\": 0.0-1.0 }.");
AddError(result, path, "Metric object is required when a metric is present.", "Return { \"score\": 1-10, \"confidence\": 0.0-1.0 }.");
continue;
}
if (!metric.Score.HasValue)
{
AddError(result, $"{path}.score", "Metric score is required.", "Return a numeric score using the configured metric scale.");
AddError(result, $"{path}.score", "Metric score is required.", "Return a score from 1 to 10.");
}
else if (metric.Score < 1 || metric.Score > 10)
{
AddError(result, $"{path}.score", "Metric score must be from 1 to 10.", "Use the V1 1-10 descriptive intensity scale.");
}
Confidence(metric.Confidence, $"{path}.confidence", result);

View File

@ -9,7 +9,6 @@ public interface IWordCompanionPresenceService
Task<WordCompanionPresenceStatus> HeartbeatAsync(int userId, string connectionId, WordCompanionPresenceHeartbeat heartbeat);
Task<WordCompanionPresenceStatus?> DisconnectAsync(string connectionId);
Task<WordCompanionPresenceStatus> GetStatusAsync(int userId);
Task<WordCompanionPresenceStatus> UpdateCurrentStoryPositionAsync(int userId, StoryPosition? position);
Task<bool> IsConnectedAsync(int userId);
Task<IReadOnlyList<WordCompanionPresenceStatus>> MarkStaleOfflineAsync();
Task<string?> GetCompanionConnectionIdAsync(int userId);
@ -69,11 +68,6 @@ public sealed class WordCompanionPresenceService : IWordCompanionPresenceService
record.LinkedProjectID = Positive(heartbeat.LinkedProjectID);
record.LinkedBookID = Positive(heartbeat.LinkedBookID);
record.LastHeartbeatUtc = now;
if (record.CurrentStoryPosition is not null
&& (record.LinkedProjectID != record.CurrentStoryPosition.ProjectId || record.LinkedBookID != record.CurrentStoryPosition.BookId))
{
record.CurrentStoryPosition = null;
}
usersByConnection[connectionId] = userId;
return Task.FromResult(ToStatus(record, now));
@ -103,28 +97,6 @@ public sealed class WordCompanionPresenceService : IWordCompanionPresenceService
return Task.FromResult(ToStatus(record, DateTime.UtcNow));
}
public Task<WordCompanionPresenceStatus> UpdateCurrentStoryPositionAsync(int userId, StoryPosition? position)
{
var now = DateTime.UtcNow;
var record = recordsByUser.AddOrUpdate(
userId,
_ => new PresenceRecord
{
UserID = userId,
ConnectedUtc = now
},
(_, existing) => existing);
if (position?.IsValid == true)
{
record.CurrentStoryPosition = position;
record.LinkedProjectID = position.ProjectId;
record.LinkedBookID = position.BookId;
}
return Task.FromResult(ToStatus(record, now));
}
public async Task<bool> IsConnectedAsync(int userId)
=> (await GetStatusAsync(userId)).IsConnected;
@ -184,7 +156,6 @@ public sealed class WordCompanionPresenceService : IWordCompanionPresenceService
CurrentDocumentName = record.CurrentDocumentName,
LinkedProjectID = record.LinkedProjectID,
LinkedBookID = record.LinkedBookID,
CurrentStoryPosition = connected ? record.CurrentStoryPosition : null,
ConnectedUtc = record.ConnectedUtc,
LastHeartbeatUtc = record.LastHeartbeatUtc
};
@ -210,7 +181,6 @@ public sealed class WordCompanionPresenceService : IWordCompanionPresenceService
public string? CurrentDocumentName { get; set; }
public int? LinkedProjectID { get; set; }
public int? LinkedBookID { get; set; }
public StoryPosition? CurrentStoryPosition { get; set; }
public DateTime? ConnectedUtc { get; set; }
public DateTime? LastHeartbeatUtc { get; set; }
}

View File

@ -40,8 +40,7 @@ public interface IWordCompanionService
public sealed class WordCompanionService(
IWordCompanionRepository repository,
IManuscriptDocumentRepository manuscriptDocuments,
ICurrentUserService currentUser,
INameIntelligenceService nameIntelligence) : IWordCompanionService
ICurrentUserService currentUser) : IWordCompanionService
{
private const int CharacterDiscoveryMinimumMentions = 2;
private const int CharacterCreationSelectionLimit = 250;
@ -540,8 +539,7 @@ public sealed class WordCompanionService(
return null;
}
var candidates = await DecorateNameIntelligenceEvidenceAsync(
DiscoverCharacterCandidates(request.DocumentText ?? string.Empty, request.IncludeExcluded));
var candidates = DiscoverCharacterCandidates(request.DocumentText ?? string.Empty, request.IncludeExcluded);
return new WordCompanionDiscoverCharactersResponse
{
ProjectId = request.ProjectId,
@ -549,50 +547,6 @@ public sealed class WordCompanionService(
};
}
private async Task<IReadOnlyList<WordCompanionCharacterCandidateDto>> DecorateNameIntelligenceEvidenceAsync(IReadOnlyList<WordCompanionCharacterCandidateDto> candidates)
{
var decorated = new List<WordCompanionCharacterCandidateDto>(candidates.Count);
foreach (var candidate in candidates)
{
var firstName = FirstName(candidate.Text);
var recognition = await nameIntelligence.RecogniseAsync(firstName, "en-US");
if (!recognition.Recognised || recognition.Name is null)
{
decorated.Add(candidate);
continue;
}
var ambiguous = recognition.Name.IsWordAmbiguous;
var hasContext = candidate.QualityScore >= 4
|| string.Equals(candidate.Category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase)
|| candidate.Reason.Contains("Dialogue", StringComparison.OrdinalIgnoreCase)
|| candidate.Reason.Contains("honorific", StringComparison.OrdinalIgnoreCase);
decorated.Add(new WordCompanionCharacterCandidateDto
{
Text = candidate.Text,
MentionCount = candidate.MentionCount,
Confidence = candidate.Confidence,
QualityScore = ambiguous && !hasContext ? candidate.QualityScore : candidate.QualityScore + 1,
Category = candidate.Category,
Reason = ambiguous
? $"{candidate.Reason}; recognised ambiguous given-name form requiring context"
: $"{candidate.Reason}; recognised given-name form",
IsExistingCharacterMatch = candidate.IsExistingCharacterMatch
});
}
return decorated
.OrderByDescending(item => CategorySortValue(item.Category))
.ThenByDescending(item => item.QualityScore)
.ThenByDescending(item => ConfidenceSortValue(item.Confidence))
.ThenByDescending(item => item.MentionCount)
.ThenBy(item => item.Text, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static string FirstName(string? value)
=> (value ?? string.Empty).Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? string.Empty;
public Task<WordCompanionCreateDiscoveredCharactersResponse?> CreateDiscoveredCharactersAsync(WordCompanionCreateDiscoveredCharactersRequest request)
{
if (request.ProjectId <= 0 || request.CandidateNames.Count == 0)
@ -723,88 +677,6 @@ public sealed class WordCompanionService(
.ToList();
}
public static IReadOnlyList<ManuscriptScanCharacterCandidatePreview> DiscoverCoreImportCharacterCandidates(
IReadOnlyList<SceneManuscriptSourceRecord> sourceScenes)
{
var orderedScenes = sourceScenes
.OrderBy(scene => scene.ChapterID)
.ThenBy(scene => scene.SourceStartParagraph ?? int.MaxValue)
.ThenBy(scene => scene.SceneID)
.ToList();
var manuscriptText = string.Join("\n\n", orderedScenes
.Select(scene => scene.SourceText)
.Where(text => !string.IsNullOrWhiteSpace(text)));
if (string.IsNullOrWhiteSpace(manuscriptText))
{
return [];
}
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);
return DiscoverCharacterCandidates(manuscriptText)
.Where(IsAutoSelectedScanCharacterCandidate)
.Select((candidate, index) =>
{
var evidenceScenes = CandidateSceneEvidence(candidate.Text, orderedScenes, chapterNumbers, sceneNumbers);
return new ManuscriptScanCharacterCandidatePreview
{
TemporaryCharacterKey = $"post-scene-{StableCandidateKey(candidate.Text, index)}",
Name = candidate.Text,
MentionCount = evidenceScenes.Sum(evidence => evidence.MentionCount),
QualityScore = candidate.QualityScore,
Category = candidate.Category,
Reason = string.IsNullOrWhiteSpace(candidate.Reason)
? "Discovered from canonical scene source text."
: candidate.Reason,
IsExistingCharacterMatch = candidate.IsExistingCharacterMatch,
SuggestedImportance = candidate.Confidence,
EvidenceScenes = evidenceScenes
};
})
.ToList();
}
private static IReadOnlyList<ManuscriptScanCharacterEvidenceScene> CandidateSceneEvidence(
string candidateText,
IReadOnlyList<SceneManuscriptSourceRecord> sourceScenes,
IReadOnlyDictionary<int, int> chapterNumbers,
IReadOnlyDictionary<int, int> sceneNumbers)
{
if (string.IsNullOrWhiteSpace(candidateText))
{
return [];
}
var pattern = $@"(?<![\p{{L}}\p{{N}}]){Regex.Escape(candidateText.Trim())}(?=(?:['’]s)?(?![\p{{L}}\p{{N}}])|$)";
var regex = new Regex(pattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
return sourceScenes
.Select(scene => new
{
Source = scene,
MentionCount = string.IsNullOrWhiteSpace(scene.SourceText)
? 0
: regex.Matches(scene.SourceText).Count
})
.Where(item => item.MentionCount > 0)
.Select(item => new ManuscriptScanCharacterEvidenceScene
{
SceneID = item.Source.SceneID,
ChapterID = item.Source.ChapterID,
ChapterNumber = chapterNumbers.GetValueOrDefault(item.Source.ChapterID),
SceneNumber = sceneNumbers.GetValueOrDefault(item.Source.SceneID),
MentionCount = item.MentionCount,
StructuralSummary = item.Source.StructuralSummary
})
.ToList();
}
private static WordCompanionCharacterCandidateDto? BuildCandidateDto(CharacterCandidateEvidence evidence)
{
if (string.IsNullOrWhiteSpace(evidence.Text) || evidence.Text.Length > 200)

View File

@ -147,7 +147,6 @@ public sealed class WritingWorkspacePresenceService : IWritingWorkspacePresenceS
WordDocumentOpen = wordStatus.DocumentOpen,
WordProjectID = wordStatus.LinkedProjectID,
WordBookID = wordStatus.LinkedBookID,
CurrentStoryPosition = wordStatus.CurrentStoryPosition,
Windows = windows,
Launcher = LatestRole(windows, WritingWorkspaceRoles.Launcher),
Context = LatestRole(windows, WritingWorkspaceRoles.Context),

View File

@ -25,25 +25,6 @@ AS
BEGIN
SET NOCOUNT ON;
MERGE dbo.SceneMetricTypes AS target
USING (VALUES
(N'Overall Intensity', N'General dramatic intensity for the scene.', 1, 10, 5, 10),
(N'Tension', N'Pressure, suspense, uncertainty, or conflict.', 1, 10, 5, 20),
(N'Emotional Weight', N'Emotional load carried by the scene.', 1, 10, 5, 30),
(N'Action', N'Physical action, movement, or event energy.', 1, 10, 3, 40),
(N'Darkness', N'Darkness, bleakness, fear, or threat.', 1, 10, 3, 50),
(N'Romance', N'Romantic focus or intimacy.', 1, 10, 1, 60),
(N'Sexual Charge', N'Sexual tension or sensual charge.', 1, 10, 1, 70),
(N'Violence', N'Violent action, injury, or threat.', 1, 10, 1, 80),
(N'Hope / Lightness', N'Hope, relief, warmth, or tonal lightness.', 1, 10, 4, 90),
(N'Comedy', N'Humour or comic relief.', 1, 10, 1, 100),
(N'Mystery', N'Questions, clue energy, and mystery pressure.', 1, 10, 5, 110)
) AS source (MetricName, Description, MinValue, MaxValue, DefaultValue, SortOrder)
ON target.ProjectID IS NULL AND target.MetricName = source.MetricName
WHEN NOT MATCHED THEN
INSERT (ProjectID, MetricName, Description, MinValue, MaxValue, DefaultValue, SortOrder, IsActive)
VALUES (NULL, source.MetricName, source.Description, source.MinValue, source.MaxValue, source.DefaultValue, source.SortOrder, 1);
IF NOT EXISTS (SELECT 1 FROM dbo.ProjectTimelineSettings WHERE ProjectID = @ProjectID)
BEGIN
INSERT dbo.ProjectTimelineSettings

View File

@ -111,18 +111,6 @@ BEGIN
WHERE consent.ProjectID = @ProjectID
OR EXISTS (SELECT 1 FROM @BookIDs b WHERE b.BookID = consent.BookID);
DELETE evidence
FROM dbo.StoryIntelligenceNarrativeCandidateEvidence evidence
INNER JOIN dbo.StoryIntelligenceNarrativeCandidates candidate ON candidate.NarrativeCandidateID = evidence.NarrativeCandidateID
WHERE candidate.ProjectID = @ProjectID
OR EXISTS (SELECT 1 FROM @BookIDs b WHERE b.BookID = candidate.BookID)
OR EXISTS (SELECT 1 FROM @SceneIDs scene WHERE scene.SceneID = evidence.SceneID);
DELETE candidate
FROM dbo.StoryIntelligenceNarrativeCandidates candidate
WHERE candidate.ProjectID = @ProjectID
OR EXISTS (SELECT 1 FROM @BookIDs b WHERE b.BookID = candidate.BookID);
DELETE FROM dbo.ProjectActivity WHERE ProjectID = @ProjectID;
DELETE prh
FROM dbo.ProjectRestoreHistory prh

View File

@ -118,14 +118,6 @@ BEGIN
DELETE FROM dbo.StoryIntelligenceConsents WHERE BookID = @BookID;
DELETE evidence
FROM dbo.StoryIntelligenceNarrativeCandidateEvidence evidence
INNER JOIN dbo.StoryIntelligenceNarrativeCandidates candidate ON candidate.NarrativeCandidateID = evidence.NarrativeCandidateID
WHERE candidate.BookID = @BookID
OR EXISTS (SELECT 1 FROM @SceneIDs scene WHERE scene.SceneID = evidence.SceneID);
DELETE FROM dbo.StoryIntelligenceNarrativeCandidates WHERE BookID = @BookID;
DELETE petpl
FROM dbo.PlotEventTargetPlotLines petpl
INNER JOIN dbo.ThreadEvents te ON te.ThreadEventID = petpl.PlotEventID

View File

@ -1,416 +0,0 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF COL_LENGTH(N'dbo.IllustrationLibraryItems', N'Quality') IS NULL
ALTER TABLE dbo.IllustrationLibraryItems ADD Quality nvarchar(40) NULL;
GO
IF COL_LENGTH(N'dbo.IllustrationLibraryItems', N'Size') IS NULL
ALTER TABLE dbo.IllustrationLibraryItems ADD Size nvarchar(40) NULL;
GO
IF COL_LENGTH(N'dbo.IllustrationLibraryItems', N'OutputFormat') IS NULL
ALTER TABLE dbo.IllustrationLibraryItems ADD OutputFormat nvarchar(40) NULL;
GO
IF COL_LENGTH(N'dbo.IllustrationLibraryItems', N'ImageKind') IS NULL
ALTER TABLE dbo.IllustrationLibraryItems ADD ImageKind nvarchar(40) NULL;
GO
IF COL_LENGTH(N'dbo.IllustrationLibraryItems', N'CanonicalName') IS NULL
ALTER TABLE dbo.IllustrationLibraryItems ADD CanonicalName nvarchar(200) NULL;
GO
IF COL_LENGTH(N'dbo.IllustrationLibraryItems', N'BaseType') IS NULL
ALTER TABLE dbo.IllustrationLibraryItems ADD BaseType nvarchar(80) NULL;
GO
IF COL_LENGTH(N'dbo.IllustrationLibraryItems', N'SpecificityLevel') IS NULL
ALTER TABLE dbo.IllustrationLibraryItems ADD SpecificityLevel nvarchar(40) NULL;
GO
IF COL_LENGTH(N'dbo.IllustrationLibraryItems', N'IsFallbackClassification') IS NULL
ALTER TABLE dbo.IllustrationLibraryItems ADD IsFallbackClassification bit NOT NULL CONSTRAINT DF_IllustrationLibraryItems_IsFallbackClassification DEFAULT 0;
GO
IF COL_LENGTH(N'dbo.IllustrationLibraryItems', N'ReuseScope') IS NULL
ALTER TABLE dbo.IllustrationLibraryItems ADD ReuseScope nvarchar(40) NULL;
GO
IF COL_LENGTH(N'dbo.IllustrationLibraryItems', N'SourceProjectID') IS NULL
ALTER TABLE dbo.IllustrationLibraryItems ADD SourceProjectID int NULL;
GO
IF COL_LENGTH(N'dbo.IllustrationLibraryItems', N'SourceBookID') IS NULL
ALTER TABLE dbo.IllustrationLibraryItems ADD SourceBookID int NULL;
GO
IF COL_LENGTH(N'dbo.IllustrationLibraryItems', N'SourceChapterID') IS NULL
ALTER TABLE dbo.IllustrationLibraryItems ADD SourceChapterID int NULL;
GO
IF COL_LENGTH(N'dbo.IllustrationLibraryItems', N'SourceSceneID') IS NULL
ALTER TABLE dbo.IllustrationLibraryItems ADD SourceSceneID int NULL;
GO
IF COL_LENGTH(N'dbo.IllustrationLibraryItems', N'LinkedEntityType') IS NULL
ALTER TABLE dbo.IllustrationLibraryItems ADD LinkedEntityType nvarchar(40) NULL;
GO
IF COL_LENGTH(N'dbo.IllustrationLibraryItems', N'LinkedEntityID') IS NULL
ALTER TABLE dbo.IllustrationLibraryItems ADD LinkedEntityID int NULL;
GO
IF COL_LENGTH(N'dbo.IllustrationLibraryItems', N'GenerationReason') IS NULL
ALTER TABLE dbo.IllustrationLibraryItems ADD GenerationReason nvarchar(200) NULL;
GO
IF COL_LENGTH(N'dbo.IllustrationLibraryItems', N'GenerationStatus') IS NULL
ALTER TABLE dbo.IllustrationLibraryItems ADD GenerationStatus nvarchar(40) NULL;
GO
IF COL_LENGTH(N'dbo.IllustrationLibraryItems', N'IsArchived') IS NULL
ALTER TABLE dbo.IllustrationLibraryItems ADD IsArchived bit NOT NULL CONSTRAINT DF_IllustrationLibraryItems_IsArchived DEFAULT 0;
GO
IF COL_LENGTH(N'dbo.IllustrationLibraryItems', N'ArchivedAt') IS NULL
ALTER TABLE dbo.IllustrationLibraryItems ADD ArchivedAt datetime2(0) NULL;
GO
IF COL_LENGTH(N'dbo.IllustrationLibraryItems', N'ArchiveReason') IS NULL
ALTER TABLE dbo.IllustrationLibraryItems ADD ArchiveReason nvarchar(500) NULL;
GO
IF COL_LENGTH(N'dbo.IllustrationLibraryItems', N'SupersededByImageID') IS NULL
ALTER TABLE dbo.IllustrationLibraryItems ADD SupersededByImageID int NULL;
GO
IF COL_LENGTH(N'dbo.IllustrationLibraryItems', N'ReuseOfImageID') IS NULL
ALTER TABLE dbo.IllustrationLibraryItems ADD ReuseOfImageID int NULL;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_IllustrationLibraryItems_ArchiveReuse' AND object_id = OBJECT_ID(N'dbo.IllustrationLibraryItems'))
CREATE INDEX IX_IllustrationLibraryItems_ArchiveReuse ON dbo.IllustrationLibraryItems(IsArchived, IsActive, Category, Status, BaseType, ReuseScope);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_IllustrationLibraryItems_SourceContext' AND object_id = OBJECT_ID(N'dbo.IllustrationLibraryItems'))
CREATE INDEX IX_IllustrationLibraryItems_SourceContext ON dbo.IllustrationLibraryItems(SourceProjectID, SourceBookID, SourceChapterID, SourceSceneID, LinkedEntityType, LinkedEntityID);
GO
UPDATE dbo.IllustrationLibraryItems
SET ImageKind = COALESCE(ImageKind, Category),
CanonicalName = COALESCE(CanonicalName, JSON_VALUE(CASE WHEN ISJSON(MetadataJson) = 1 THEN MetadataJson ELSE N'{}' END, '$.canonicalName'), DisplayTitle),
BaseType = COALESCE(BaseType, JSON_VALUE(CASE WHEN ISJSON(MetadataJson) = 1 THEN MetadataJson ELSE N'{}' END, '$.baseType'), JSON_VALUE(CASE WHEN ISJSON(MetadataJson) = 1 THEN MetadataJson ELSE N'{}' END, '$.locationType'), JSON_VALUE(CASE WHEN ISJSON(SpecificationJson) = 1 THEN SpecificationJson ELSE N'{}' END, '$.locationType'), JSON_VALUE(CASE WHEN ISJSON(SpecificationJson) = 1 THEN SpecificationJson ELSE N'{}' END, '$.objectType')),
SpecificityLevel = COALESCE(SpecificityLevel, JSON_VALUE(CASE WHEN ISJSON(MetadataJson) = 1 THEN MetadataJson ELSE N'{}' END, '$.specificityLevel'), CASE WHEN StableCode LIKE N'demand-%' THEN N'Archetype' ELSE N'Generic' END),
IsFallbackClassification = CASE
WHEN IsFallbackClassification = 1 THEN 1
WHEN Category = N'Location' AND (DisplayTitle LIKE N'%Unknown Interior%' OR StableCode LIKE N'%unknowninterior%' OR JSON_VALUE(CASE WHEN ISJSON(SpecificationJson) = 1 THEN SpecificationJson ELSE N'{}' END, '$.locationType') = N'UnknownInterior') THEN 1
WHEN Category = N'Asset' AND (DisplayTitle LIKE N'%Unknown Object%' OR StableCode LIKE N'%unknownobject%' OR JSON_VALUE(CASE WHEN ISJSON(SpecificationJson) = 1 THEN SpecificationJson ELSE N'{}' END, '$.objectType') = N'UnknownObject') THEN 1
ELSE 0
END,
ReuseScope = COALESCE(ReuseScope, CASE
WHEN StableCode LIKE N'demand-%' THEN N'Book'
WHEN StableCode LIKE N'loc-%' OR StableCode LIKE N'char-%' OR StableCode LIKE N'asset-%' THEN N'Global'
ELSE N'Project'
END),
GenerationStatus = COALESCE(GenerationStatus, Status),
UpdatedUtc = SYSUTCDATETIME()
WHERE IsActive = 1
AND (
ImageKind IS NULL
OR CanonicalName IS NULL
OR SpecificityLevel IS NULL
OR ReuseScope IS NULL
OR GenerationStatus IS NULL
);
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_List
@Category nvarchar(40) = NULL,
@Status nvarchar(40) = NULL,
@Search nvarchar(120) = NULL
AS
BEGIN
SET NOCOUNT ON;
SELECT *
FROM dbo.IllustrationLibraryItems
WHERE IsArchived = 0
AND (@Category IS NULL OR Category = @Category)
AND (@Status IS NULL OR Status = @Status)
AND (
@Search IS NULL
OR StableCode LIKE N'%' + @Search + N'%'
OR DisplayTitle LIKE N'%' + @Search + N'%'
OR ShortDescription LIKE N'%' + @Search + N'%'
OR CanonicalName LIKE N'%' + @Search + N'%'
OR BaseType LIKE N'%' + @Search + N'%'
)
ORDER BY
CASE Status
WHEN N'Generating' THEN 1
WHEN N'Queued' THEN 2
WHEN N'Generated' THEN 3
WHEN N'Planned' THEN 4
WHEN N'Failed' THEN 5
WHEN N'Rejected' THEN 6
WHEN N'Approved' THEN 7
ELSE 8
END,
UpdatedUtc DESC,
IllustrationLibraryItemID DESC;
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_Summary
AS
BEGIN
SET NOCOUNT ON;
SELECT
COUNT(1) AS TotalCount,
ISNULL(SUM(CASE WHEN Status = N'Planned' AND IsArchived = 0 THEN 1 ELSE 0 END), 0) AS PlannedCount,
ISNULL(SUM(CASE WHEN Status = N'Queued' AND IsArchived = 0 THEN 1 ELSE 0 END), 0) AS QueuedCount,
ISNULL(SUM(CASE WHEN Status = N'Generating' AND IsArchived = 0 THEN 1 ELSE 0 END), 0) AS GeneratingCount,
ISNULL(SUM(CASE WHEN Status = N'Generated' AND IsArchived = 0 THEN 1 ELSE 0 END), 0) AS GeneratedCount,
ISNULL(SUM(CASE WHEN Status = N'Approved' AND IsArchived = 0 THEN 1 ELSE 0 END), 0) AS ApprovedCount,
ISNULL(SUM(CASE WHEN Status = N'Rejected' AND IsArchived = 0 THEN 1 ELSE 0 END), 0) AS RejectedCount,
ISNULL(SUM(CASE WHEN Status = N'Failed' AND IsArchived = 0 THEN 1 ELSE 0 END), 0) AS FailedCount,
ISNULL(SUM(CASE WHEN Status = N'Superseded' AND IsArchived = 0 THEN 1 ELSE 0 END), 0) AS SupersededCount,
ISNULL(SUM(CASE WHEN Status = N'Disabled' AND IsArchived = 0 THEN 1 ELSE 0 END), 0) AS DisabledCount,
ISNULL(SUM(CASE WHEN IsArchived = 1 THEN 1 ELSE 0 END), 0) AS ArchivedCount
FROM dbo.IllustrationLibraryItems;
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_UpsertPlanned
@Category nvarchar(40),
@StableCode nvarchar(120),
@DisplayTitle nvarchar(200),
@ShortDescription nvarchar(500) = NULL,
@MetadataJson nvarchar(max) = NULL,
@SpecificationJson nvarchar(max) = NULL
AS
BEGIN
SET NOCOUNT ON;
IF EXISTS (
SELECT 1
FROM dbo.IllustrationLibraryItems
WHERE StableCode = @StableCode
AND ParentItemID IS NULL
AND IsActive = 1
AND IsArchived = 0
AND Status IN (N'Planned', N'Queued', N'Generating', N'Generated', N'Approved')
)
BEGIN
SELECT * FROM dbo.IllustrationLibraryItems
WHERE StableCode = @StableCode
AND ParentItemID IS NULL
AND IsActive = 1
AND IsArchived = 0
AND Status IN (N'Planned', N'Queued', N'Generating', N'Generated', N'Approved');
RETURN;
END;
INSERT dbo.IllustrationLibraryItems
(Category, StableCode, DisplayTitle, ShortDescription, MetadataJson, SpecificationJson, Status, ImageKind, CanonicalName, BaseType, SpecificityLevel, IsFallbackClassification, ReuseScope, SourceProjectID, SourceBookID, SourceChapterID, SourceSceneID, LinkedEntityType, LinkedEntityID, GenerationReason, GenerationStatus)
VALUES
(
@Category,
@StableCode,
@DisplayTitle,
@ShortDescription,
@MetadataJson,
@SpecificationJson,
N'Planned',
COALESCE(JSON_VALUE(CASE WHEN ISJSON(@MetadataJson) = 1 THEN @MetadataJson ELSE N'{}' END, '$.imageKind'), @Category),
COALESCE(JSON_VALUE(CASE WHEN ISJSON(@MetadataJson) = 1 THEN @MetadataJson ELSE N'{}' END, '$.canonicalName'), @DisplayTitle),
COALESCE(JSON_VALUE(CASE WHEN ISJSON(@MetadataJson) = 1 THEN @MetadataJson ELSE N'{}' END, '$.baseType'), JSON_VALUE(CASE WHEN ISJSON(@SpecificationJson) = 1 THEN @SpecificationJson ELSE N'{}' END, CASE WHEN @Category = N'Location' THEN '$.locationType' WHEN @Category = N'Asset' THEN '$.objectType' ELSE '$.presentation' END)),
COALESCE(JSON_VALUE(CASE WHEN ISJSON(@MetadataJson) = 1 THEN @MetadataJson ELSE N'{}' END, '$.specificityLevel'), N'Archetype'),
CASE WHEN JSON_VALUE(CASE WHEN ISJSON(@MetadataJson) = 1 THEN @MetadataJson ELSE N'{}' END, '$.isFallbackClassification') IN (N'true', N'True', N'1') THEN 1 ELSE 0 END,
COALESCE(JSON_VALUE(CASE WHEN ISJSON(@MetadataJson) = 1 THEN @MetadataJson ELSE N'{}' END, '$.reuseScope'), N'Book'),
TRY_CONVERT(int, JSON_VALUE(CASE WHEN ISJSON(@MetadataJson) = 1 THEN @MetadataJson ELSE N'{}' END, '$.sourceProjectId')),
TRY_CONVERT(int, JSON_VALUE(CASE WHEN ISJSON(@MetadataJson) = 1 THEN @MetadataJson ELSE N'{}' END, '$.sourceBookId')),
TRY_CONVERT(int, JSON_VALUE(CASE WHEN ISJSON(@MetadataJson) = 1 THEN @MetadataJson ELSE N'{}' END, '$.sourceChapterId')),
TRY_CONVERT(int, JSON_VALUE(CASE WHEN ISJSON(@MetadataJson) = 1 THEN @MetadataJson ELSE N'{}' END, '$.sourceSceneId')),
JSON_VALUE(CASE WHEN ISJSON(@MetadataJson) = 1 THEN @MetadataJson ELSE N'{}' END, '$.linkedEntityType'),
TRY_CONVERT(int, JSON_VALUE(CASE WHEN ISJSON(@MetadataJson) = 1 THEN @MetadataJson ELSE N'{}' END, '$.linkedEntityId')),
COALESCE(JSON_VALUE(CASE WHEN ISJSON(@MetadataJson) = 1 THEN @MetadataJson ELSE N'{}' END, '$.generationReason'), N'Demand generation'),
N'Planned'
);
SELECT * FROM dbo.IllustrationLibraryItems WHERE IllustrationLibraryItemID = SCOPE_IDENTITY();
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_Queue
@IllustrationLibraryItemID int = NULL,
@Category nvarchar(40) = NULL
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.IllustrationLibraryItems
SET Status = N'Queued',
GenerationStatus = N'Queued',
QueuedUtc = SYSUTCDATETIME(),
UpdatedUtc = SYSUTCDATETIME(),
LastError = NULL
WHERE (@IllustrationLibraryItemID IS NULL OR IllustrationLibraryItemID = @IllustrationLibraryItemID)
AND (@Category IS NULL OR Category = @Category)
AND Status IN (N'Planned', N'Failed', N'Rejected')
AND IsActive = 1
AND IsArchived = 0;
SELECT @@ROWCOUNT AS AffectedRows;
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_ClaimNext
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRANSACTION;
DECLARE @Claimed TABLE (IllustrationLibraryItemID int NOT NULL);
;WITH nextItem AS
(
SELECT TOP (1) *
FROM dbo.IllustrationLibraryItems WITH (UPDLOCK, READPAST, ROWLOCK)
WHERE Status = N'Queued' AND IsActive = 1 AND IsArchived = 0
ORDER BY QueuedUtc, IllustrationLibraryItemID
)
UPDATE nextItem
SET Status = N'Generating',
GenerationStatus = N'Generating',
AttemptCount = AttemptCount + 1,
StartedUtc = SYSUTCDATETIME(),
UpdatedUtc = SYSUTCDATETIME()
OUTPUT inserted.IllustrationLibraryItemID INTO @Claimed;
SELECT item.*
FROM dbo.IllustrationLibraryItems item
INNER JOIN @Claimed claimed ON claimed.IllustrationLibraryItemID = item.IllustrationLibraryItemID;
COMMIT TRANSACTION;
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_MarkGenerated
@IllustrationLibraryItemID int,
@FilePath nvarchar(500),
@ThumbnailPath nvarchar(500),
@Width int,
@Height int,
@FileSizeBytes bigint,
@MimeType nvarchar(100),
@Provider nvarchar(80),
@Model nvarchar(120),
@PromptText nvarchar(max),
@PromptTemplateVersion nvarchar(40),
@ProviderRequestID nvarchar(160) = NULL,
@Quality nvarchar(40) = NULL,
@Size nvarchar(40) = NULL,
@OutputFormat nvarchar(40) = NULL
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.IllustrationLibraryItems
SET Status = N'Generated',
GenerationStatus = N'Generated',
FilePath = @FilePath,
ThumbnailPath = @ThumbnailPath,
Width = @Width,
Height = @Height,
FileSizeBytes = @FileSizeBytes,
MimeType = @MimeType,
Provider = @Provider,
Model = @Model,
Quality = @Quality,
Size = @Size,
OutputFormat = @OutputFormat,
PromptText = @PromptText,
PromptTemplateVersion = @PromptTemplateVersion,
ProviderRequestID = @ProviderRequestID,
CompletedUtc = SYSUTCDATETIME(),
UpdatedUtc = SYSUTCDATETIME(),
LastError = NULL
WHERE IllustrationLibraryItemID = @IllustrationLibraryItemID
AND Status = N'Generating'
AND IsArchived = 0;
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_MarkFailed
@IllustrationLibraryItemID int,
@LastError nvarchar(1000)
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.IllustrationLibraryItems
SET Status = N'Failed',
GenerationStatus = N'Failed',
LastError = @LastError,
CompletedUtc = SYSUTCDATETIME(),
UpdatedUtc = SYSUTCDATETIME()
WHERE IllustrationLibraryItemID = @IllustrationLibraryItemID
AND Status = N'Generating'
AND IsArchived = 0;
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_GetApproved
AS
BEGIN
SET NOCOUNT ON;
SELECT *
FROM dbo.IllustrationLibraryItems
WHERE Status = N'Approved'
AND IsApproved = 1
AND IsActive = 1
AND IsArchived = 0
AND FilePath IS NOT NULL
ORDER BY StableCode, ApprovedUtc DESC;
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_ArchiveGeneratedSet
@ProjectID int = NULL,
@BookID int = NULL,
@ImportSessionID int = NULL,
@CreatedBeforeUtc datetime2(0) = NULL,
@StableCodePrefix nvarchar(120) = NULL,
@Reason nvarchar(500) = NULL
AS
BEGIN
SET NOCOUNT ON;
UPDATE item
SET IsArchived = 1,
IsActive = 0,
ArchivedAt = COALESCE(ArchivedAt, SYSUTCDATETIME()),
ArchiveReason = COALESCE(@Reason, N'Archived generated illustration set out of active reuse pool.'),
GenerationStatus = COALESCE(GenerationStatus, Status),
UpdatedUtc = SYSUTCDATETIME()
FROM dbo.IllustrationLibraryItems item
WHERE item.IsArchived = 0
AND item.Status IN (N'Generated', N'Approved')
AND item.Provider = N'OpenAI'
AND (@ProjectID IS NULL OR item.SourceProjectID = @ProjectID)
AND (@BookID IS NULL OR item.SourceBookID = @BookID)
AND (@ImportSessionID IS NULL OR TRY_CONVERT(int, JSON_VALUE(CASE WHEN ISJSON(item.MetadataJson) = 1 THEN item.MetadataJson ELSE N'{}' END, '$.sourceImportSessionId')) = @ImportSessionID)
AND (@CreatedBeforeUtc IS NULL OR item.CreatedUtc < @CreatedBeforeUtc)
AND (@StableCodePrefix IS NULL OR item.StableCode LIKE @StableCodePrefix + N'%');
SELECT @@ROWCOUNT AS ArchivedCount;
END;
GO

View File

@ -1,632 +0,0 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF OBJECT_ID(N'dbo.NameLocales', N'U') IS NULL
BEGIN
CREATE TABLE dbo.NameLocales
(
NameLocaleID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_NameLocales PRIMARY KEY,
LocaleCode nvarchar(16) NOT NULL,
DisplayName nvarchar(100) NOT NULL,
IsoCountryCode nvarchar(3) NOT NULL,
EvidenceBasis nvarchar(300) NOT NULL,
DefaultConfidence decimal(9,4) NOT NULL,
SortOrder int NOT NULL,
IsActive bit NOT NULL CONSTRAINT DF_NameLocales_IsActive DEFAULT 1,
CreatedUtc datetime2(0) NOT NULL CONSTRAINT DF_NameLocales_CreatedUtc DEFAULT SYSUTCDATETIME(),
UpdatedUtc datetime2(0) NOT NULL CONSTRAINT DF_NameLocales_UpdatedUtc DEFAULT SYSUTCDATETIME(),
CONSTRAINT UQ_NameLocales_LocaleCode UNIQUE (LocaleCode),
CONSTRAINT CK_NameLocales_DefaultConfidence CHECK (DefaultConfidence >= 0 AND DefaultConfidence <= 1)
);
END;
GO
IF OBJECT_ID(N'dbo.GivenNames', N'U') IS NULL
BEGIN
CREATE TABLE dbo.GivenNames
(
GivenNameID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_GivenNames PRIMARY KEY,
GivenNameKey nvarchar(80) NOT NULL,
DisplayName nvarchar(120) NOT NULL,
NormalisedName nvarchar(120) COLLATE Latin1_General_100_CI_AI_SC NOT NULL,
NameType nvarchar(40) NOT NULL,
IsWordAmbiguous bit NOT NULL,
IsActive bit NOT NULL CONSTRAINT DF_GivenNames_IsActive DEFAULT 1,
Notes nvarchar(500) NULL,
CreatedUtc datetime2(0) NOT NULL CONSTRAINT DF_GivenNames_CreatedUtc DEFAULT SYSUTCDATETIME(),
UpdatedUtc datetime2(0) NOT NULL CONSTRAINT DF_GivenNames_UpdatedUtc DEFAULT SYSUTCDATETIME(),
CONSTRAINT UQ_GivenNames_GivenNameKey UNIQUE (GivenNameKey),
CONSTRAINT UQ_GivenNames_NormalisedName UNIQUE (NormalisedName),
CONSTRAINT CK_GivenNames_NameType CHECK (NameType IN (N'Canonical', N'Independent', N'Diminutive', N'Nickname', N'Variant'))
);
END;
GO
IF OBJECT_ID(N'dbo.NameRelationships', N'U') IS NULL
BEGIN
CREATE TABLE dbo.NameRelationships
(
NameRelationshipID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_NameRelationships PRIMARY KEY,
RelationshipKey nvarchar(80) NOT NULL,
FromGivenNameID int NOT NULL,
ToGivenNameID int NOT NULL,
RelationshipType nvarchar(40) NOT NULL,
Strength decimal(9,4) NOT NULL,
IsBidirectional bit NOT NULL,
IsActive bit NOT NULL CONSTRAINT DF_NameRelationships_IsActive DEFAULT 1,
CurationSource nvarchar(120) NULL,
Notes nvarchar(500) NULL,
CreatedUtc datetime2(0) NOT NULL CONSTRAINT DF_NameRelationships_CreatedUtc DEFAULT SYSUTCDATETIME(),
UpdatedUtc datetime2(0) NOT NULL CONSTRAINT DF_NameRelationships_UpdatedUtc DEFAULT SYSUTCDATETIME(),
CONSTRAINT UQ_NameRelationships_RelationshipKey UNIQUE (RelationshipKey),
CONSTRAINT FK_NameRelationships_FromGivenName FOREIGN KEY (FromGivenNameID) REFERENCES dbo.GivenNames(GivenNameID),
CONSTRAINT FK_NameRelationships_ToGivenName FOREIGN KEY (ToGivenNameID) REFERENCES dbo.GivenNames(GivenNameID),
CONSTRAINT CK_NameRelationships_NotSelf CHECK (FromGivenNameID <> ToGivenNameID),
CONSTRAINT CK_NameRelationships_Strength CHECK (Strength >= 0 AND Strength <= 1),
CONSTRAINT CK_NameRelationships_Type CHECK (RelationshipType IN (N'ShortForm', N'Diminutive', N'Nickname', N'SpellingVariant', N'AlternativeForm', N'AnglicisedForm'))
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_NameRelationships_ActivePairType' AND object_id = OBJECT_ID(N'dbo.NameRelationships'))
CREATE UNIQUE INDEX UX_NameRelationships_ActivePairType ON dbo.NameRelationships(FromGivenNameID, ToGivenNameID, RelationshipType) WHERE IsActive = 1;
GO
IF OBJECT_ID(N'dbo.NameUsages', N'U') IS NULL
BEGIN
CREATE TABLE dbo.NameUsages
(
NameUsageID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_NameUsages PRIMARY KEY,
UsageKey nvarchar(80) NOT NULL,
GivenNameID int NOT NULL,
NameLocaleID int NOT NULL,
BirthYearFrom int NOT NULL,
BirthYearTo int NOT NULL,
MaleWeight decimal(9,4) NOT NULL,
FemaleWeight decimal(9,4) NOT NULL,
UsageWeight decimal(9,4) NOT NULL,
Confidence decimal(9,4) NOT NULL,
SourceType nvarchar(60) NOT NULL,
SourceReference nvarchar(300) NULL,
IsActive bit NOT NULL CONSTRAINT DF_NameUsages_IsActive DEFAULT 1,
Notes nvarchar(500) NULL,
CreatedUtc datetime2(0) NOT NULL CONSTRAINT DF_NameUsages_CreatedUtc DEFAULT SYSUTCDATETIME(),
UpdatedUtc datetime2(0) NOT NULL CONSTRAINT DF_NameUsages_UpdatedUtc DEFAULT SYSUTCDATETIME(),
CONSTRAINT UQ_NameUsages_UsageKey UNIQUE (UsageKey),
CONSTRAINT UQ_NameUsages_NameLocaleYears UNIQUE (GivenNameID, NameLocaleID, BirthYearFrom, BirthYearTo),
CONSTRAINT FK_NameUsages_GivenName FOREIGN KEY (GivenNameID) REFERENCES dbo.GivenNames(GivenNameID),
CONSTRAINT FK_NameUsages_NameLocale FOREIGN KEY (NameLocaleID) REFERENCES dbo.NameLocales(NameLocaleID),
CONSTRAINT CK_NameUsages_YearOrder CHECK (BirthYearFrom <= BirthYearTo),
CONSTRAINT CK_NameUsages_Weights CHECK (MaleWeight >= 0 AND MaleWeight <= 1 AND FemaleWeight >= 0 AND FemaleWeight <= 1 AND UsageWeight >= 0 AND UsageWeight <= 1 AND Confidence >= 0 AND Confidence <= 1),
CONSTRAINT CK_NameUsages_GenderTotal CHECK (ABS((MaleWeight + FemaleWeight) - 1.0000) <= 0.0002),
CONSTRAINT CK_NameUsages_SourceType CHECK (SourceType IN (N'OfficialStatisticalDerived', N'OfficialSourceBlendedEstimate'))
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_NameUsages_QueryPath' AND object_id = OBJECT_ID(N'dbo.NameUsages'))
CREATE INDEX IX_NameUsages_QueryPath ON dbo.NameUsages(GivenNameID, NameLocaleID, IsActive, BirthYearFrom, BirthYearTo) INCLUDE (MaleWeight, FemaleWeight, UsageWeight, Confidence, SourceType);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_GivenNames_Search' AND object_id = OBJECT_ID(N'dbo.GivenNames'))
CREATE INDEX IX_GivenNames_Search ON dbo.GivenNames(IsActive, NormalisedName, DisplayName) INCLUDE (NameType, IsWordAmbiguous);
GO
IF OBJECT_ID(N'dbo.NameReferenceImports', N'U') IS NULL
BEGIN
CREATE TABLE dbo.NameReferenceImports
(
NameReferenceImportID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_NameReferenceImports PRIMARY KEY,
PackageID nvarchar(120) NOT NULL,
SemanticVersion nvarchar(40) NOT NULL,
PackageChecksum nvarchar(128) NULL,
StartedUtc datetime2(0) NOT NULL CONSTRAINT DF_NameReferenceImports_StartedUtc DEFAULT SYSUTCDATETIME(),
CompletedUtc datetime2(0) NULL,
Succeeded bit NOT NULL CONSTRAINT DF_NameReferenceImports_Succeeded DEFAULT 0,
LocaleRows int NOT NULL CONSTRAINT DF_NameReferenceImports_LocaleRows DEFAULT 0,
GivenNameRows int NOT NULL CONSTRAINT DF_NameReferenceImports_GivenNameRows DEFAULT 0,
RelationshipRows int NOT NULL CONSTRAINT DF_NameReferenceImports_RelationshipRows DEFAULT 0,
UsageRows int NOT NULL CONSTRAINT DF_NameReferenceImports_UsageRows DEFAULT 0,
ErrorSummary nvarchar(max) NULL
);
END;
GO
IF TYPE_ID(N'dbo.NameLocaleImportTable') IS NULL
CREATE TYPE dbo.NameLocaleImportTable AS TABLE
(
LocaleCode nvarchar(16) NOT NULL,
DisplayName nvarchar(100) NOT NULL,
IsoCountryCode nvarchar(3) NOT NULL,
EvidenceBasis nvarchar(300) NOT NULL,
DefaultConfidence decimal(9,4) NOT NULL,
SortOrder int NOT NULL,
IsActive bit NOT NULL
);
GO
IF TYPE_ID(N'dbo.GivenNameImportTable') IS NULL
CREATE TYPE dbo.GivenNameImportTable AS TABLE
(
GivenNameKey nvarchar(80) NOT NULL,
DisplayName nvarchar(120) NOT NULL,
NormalisedName nvarchar(120) NOT NULL,
NameType nvarchar(40) NOT NULL,
IsWordAmbiguous bit NOT NULL,
IsActive bit NOT NULL,
Notes nvarchar(500) NULL
);
GO
IF TYPE_ID(N'dbo.NameRelationshipImportTable') IS NULL
CREATE TYPE dbo.NameRelationshipImportTable AS TABLE
(
RelationshipKey nvarchar(80) NOT NULL,
FromGivenNameKey nvarchar(80) NOT NULL,
ToGivenNameKey nvarchar(80) NOT NULL,
RelationshipType nvarchar(40) NOT NULL,
Strength decimal(9,4) NOT NULL,
IsBidirectional bit NOT NULL,
IsActive bit NOT NULL,
CurationSource nvarchar(120) NULL,
Notes nvarchar(500) NULL
);
GO
IF TYPE_ID(N'dbo.NameUsageImportTable') IS NULL
CREATE TYPE dbo.NameUsageImportTable AS TABLE
(
UsageKey nvarchar(80) NOT NULL,
GivenNameKey nvarchar(80) NOT NULL,
LocaleCode nvarchar(16) NOT NULL,
BirthYearFrom int NOT NULL,
BirthYearTo int NOT NULL,
MaleWeight decimal(9,4) NOT NULL,
FemaleWeight decimal(9,4) NOT NULL,
UsageWeight decimal(9,4) NOT NULL,
Confidence decimal(9,4) NOT NULL,
SourceType nvarchar(60) NOT NULL,
SourceReference nvarchar(300) NULL,
IsActive bit NOT NULL,
Notes nvarchar(500) NULL
);
GO
CREATE OR ALTER PROCEDURE dbo.NameReferenceImport_GetLatest
@PackageID nvarchar(120),
@SemanticVersion nvarchar(40)
AS
BEGIN
SET NOCOUNT ON;
SELECT TOP (1) *
FROM dbo.NameReferenceImports
WHERE PackageID = @PackageID
AND SemanticVersion = @SemanticVersion
AND Succeeded = 1
ORDER BY CompletedUtc DESC, NameReferenceImportID DESC;
END;
GO
CREATE OR ALTER PROCEDURE dbo.NameReferenceImport_Start
@PackageID nvarchar(120),
@SemanticVersion nvarchar(40),
@PackageChecksum nvarchar(128)
AS
BEGIN
SET NOCOUNT ON;
INSERT dbo.NameReferenceImports (PackageID, SemanticVersion, PackageChecksum)
VALUES (@PackageID, @SemanticVersion, @PackageChecksum);
SELECT CAST(SCOPE_IDENTITY() AS int) AS NameReferenceImportID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.NameReferenceImport_Complete
@NameReferenceImportID int,
@Succeeded bit,
@LocaleRows int,
@GivenNameRows int,
@RelationshipRows int,
@UsageRows int,
@ErrorSummary nvarchar(max) = NULL
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.NameReferenceImports
SET CompletedUtc = SYSUTCDATETIME(),
Succeeded = @Succeeded,
LocaleRows = @LocaleRows,
GivenNameRows = @GivenNameRows,
RelationshipRows = @RelationshipRows,
UsageRows = @UsageRows,
ErrorSummary = @ErrorSummary
WHERE NameReferenceImportID = @NameReferenceImportID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.NameLocale_BulkUpsert
@Rows dbo.NameLocaleImportTable READONLY
AS
BEGIN
SET NOCOUNT ON;
MERGE dbo.NameLocales WITH (HOLDLOCK) AS target
USING @Rows AS source
ON target.LocaleCode = source.LocaleCode
WHEN MATCHED THEN
UPDATE SET DisplayName = source.DisplayName,
IsoCountryCode = source.IsoCountryCode,
EvidenceBasis = source.EvidenceBasis,
DefaultConfidence = source.DefaultConfidence,
SortOrder = source.SortOrder,
IsActive = source.IsActive,
UpdatedUtc = SYSUTCDATETIME()
WHEN NOT MATCHED THEN
INSERT (LocaleCode, DisplayName, IsoCountryCode, EvidenceBasis, DefaultConfidence, SortOrder, IsActive)
VALUES (source.LocaleCode, source.DisplayName, source.IsoCountryCode, source.EvidenceBasis, source.DefaultConfidence, source.SortOrder, source.IsActive);
END;
GO
CREATE OR ALTER PROCEDURE dbo.GivenName_BulkUpsert
@Rows dbo.GivenNameImportTable READONLY
AS
BEGIN
SET NOCOUNT ON;
MERGE dbo.GivenNames WITH (HOLDLOCK) AS target
USING @Rows AS source
ON target.GivenNameKey = source.GivenNameKey
WHEN MATCHED THEN
UPDATE SET DisplayName = source.DisplayName,
NormalisedName = source.NormalisedName,
NameType = source.NameType,
IsWordAmbiguous = source.IsWordAmbiguous,
IsActive = source.IsActive,
Notes = source.Notes,
UpdatedUtc = SYSUTCDATETIME()
WHEN NOT MATCHED THEN
INSERT (GivenNameKey, DisplayName, NormalisedName, NameType, IsWordAmbiguous, IsActive, Notes)
VALUES (source.GivenNameKey, source.DisplayName, source.NormalisedName, source.NameType, source.IsWordAmbiguous, source.IsActive, source.Notes);
END;
GO
CREATE OR ALTER PROCEDURE dbo.NameRelationship_BulkUpsert
@Rows dbo.NameRelationshipImportTable READONLY
AS
BEGIN
SET NOCOUNT ON;
MERGE dbo.NameRelationships WITH (HOLDLOCK) AS target
USING
(
SELECT r.*, f.GivenNameID AS FromGivenNameID, t.GivenNameID AS ToGivenNameID
FROM @Rows r
INNER JOIN dbo.GivenNames f ON f.GivenNameKey = r.FromGivenNameKey
INNER JOIN dbo.GivenNames t ON t.GivenNameKey = r.ToGivenNameKey
) AS source
ON target.RelationshipKey = source.RelationshipKey
WHEN MATCHED THEN
UPDATE SET FromGivenNameID = source.FromGivenNameID,
ToGivenNameID = source.ToGivenNameID,
RelationshipType = source.RelationshipType,
Strength = source.Strength,
IsBidirectional = source.IsBidirectional,
IsActive = source.IsActive,
CurationSource = source.CurationSource,
Notes = source.Notes,
UpdatedUtc = SYSUTCDATETIME()
WHEN NOT MATCHED THEN
INSERT (RelationshipKey, FromGivenNameID, ToGivenNameID, RelationshipType, Strength, IsBidirectional, IsActive, CurationSource, Notes)
VALUES (source.RelationshipKey, source.FromGivenNameID, source.ToGivenNameID, source.RelationshipType, source.Strength, source.IsBidirectional, source.IsActive, source.CurationSource, source.Notes);
END;
GO
CREATE OR ALTER PROCEDURE dbo.NameUsage_BulkUpsert
@Rows dbo.NameUsageImportTable READONLY
AS
BEGIN
SET NOCOUNT ON;
MERGE dbo.NameUsages WITH (HOLDLOCK) AS target
USING
(
SELECT r.*, g.GivenNameID, l.NameLocaleID
FROM @Rows r
INNER JOIN dbo.GivenNames g ON g.GivenNameKey = r.GivenNameKey
INNER JOIN dbo.NameLocales l ON l.LocaleCode = r.LocaleCode
) AS source
ON target.UsageKey = source.UsageKey
WHEN MATCHED THEN
UPDATE SET GivenNameID = source.GivenNameID,
NameLocaleID = source.NameLocaleID,
BirthYearFrom = source.BirthYearFrom,
BirthYearTo = source.BirthYearTo,
MaleWeight = source.MaleWeight,
FemaleWeight = source.FemaleWeight,
UsageWeight = source.UsageWeight,
Confidence = source.Confidence,
SourceType = source.SourceType,
SourceReference = source.SourceReference,
IsActive = source.IsActive,
Notes = source.Notes,
UpdatedUtc = SYSUTCDATETIME()
WHEN NOT MATCHED THEN
INSERT (UsageKey, GivenNameID, NameLocaleID, BirthYearFrom, BirthYearTo, MaleWeight, FemaleWeight, UsageWeight, Confidence, SourceType, SourceReference, IsActive, Notes)
VALUES (source.UsageKey, source.GivenNameID, source.NameLocaleID, source.BirthYearFrom, source.BirthYearTo, source.MaleWeight, source.FemaleWeight, source.UsageWeight, source.Confidence, source.SourceType, source.SourceReference, source.IsActive, source.Notes);
END;
GO
CREATE OR ALTER PROCEDURE dbo.NameLocale_List
AS
BEGIN
SET NOCOUNT ON;
SELECT *
FROM dbo.NameLocales
WHERE IsActive = 1
ORDER BY SortOrder, DisplayName;
END;
GO
CREATE OR ALTER PROCEDURE dbo.GivenName_Recognise
@NormalisedName nvarchar(120),
@LocaleCode nvarchar(16) = NULL,
@BirthYear int = NULL
AS
BEGIN
SET NOCOUNT ON;
DECLARE @GivenNameID int;
DECLARE @NameLocaleID int;
SELECT TOP (1) @GivenNameID = GivenNameID
FROM dbo.GivenNames
WHERE NormalisedName = @NormalisedName COLLATE Latin1_General_100_CI_AI_SC
AND IsActive = 1;
SELECT TOP (1) @NameLocaleID = NameLocaleID
FROM dbo.NameLocales
WHERE IsActive = 1
AND LocaleCode = COALESCE(@LocaleCode, N'en-US');
IF @NameLocaleID IS NULL
SELECT TOP (1) @NameLocaleID = NameLocaleID FROM dbo.NameLocales WHERE LocaleCode = N'en-US' AND IsActive = 1;
SELECT *
FROM dbo.GivenNames
WHERE GivenNameID = @GivenNameID;
IF @GivenNameID IS NOT NULL
BEGIN
SELECT TOP (1)
u.*,
l.LocaleCode,
l.DisplayName AS LocaleDisplayName,
CASE
WHEN @BirthYear IS NULL THEN N'General'
WHEN @BirthYear BETWEEN u.BirthYearFrom AND u.BirthYearTo THEN N'ExactEra'
ELSE N'NearestEra'
END AS YearMatchKind
FROM dbo.NameUsages u
INNER JOIN dbo.NameLocales l ON l.NameLocaleID = u.NameLocaleID
WHERE u.GivenNameID = @GivenNameID
AND u.NameLocaleID = @NameLocaleID
AND u.IsActive = 1
ORDER BY
CASE WHEN @BirthYear IS NOT NULL AND @BirthYear BETWEEN u.BirthYearFrom AND u.BirthYearTo THEN 0 ELSE 1 END,
CASE WHEN @BirthYear IS NULL THEN ABS(1960 - ((u.BirthYearFrom + u.BirthYearTo) / 2)) ELSE ABS(@BirthYear - ((u.BirthYearFrom + u.BirthYearTo) / 2)) END,
u.UsageWeight DESC;
EXEC dbo.GivenName_Relationships @GivenNameID = @GivenNameID;
END;
END;
GO
CREATE OR ALTER PROCEDURE dbo.GivenName_Relationships
@GivenNameID int
AS
BEGIN
SET NOCOUNT ON;
SELECT
r.NameRelationshipID,
r.RelationshipKey,
r.FromGivenNameID,
r.ToGivenNameID,
f.DisplayName AS FromDisplayName,
t.DisplayName AS ToDisplayName,
r.RelationshipType,
r.Strength,
r.IsBidirectional,
r.IsActive,
r.CurationSource,
r.Notes
FROM dbo.NameRelationships r
INNER JOIN dbo.GivenNames f ON f.GivenNameID = r.FromGivenNameID
INNER JOIN dbo.GivenNames t ON t.GivenNameID = r.ToGivenNameID
WHERE r.IsActive = 1
AND (r.FromGivenNameID = @GivenNameID OR (r.IsBidirectional = 1 AND r.ToGivenNameID = @GivenNameID))
ORDER BY r.Strength DESC, t.DisplayName;
END;
GO
CREATE OR ALTER PROCEDURE dbo.GivenName_Search
@UserID int,
@ProjectID int,
@BookID int = NULL,
@Search nvarchar(120) = NULL,
@LocaleCode nvarchar(16) = NULL,
@BirthYear int = NULL,
@GenderPreference nvarchar(12) = NULL,
@GenderThreshold decimal(9,4) = NULL,
@NameType nvarchar(40) = NULL,
@MinimumUsage decimal(9,4) = NULL,
@UsedScope nvarchar(20) = N'Project',
@UsedOnly bit = NULL,
@Page int = 1,
@PageSize int = 25
AS
BEGIN
SET NOCOUNT ON;
IF NOT EXISTS (SELECT 1 FROM dbo.ProjectUserAccess WHERE ProjectID = @ProjectID AND UserID = @UserID AND IsActive = 1)
BEGIN
SELECT TOP (0) CAST(NULL AS int) AS GivenNameID;
RETURN;
END;
IF @Page < 1 SET @Page = 1;
IF @PageSize < 1 SET @PageSize = 25;
IF @PageSize > 100 SET @PageSize = 100;
DECLARE @NameLocaleID int = NULL;
IF @LocaleCode IS NOT NULL
BEGIN
SELECT TOP (1) @NameLocaleID = NameLocaleID FROM dbo.NameLocales WHERE LocaleCode = @LocaleCode AND IsActive = 1;
END;
;WITH UsageChoice AS
(
SELECT
u.*,
l.LocaleCode,
l.DisplayName AS LocaleDisplayName,
ROW_NUMBER() OVER (PARTITION BY u.GivenNameID ORDER BY
CASE WHEN @BirthYear IS NOT NULL AND @BirthYear BETWEEN u.BirthYearFrom AND u.BirthYearTo THEN 0 ELSE 1 END,
CASE WHEN @BirthYear IS NULL THEN ABS(1960 - ((u.BirthYearFrom + u.BirthYearTo) / 2)) ELSE ABS(@BirthYear - ((u.BirthYearFrom + u.BirthYearTo) / 2)) END,
u.UsageWeight DESC) AS rn
FROM dbo.NameUsages u
INNER JOIN dbo.NameLocales l ON l.NameLocaleID = u.NameLocaleID
WHERE u.IsActive = 1
AND (@NameLocaleID IS NULL OR u.NameLocaleID = @NameLocaleID)
),
Used AS
(
SELECT g.GivenNameID, COUNT(DISTINCT c.CharacterID) AS UsedCount,
STRING_AGG(CONVERT(nvarchar(max), c.CharacterName + N' (' + p.ProjectName + COALESCE(N' / ' + b.BookTitle, N'') + N')'), N'; ') AS UsedSummary
FROM dbo.GivenNames g
INNER JOIN
(
SELECT c.CharacterID, c.ProjectID, c.CharacterName, c.CharacterName AS MatchedName
FROM dbo.Characters c
WHERE c.IsArchived = 0
UNION ALL
SELECT c.CharacterID, c.ProjectID, c.CharacterName, ca.Alias AS MatchedName
FROM dbo.CharacterAliases ca
INNER JOIN dbo.Characters c ON c.CharacterID = ca.CharacterID
WHERE c.IsArchived = 0
) c ON UPPER(LTRIM(RTRIM(c.MatchedName))) = g.NormalisedName COLLATE Latin1_General_100_CI_AI_SC
INNER JOIN dbo.Projects p ON p.ProjectID = c.ProjectID
INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = c.ProjectID AND pua.UserID = @UserID AND pua.IsActive = 1
LEFT JOIN dbo.Books b ON b.ProjectID = c.ProjectID AND b.IsArchived = 0
WHERE (@UsedScope = N'Library' OR c.ProjectID = @ProjectID)
AND (@BookID IS NULL OR @UsedScope <> N'Book' OR b.BookID = @BookID)
GROUP BY g.GivenNameID
),
Related AS
(
SELECT x.GivenNameID,
STRING_AGG(CONVERT(nvarchar(max), x.DisplayName), N', ') AS RelatedForms
FROM
(
SELECT r.FromGivenNameID AS GivenNameID, t.DisplayName
FROM dbo.NameRelationships r
INNER JOIN dbo.GivenNames t ON t.GivenNameID = r.ToGivenNameID
WHERE r.IsActive = 1
UNION
SELECT r.ToGivenNameID AS GivenNameID, f.DisplayName
FROM dbo.NameRelationships r
INNER JOIN dbo.GivenNames f ON f.GivenNameID = r.FromGivenNameID
WHERE r.IsActive = 1 AND r.IsBidirectional = 1
) x
GROUP BY x.GivenNameID
),
Filtered AS
(
SELECT
g.GivenNameID, g.GivenNameKey, g.DisplayName, g.NormalisedName, g.NameType, g.IsWordAmbiguous,
u.MaleWeight, u.FemaleWeight, u.UsageWeight, u.Confidence, u.BirthYearFrom, u.BirthYearTo, u.LocaleCode, u.LocaleDisplayName, u.SourceType,
rel.RelatedForms,
COALESCE(used.UsedSummary, N'') AS UsedSummary,
COALESCE(used.UsedCount, 0) AS UsedCount,
COUNT(1) OVER() AS TotalRows
FROM dbo.GivenNames g
LEFT JOIN UsageChoice u ON u.GivenNameID = g.GivenNameID AND u.rn = 1
LEFT JOIN Used used ON used.GivenNameID = g.GivenNameID
LEFT JOIN Related rel ON rel.GivenNameID = g.GivenNameID
WHERE g.IsActive = 1
AND (@Search IS NULL OR g.NormalisedName LIKE UPPER(LTRIM(RTRIM(@Search))) + N'%' COLLATE Latin1_General_100_CI_AI_SC OR g.DisplayName LIKE @Search + N'%')
AND (@NameType IS NULL OR g.NameType = @NameType)
AND (@MinimumUsage IS NULL OR u.UsageWeight >= @MinimumUsage)
AND (@UsedOnly IS NULL OR (@UsedOnly = 1 AND COALESCE(used.UsedCount, 0) > 0) OR (@UsedOnly = 0 AND COALESCE(used.UsedCount, 0) = 0))
AND (
@GenderPreference IS NULL
OR @GenderThreshold IS NULL
OR (@GenderPreference = N'Female' AND u.FemaleWeight >= @GenderThreshold)
OR (@GenderPreference = N'Male' AND u.MaleWeight >= @GenderThreshold)
OR (@GenderPreference = N'Mixed' AND u.MaleWeight >= (1 - @GenderThreshold) AND u.FemaleWeight >= (1 - @GenderThreshold))
)
)
SELECT *
FROM Filtered
ORDER BY
CASE WHEN @Search IS NOT NULL AND NormalisedName = UPPER(LTRIM(RTRIM(@Search))) COLLATE Latin1_General_100_CI_AI_SC THEN 0 ELSE 1 END,
UsageWeight DESC,
DisplayName
OFFSET (@Page - 1) * @PageSize ROWS FETCH NEXT @PageSize ROWS ONLY;
END;
GO
CREATE OR ALTER PROCEDURE dbo.GivenName_UsedDecorations
@UserID int,
@ProjectID int,
@BookID int = NULL,
@NormalisedName nvarchar(120),
@UsedScope nvarchar(20) = N'Project'
AS
BEGIN
SET NOCOUNT ON;
IF NOT EXISTS (SELECT 1 FROM dbo.ProjectUserAccess WHERE ProjectID = @ProjectID AND UserID = @UserID AND IsActive = 1)
RETURN;
SELECT
rows.MatchedName,
rows.MatchType,
c.CharacterID,
c.CharacterName,
p.ProjectID,
p.ProjectName,
b.BookID,
b.BookTitle,
b.Subtitle AS BookSubtitle,
CASE WHEN b.BookID IS NOT NULL THEN N'Book' ELSE N'Project' END AS Scope
FROM
(
SELECT c.CharacterID, c.ProjectID, c.CharacterName AS MatchedName, N'Character' AS MatchType
FROM dbo.Characters c
WHERE c.IsArchived = 0
UNION ALL
SELECT ca.CharacterID, c.ProjectID, ca.Alias AS MatchedName, N'Alias' AS MatchType
FROM dbo.CharacterAliases ca
INNER JOIN dbo.Characters c ON c.CharacterID = ca.CharacterID
WHERE c.IsArchived = 0
) rows
INNER JOIN dbo.Characters c ON c.CharacterID = rows.CharacterID
INNER JOIN dbo.Projects p ON p.ProjectID = c.ProjectID
INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = c.ProjectID AND pua.UserID = @UserID AND pua.IsActive = 1
LEFT JOIN dbo.SceneCharacters sc ON sc.CharacterID = c.CharacterID
LEFT JOIN dbo.Scenes s ON s.SceneID = sc.SceneID AND s.IsArchived = 0
LEFT JOIN dbo.Chapters ch ON ch.ChapterID = s.ChapterID AND ch.IsArchived = 0
LEFT JOIN dbo.Books b ON b.BookID = ch.BookID AND b.IsArchived = 0
WHERE UPPER(LTRIM(RTRIM(rows.MatchedName))) = @NormalisedName COLLATE Latin1_General_100_CI_AI_SC
AND (@UsedScope = N'Library' OR c.ProjectID = @ProjectID)
AND (@BookID IS NULL OR @UsedScope <> N'Book' OR b.BookID = @BookID)
ORDER BY p.ProjectName, b.SortOrder, c.CharacterName;
END;
GO

View File

@ -1,122 +0,0 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF COL_LENGTH(N'dbo.StoryIntelligenceBookPipelines', N'CompletionEmailQueuedUtc') IS NULL
BEGIN
ALTER TABLE dbo.StoryIntelligenceBookPipelines
ADD CompletionEmailQueuedUtc datetime2 NULL;
END;
GO
IF COL_LENGTH(N'dbo.StoryIntelligenceBookPipelines', N'FailureEmailQueuedUtc') IS NULL
BEGIN
ALTER TABLE dbo.StoryIntelligenceBookPipelines
ADD FailureEmailQueuedUtc datetime2 NULL;
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceBookPipeline_TryBeginCompletionNotification
@BookID int,
@UserID int
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @ImportSessionID int;
DECLARE @ProjectID int;
DECLARE @HasActiveRuns bit;
DECLARE @HasFailedRuns bit;
DECLARE @ChapterCount int;
DECLARE @CompletedChapterCount int;
DECLARE @FailedChapterCount int;
DECLARE @NotificationKind nvarchar(20);
SELECT @ImportSessionID = p.StoryIntelligenceBookPipelineID,
@ProjectID = p.ProjectID
FROM dbo.StoryIntelligenceBookPipelines p
INNER JOIN dbo.ProjectUserAccess access ON access.ProjectID = p.ProjectID
AND access.UserID = @UserID
AND access.IsActive = 1
WHERE p.BookID = @BookID;
IF @ImportSessionID IS NULL
RETURN;
SELECT @HasActiveRuns = CASE WHEN EXISTS
(
SELECT 1
FROM dbo.StoryIntelligenceRuns
WHERE BookID = @BookID
AND UserID = @UserID
AND Status IN (N'Pending', N'Running')
) THEN 1 ELSE 0 END;
IF @HasActiveRuns = 1
RETURN;
SELECT @ChapterCount = COUNT(1),
@CompletedChapterCount = SUM(CASE WHEN Status IN (N'Completed', N'CompletedWithWarnings') THEN 1 ELSE 0 END),
@FailedChapterCount = SUM(CASE WHEN Status IN (N'Failed', N'Cancelled') THEN 1 ELSE 0 END),
@HasFailedRuns = CASE WHEN SUM(CASE WHEN Status IN (N'Failed', N'Cancelled') THEN 1 ELSE 0 END) > 0 THEN 1 ELSE 0 END
FROM dbo.StoryIntelligenceRuns
WHERE BookID = @BookID
AND UserID = @UserID;
IF COALESCE(@ChapterCount, 0) = 0
RETURN;
SET @NotificationKind = CASE WHEN @HasFailedRuns = 1 THEN N'Failure' ELSE N'Success' END;
BEGIN TRANSACTION;
IF @NotificationKind = N'Failure'
BEGIN
UPDATE dbo.StoryIntelligenceBookPipelines
SET FailureEmailQueuedUtc = SYSUTCDATETIME(),
UpdatedUtc = SYSUTCDATETIME()
WHERE StoryIntelligenceBookPipelineID = @ImportSessionID
AND FailureEmailQueuedUtc IS NULL;
IF @@ROWCOUNT = 0
BEGIN
ROLLBACK TRANSACTION;
RETURN;
END;
END
ELSE
BEGIN
UPDATE dbo.StoryIntelligenceBookPipelines
SET CompletionEmailQueuedUtc = SYSUTCDATETIME(),
UpdatedUtc = SYSUTCDATETIME()
WHERE StoryIntelligenceBookPipelineID = @ImportSessionID
AND CompletionEmailQueuedUtc IS NULL;
IF @@ROWCOUNT = 0
BEGIN
ROLLBACK TRANSACTION;
RETURN;
END;
END;
COMMIT TRANSACTION;
SELECT @ImportSessionID AS ImportSessionID,
@UserID AS UserID,
p.ProjectID,
project.ProjectName,
p.BookID,
book.BookTitle,
book.Subtitle AS BookSubtitle,
@ChapterCount AS ChapterCount,
@CompletedChapterCount AS CompletedChapterCount,
@FailedChapterCount AS FailedChapterCount,
@NotificationKind AS NotificationKind
FROM dbo.StoryIntelligenceBookPipelines p
INNER JOIN dbo.Projects project ON project.ProjectID = p.ProjectID
INNER JOIN dbo.Books book ON book.BookID = p.BookID
WHERE p.StoryIntelligenceBookPipelineID = @ImportSessionID;
END;
GO

Some files were not shown because too many files have changed in this diff Show More