Compare commits
No commits in common. "108df3db3ef4d8b8840c9c82431028c9f95cd1b3" and "1c7cf9bee16a81ed5a9a301001d0060fde5fc9ec" have entirely different histories.
108df3db3e
...
1c7cf9bee1
File diff suppressed because it is too large
Load Diff
@ -21,9 +21,7 @@ public sealed class AdminController(
|
|||||||
IStoryIntelligenceExistingChapterQueueService existingChapterQueue,
|
IStoryIntelligenceExistingChapterQueueService existingChapterQueue,
|
||||||
IStoryIntelligenceFullChapterTestService fullChapterTest,
|
IStoryIntelligenceFullChapterTestService fullChapterTest,
|
||||||
IStoryIntelligenceImportCommitService storyIntelligenceImportCommit,
|
IStoryIntelligenceImportCommitService storyIntelligenceImportCommit,
|
||||||
INameIntelligenceService nameIntelligence,
|
IIllustrationLibraryService illustrationLibrary) : Controller
|
||||||
IIllustrationLibraryService illustrationLibrary,
|
|
||||||
ILogger<AdminController> logger) : Controller
|
|
||||||
{
|
{
|
||||||
[HttpGet("")]
|
[HttpGet("")]
|
||||||
[HttpGet("index")]
|
[HttpGet("index")]
|
||||||
@ -41,34 +39,6 @@ public sealed class AdminController(
|
|||||||
return View();
|
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")]
|
[HttpGet("story-intelligence/illustration-library")]
|
||||||
public async Task<IActionResult> StoryIntelligenceIllustrationLibrary([FromQuery] IllustrationLibraryFilter filter)
|
public async Task<IActionResult> StoryIntelligenceIllustrationLibrary([FromQuery] IllustrationLibraryFilter filter)
|
||||||
{
|
{
|
||||||
@ -199,27 +169,8 @@ public sealed class AdminController(
|
|||||||
return Forbid();
|
return Forbid();
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.LogWarning("Story Intelligence development reset requested by admin user {UserId}.", userId);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await storyIntelligenceDevelopmentReset.ResetOwnedProjectsAndImportsAsync(userId, cancellationToken);
|
var result = await storyIntelligenceDevelopmentReset.ResetOwnedProjectsAndImportsAsync(userId, cancellationToken);
|
||||||
TempData[result.Success ? "AdminMessage" : "AdminError"] = result.Message;
|
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));
|
return RedirectToAction(nameof(StoryIntelligenceDiagnostics));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -69,8 +69,6 @@ public sealed class BooksController(IBookService books, IOnboardingService onboa
|
|||||||
createModel.BookNumber = model.BookNumber;
|
createModel.BookNumber = model.BookNumber;
|
||||||
createModel.Description = model.Description;
|
createModel.Description = model.Description;
|
||||||
createModel.TargetWordCount = model.TargetWordCount;
|
createModel.TargetWordCount = model.TargetWordCount;
|
||||||
createModel.StoryEra = model.StoryEra;
|
|
||||||
createModel.PrimaryLocaleCode = model.PrimaryLocaleCode;
|
|
||||||
createModel.WritingStartDate = model.WritingStartDate;
|
createModel.WritingStartDate = model.WritingStartDate;
|
||||||
createModel.TargetCompletionDate = model.TargetCompletionDate;
|
createModel.TargetCompletionDate = model.TargetCompletionDate;
|
||||||
createModel.PublicationDate = model.PublicationDate;
|
createModel.PublicationDate = model.PublicationDate;
|
||||||
|
|||||||
@ -51,7 +51,6 @@ public sealed class ChaptersController(IChapterService chapters) : Controller
|
|||||||
|
|
||||||
createModel.ChapterNumber = model.ChapterNumber;
|
createModel.ChapterNumber = model.ChapterNumber;
|
||||||
createModel.ChapterTitle = model.ChapterTitle;
|
createModel.ChapterTitle = model.ChapterTitle;
|
||||||
createModel.POVCharacterID = model.POVCharacterID;
|
|
||||||
createModel.Summary = model.Summary;
|
createModel.Summary = model.Summary;
|
||||||
createModel.RevisionStatusID = model.RevisionStatusID;
|
createModel.RevisionStatusID = model.RevisionStatusID;
|
||||||
return View("Edit", createModel);
|
return View("Edit", createModel);
|
||||||
@ -60,15 +59,6 @@ public sealed class ChaptersController(IChapterService chapters) : Controller
|
|||||||
return RedirectToAction(nameof(Details), new { id = chapterId });
|
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]
|
[HttpPost]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> Archive(int id, int bookId)
|
public async Task<IActionResult> Archive(int id, int bookId)
|
||||||
|
|||||||
@ -32,12 +32,6 @@ public sealed class CharactersController(ICharacterService characters) : Control
|
|||||||
return model is null ? NotFound() : View(model);
|
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)
|
public async Task<IActionResult> CropAvatar(int imageId)
|
||||||
{
|
{
|
||||||
var model = await characters.GetAvatarCropAsync(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 });
|
: 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)
|
private void ValidateSexWorkflow(CharacterEditViewModel model)
|
||||||
{
|
{
|
||||||
if (model.SexValueID == CharacterEditViewModel.CustomSexValue)
|
if (model.SexValueID == CharacterEditViewModel.CustomSexValue)
|
||||||
|
|||||||
193
PlotLine/Controllers/DevelopmentController.cs
Normal file
193
PlotLine/Controllers/DevelopmentController.cs
Normal 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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -60,14 +60,6 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
|||||||
return RedirectToAction(nameof(Index));
|
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["ScanReviewStep"] = "characters";
|
||||||
ViewData["CharacterReviewPage"] = Math.Max(1, page);
|
ViewData["CharacterReviewPage"] = Math.Max(1, page);
|
||||||
return View("ScanReview", model);
|
return View("ScanReview", model);
|
||||||
@ -84,23 +76,14 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
|||||||
public async Task<IActionResult> StoryIntelligence()
|
public async Task<IActionResult> StoryIntelligence()
|
||||||
{
|
{
|
||||||
var model = await storyIntelligence.GetOverviewAsync();
|
var model = await storyIntelligence.GetOverviewAsync();
|
||||||
if (model.ExistingBatchID.HasValue)
|
return View(model);
|
||||||
{
|
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("story-intelligence/context")]
|
[HttpGet("story-intelligence/context")]
|
||||||
public async Task<IActionResult> StoryIntelligenceContext()
|
public async Task<IActionResult> StoryIntelligenceContext()
|
||||||
{
|
{
|
||||||
return await StoryIntelligence();
|
var model = await storyIntelligence.GetOverviewAsync();
|
||||||
|
return View(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("story-intelligence/book/{bookId:int}/continue")]
|
[HttpGet("story-intelligence/book/{bookId:int}/continue")]
|
||||||
@ -116,7 +99,6 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
|||||||
return target.Route switch
|
return target.Route switch
|
||||||
{
|
{
|
||||||
StoryIntelligenceResumeRoutes.Complete => RedirectToAction(nameof(StoryIntelligenceComplete), new { batchId = target.BatchID }),
|
StoryIntelligenceResumeRoutes.Complete => RedirectToAction(nameof(StoryIntelligenceComplete), new { batchId = target.BatchID }),
|
||||||
StoryIntelligenceResumeRoutes.Narrative => RedirectToAction(nameof(StoryIntelligenceNarrative), new { batchId = target.BatchID }),
|
|
||||||
StoryIntelligenceResumeRoutes.Knowledge => RedirectToAction(nameof(StoryIntelligenceKnowledge), new { batchId = target.BatchID }),
|
StoryIntelligenceResumeRoutes.Knowledge => RedirectToAction(nameof(StoryIntelligenceKnowledge), new { batchId = target.BatchID }),
|
||||||
StoryIntelligenceResumeRoutes.Relationships => RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId = target.BatchID }),
|
StoryIntelligenceResumeRoutes.Relationships => RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId = target.BatchID }),
|
||||||
StoryIntelligenceResumeRoutes.Assets => RedirectToAction(nameof(StoryIntelligenceAssets), 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")]
|
[HttpPost("story-intelligence/start")]
|
||||||
[ValidateAntiForgeryToken]
|
[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)
|
if (job is null)
|
||||||
{
|
{
|
||||||
TempData["OnboardingStoryIntelligenceError"] = "Review the manuscript scan before starting Story Intelligence.";
|
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 });
|
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")]
|
[HttpPost("story-intelligence/skip")]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> SkipStoryIntelligence()
|
public async Task<IActionResult> SkipStoryIntelligence()
|
||||||
@ -521,73 +479,6 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
|||||||
return model is null ? NotFound() : View(model);
|
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")]
|
[HttpGet("story-intelligence/complete")]
|
||||||
public async Task<IActionResult> StoryIntelligenceComplete(Guid batchId)
|
public async Task<IActionResult> StoryIntelligenceComplete(Guid batchId)
|
||||||
{
|
{
|
||||||
@ -622,11 +513,6 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
|||||||
return RedirectToAction(nameof(StoryIntelligenceKnowledge), new { batchId });
|
return RedirectToAction(nameof(StoryIntelligenceKnowledge), new { batchId });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!progress.PipelineDashboard.NarrativeStageComplete)
|
|
||||||
{
|
|
||||||
return RedirectToAction(nameof(StoryIntelligenceNarrative), new { batchId });
|
|
||||||
}
|
|
||||||
|
|
||||||
var model = await storyIntelligence.GetCompletionAsync(batchId);
|
var model = await storyIntelligence.GetCompletionAsync(batchId);
|
||||||
return model is null ? NotFound() : View(model);
|
return model is null ? NotFound() : View(model);
|
||||||
}
|
}
|
||||||
@ -640,47 +526,23 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
|||||||
var continueToCharacters = string.Equals(intent, "characters", StringComparison.OrdinalIgnoreCase);
|
var continueToCharacters = string.Equals(intent, "characters", StringComparison.OrdinalIgnoreCase);
|
||||||
var continueCharacterReview = string.Equals(intent, "character-next", StringComparison.OrdinalIgnoreCase);
|
var continueCharacterReview = string.Equals(intent, "character-next", StringComparison.OrdinalIgnoreCase);
|
||||||
var backCharacterReview = string.Equals(intent, "character-back", StringComparison.OrdinalIgnoreCase);
|
var backCharacterReview = string.Equals(intent, "character-back", StringComparison.OrdinalIgnoreCase);
|
||||||
var readyToImport = string.Equals(intent, "structure", StringComparison.OrdinalIgnoreCase);
|
var readyToImport = string.Equals(intent, "continue", StringComparison.OrdinalIgnoreCase);
|
||||||
var completeCoreImport = string.Equals(intent, "continue", StringComparison.OrdinalIgnoreCase);
|
|
||||||
var nextCharacterPage = Math.Max(1, characterPage + (backCharacterReview ? -1 : 1));
|
var nextCharacterPage = Math.Max(1, characterPage + (backCharacterReview ? -1 : 1));
|
||||||
try
|
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);
|
var model = await onboarding.SaveScanReviewAsync(form, readyToImport);
|
||||||
if (model is null)
|
if (model is null)
|
||||||
{
|
{
|
||||||
return NotFound();
|
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
|
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."
|
: continueToCharacters ? "Chapter and scene review saved. Next: review detected characters."
|
||||||
: continueCharacterReview || backCharacterReview ? "Character review choices saved."
|
: continueCharacterReview || backCharacterReview ? "Character review choices saved."
|
||||||
: "Review choices saved.";
|
: "Review choices saved.";
|
||||||
return readyToImport
|
return readyToImport
|
||||||
? RedirectToAction(nameof(Index))
|
? RedirectToAction(nameof(StoryIntelligence))
|
||||||
: continueCharacterReview || backCharacterReview ? RedirectToAction(nameof(ScanCharacters), new { previewId = form.PreviewID, page = nextCharacterPage })
|
: continueCharacterReview || backCharacterReview ? RedirectToAction(nameof(ScanCharacters), new { previewId = form.PreviewID, page = nextCharacterPage })
|
||||||
: continueToCharacters ? RedirectToAction(nameof(ScanCharacters), new { previewId = form.PreviewID })
|
: continueToCharacters ? RedirectToAction(nameof(ScanCharacters), new { previewId = form.PreviewID })
|
||||||
: RedirectToAction(nameof(ScanReview), new { previewId = form.PreviewID });
|
: RedirectToAction(nameof(ScanReview), new { previewId = form.PreviewID });
|
||||||
@ -694,7 +556,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
|||||||
return NotFound();
|
return NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (continueToCharacters || continueCharacterReview || backCharacterReview || completeCoreImport)
|
if (readyToImport || continueCharacterReview || backCharacterReview)
|
||||||
{
|
{
|
||||||
ViewData["ScanReviewStep"] = "characters";
|
ViewData["ScanReviewStep"] = "characters";
|
||||||
ViewData["CharacterReviewPage"] = readyToImport
|
ViewData["CharacterReviewPage"] = readyToImport
|
||||||
|
|||||||
@ -50,10 +50,8 @@ public sealed class ProjectMetricsController(ISceneMetricTypeService metrics) :
|
|||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> AddMissingDefaults(int projectId)
|
public async Task<IActionResult> AddMissingDefaults(int projectId)
|
||||||
{
|
{
|
||||||
var backfilled = await metrics.AddMissingDefaultsAsync(projectId);
|
await metrics.AddMissingDefaultsAsync(projectId);
|
||||||
TempData["ProjectMetricMessage"] = backfilled > 0
|
TempData["ProjectMetricMessage"] = "Missing default scene metrics added.";
|
||||||
? $"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.";
|
|
||||||
return RedirectToAction(nameof(Index), new { projectId });
|
return RedirectToAction(nameof(Index), new { projectId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@ using PlotLine.ViewModels;
|
|||||||
namespace PlotLine.Controllers;
|
namespace PlotLine.Controllers;
|
||||||
|
|
||||||
[Authorize]
|
[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());
|
public async Task<IActionResult> Index() => View(await projects.ListAsync());
|
||||||
|
|
||||||
@ -92,19 +92,15 @@ public sealed class ProjectsController(IProjectService projects, IProjectActivit
|
|||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> HardDeleteArchivedProject(int projectId, string? confirmationName)
|
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);
|
var project = await projects.GetArchivedOwnerProjectAsync(projectId);
|
||||||
if (project is null)
|
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.";
|
TempData["ArchiveError"] = "Only an archived project owner can permanently delete a project.";
|
||||||
return RedirectToAction(nameof(Index));
|
return RedirectToAction(nameof(Index));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.Equals(confirmationName?.Trim(), project.ProjectName, StringComparison.Ordinal))
|
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.";
|
TempData["ArchiveError"] = "Type the project name exactly to permanently delete it.";
|
||||||
return RedirectToAction(nameof(Index));
|
return RedirectToAction(nameof(Index));
|
||||||
}
|
}
|
||||||
@ -112,12 +108,10 @@ public sealed class ProjectsController(IProjectService projects, IProjectActivit
|
|||||||
var result = await projects.HardDeleteAsync(projectId);
|
var result = await projects.HardDeleteAsync(projectId);
|
||||||
if (result.Succeeded)
|
if (result.Succeeded)
|
||||||
{
|
{
|
||||||
logger.LogWarning("Permanent project delete completed for archived project {ProjectID}.", projectId);
|
|
||||||
TempData["ArchiveMessage"] = "Project permanently deleted.";
|
TempData["ArchiveMessage"] = "Project permanently deleted.";
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
logger.LogWarning("Permanent project delete failed for archived project {ProjectID}: {Message}", projectId, result.Message);
|
|
||||||
TempData["ArchiveError"] = result.NotArchived
|
TempData["ArchiveError"] = result.NotArchived
|
||||||
? "Only archived projects can be permanently deleted."
|
? "Only archived projects can be permanently deleted."
|
||||||
: result.Message;
|
: result.Message;
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -13,9 +13,7 @@ namespace PlotLine.Controllers;
|
|||||||
[Route("api/word-companion")]
|
[Route("api/word-companion")]
|
||||||
public sealed class WordCompanionController(
|
public sealed class WordCompanionController(
|
||||||
IWordCompanionService wordCompanion,
|
IWordCompanionService wordCompanion,
|
||||||
IWordCompanionPresenceService presence,
|
IHubContext<WordCompanionFollowHub> followHub) : ControllerBase
|
||||||
IHubContext<WordCompanionFollowHub> followHub,
|
|
||||||
ILogger<WordCompanionController> logger) : ControllerBase
|
|
||||||
{
|
{
|
||||||
[HttpGet("projects")]
|
[HttpGet("projects")]
|
||||||
public Task<WordCompanionProjectsResponse> GetProjects() => wordCompanion.ListProjectsAsync();
|
public Task<WordCompanionProjectsResponse> GetProjects() => wordCompanion.ListProjectsAsync();
|
||||||
@ -126,35 +124,12 @@ public sealed class WordCompanionController(
|
|||||||
[HttpPost("runtime/current-scene")]
|
[HttpPost("runtime/current-scene")]
|
||||||
public async Task<IActionResult> UpdateRuntimeCurrentScene(WordCompanionRuntimeCurrentSceneRequest request)
|
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);
|
var response = await wordCompanion.ValidateRuntimeCurrentSceneAsync(request);
|
||||||
if (response is null)
|
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();
|
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
|
await BroadcastToCurrentUserAsync("wordCompanionSceneChanged", new
|
||||||
{
|
{
|
||||||
response.ProjectId,
|
response.ProjectId,
|
||||||
@ -292,12 +267,4 @@ public sealed class WordCompanionController(
|
|||||||
await followHub.Clients.User(userId).SendAsync(eventName, payload);
|
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.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,21 +7,13 @@ namespace PlotLine.Controllers;
|
|||||||
|
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[Route("word-companion")]
|
[Route("word-companion")]
|
||||||
public sealed class WordCompanionHostController(
|
public sealed class WordCompanionHostController(ICurrentUserService currentUser, IAuthorizationService authorization) : Controller
|
||||||
ICurrentUserService currentUser,
|
|
||||||
IAuthorizationService authorization,
|
|
||||||
ILogger<WordCompanionHostController> logger) : Controller
|
|
||||||
{
|
{
|
||||||
[HttpGet("")]
|
[HttpGet("")]
|
||||||
public async Task<IActionResult> Index()
|
public async Task<IActionResult> Index()
|
||||||
{
|
{
|
||||||
var isAdmin = currentUser.IsAuthenticated
|
var isAdmin = currentUser.IsAuthenticated
|
||||||
&& (await authorization.AuthorizeAsync(User, "AdminOnly")).Succeeded;
|
&& (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
|
return View(new WordCompanionHostViewModel
|
||||||
{
|
{
|
||||||
@ -33,3 +25,4 @@ public sealed class WordCompanionHostController(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -13,60 +13,37 @@ public sealed class WritingController(
|
|||||||
IStoryContextService storyContext,
|
IStoryContextService storyContext,
|
||||||
IRelationshipMapService relationshipMap,
|
IRelationshipMapService relationshipMap,
|
||||||
IFloorPlanService floorPlans,
|
IFloorPlanService floorPlans,
|
||||||
IWriterWorkspaceService writerWorkspace,
|
IWriterWorkspaceService writerWorkspace) : Controller
|
||||||
ILogger<WritingController> logger) : Controller
|
|
||||||
{
|
{
|
||||||
[HttpGet("Context")]
|
[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
|
var model = new WritingContextPageViewModel
|
||||||
{
|
{
|
||||||
ProjectID = projectId,
|
ProjectID = projectId,
|
||||||
BookID = bookId,
|
BookID = bookId,
|
||||||
ChapterID = chapterId,
|
ChapterID = chapterId,
|
||||||
SceneID = sceneId,
|
SceneID = sceneId,
|
||||||
DocumentGuid = documentGuid,
|
Display = sceneId.HasValue
|
||||||
Display = display ?? MissingContextDisplay(projectId, bookId, sceneId)
|
? await BuildDisplayAsync(projectId, bookId, chapterId, sceneId, "Manual context")
|
||||||
|
: new WritingContextDisplayViewModel { FollowStatus = "Connected, waiting for scene" }
|
||||||
};
|
};
|
||||||
|
|
||||||
return View(model);
|
return View(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("Intent")]
|
[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
|
var model = new WritingIntentPageViewModel
|
||||||
{
|
{
|
||||||
ProjectID = projectId,
|
ProjectID = projectId,
|
||||||
BookID = bookId,
|
BookID = bookId,
|
||||||
ChapterID = chapterId,
|
ChapterID = chapterId,
|
||||||
SceneID = sceneId,
|
SceneID = sceneId,
|
||||||
DocumentGuid = documentGuid,
|
Display = sceneId.HasValue || chapterId.HasValue
|
||||||
Display = display ?? MissingIntentDisplay(projectId, bookId, chapterId, sceneId)
|
? await BuildIntentDisplayAsync(projectId, bookId, chapterId, sceneId, "Manual intent")
|
||||||
|
: new WritingIntentDisplayViewModel { FollowStatus = "Connected, waiting for scene" }
|
||||||
};
|
};
|
||||||
|
|
||||||
return View(model);
|
return View(model);
|
||||||
@ -77,18 +54,16 @@ public sealed class WritingController(
|
|||||||
{
|
{
|
||||||
if (!sceneId.HasValue)
|
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", new WritingContextDisplayViewModel
|
||||||
return PartialView("~/Views/Writing/_WritingContextContent.cshtml", WaitingContextDisplay(projectId, bookId, null));
|
{
|
||||||
|
FollowStatus = "Connected, waiting for scene"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
var model = await BuildDisplayAsync(projectId, bookId, chapterId, sceneId, "Following Word");
|
var model = await BuildDisplayAsync(projectId, bookId, chapterId, sceneId, "Following Word");
|
||||||
if (model?.HasScene == true)
|
return model?.HasScene == true
|
||||||
{
|
? PartialView("~/Views/Writing/_WritingContextContent.cshtml", model)
|
||||||
return PartialView("~/Views/Writing/_WritingContextContent.cshtml", model);
|
: NotFound();
|
||||||
}
|
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("Intent/Scene")]
|
[HttpGet("Intent/Scene")]
|
||||||
@ -96,18 +71,16 @@ public sealed class WritingController(
|
|||||||
{
|
{
|
||||||
if (!sceneId.HasValue && !chapterId.HasValue)
|
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", new WritingIntentDisplayViewModel
|
||||||
return PartialView("~/Views/Writing/_WritingIntentContent.cshtml", WaitingIntentDisplay(projectId, bookId, null));
|
{
|
||||||
|
FollowStatus = "Connected, waiting for scene"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
var model = await BuildIntentDisplayAsync(projectId, bookId, chapterId, sceneId, "Following Word");
|
var model = await BuildIntentDisplayAsync(projectId, bookId, chapterId, sceneId, "Following Word");
|
||||||
if (model is not null && (model.HasScene || model.HasChapter))
|
return model is not null && (model.HasScene || model.HasChapter)
|
||||||
{
|
? PartialView("~/Views/Writing/_WritingIntentContent.cshtml", model)
|
||||||
return PartialView("~/Views/Writing/_WritingIntentContent.cshtml", model);
|
: NotFound();
|
||||||
}
|
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<WritingContextDisplayViewModel?> BuildDisplayAsync(int? projectId, int? bookId, int? chapterId, int? sceneId, string followStatus)
|
private async Task<WritingContextDisplayViewModel?> BuildDisplayAsync(int? projectId, int? bookId, int? chapterId, int? sceneId, string followStatus)
|
||||||
@ -215,42 +188,4 @@ public sealed class WritingController(
|
|||||||
.ToList()
|
.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" : "")}.";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -107,9 +107,6 @@ public sealed class IllustrationLibraryRepository(ISqlConnectionFactory connecti
|
|||||||
image.MimeType,
|
image.MimeType,
|
||||||
Provider = providerResult.Provider,
|
Provider = providerResult.Provider,
|
||||||
Model = providerResult.Model,
|
Model = providerResult.Model,
|
||||||
providerResult.Quality,
|
|
||||||
providerResult.Size,
|
|
||||||
providerResult.OutputFormat,
|
|
||||||
PromptText = prompt,
|
PromptText = prompt,
|
||||||
PromptTemplateVersion = templateVersion,
|
PromptTemplateVersion = templateVersion,
|
||||||
ProviderRequestID = providerResult.ProviderRequestID
|
ProviderRequestID = providerResult.ProviderRequestID
|
||||||
|
|||||||
@ -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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
@ -8,16 +8,6 @@ namespace PlotLine.Data;
|
|||||||
public interface IOnboardingBuildRepository
|
public interface IOnboardingBuildRepository
|
||||||
{
|
{
|
||||||
Task<OnboardingManuscriptBuildResult?> BuildAsync(int userId, OnboardingManuscriptBuildRequest request);
|
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
|
public sealed class OnboardingBuildRepository(ISqlConnectionFactory connectionFactory) : IOnboardingBuildRepository
|
||||||
@ -41,22 +31,6 @@ public sealed class OnboardingBuildRepository(ISqlConnectionFactory connectionFa
|
|||||||
},
|
},
|
||||||
commandType: CommandType.StoredProcedure);
|
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>();
|
var header = await result.ReadSingleOrDefaultAsync<OnboardingManuscriptBuildResult>();
|
||||||
if (header is null)
|
if (header is null)
|
||||||
{
|
{
|
||||||
@ -87,115 +61,6 @@ public sealed class OnboardingBuildRepository(ISqlConnectionFactory connectionFa
|
|||||||
CharacterMappings = characters
|
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
|
public sealed class OnboardingManuscriptBuildRequest
|
||||||
@ -222,7 +87,6 @@ public sealed class OnboardingBuildSceneRequest
|
|||||||
public string TemporarySceneKey { get; init; } = string.Empty;
|
public string TemporarySceneKey { get; init; } = string.Empty;
|
||||||
public string TemporaryChapterKey { get; init; } = string.Empty;
|
public string TemporaryChapterKey { get; init; } = string.Empty;
|
||||||
public string Title { get; init; } = string.Empty;
|
public string Title { get; init; } = string.Empty;
|
||||||
public string? SuggestedTitle { get; init; }
|
|
||||||
public int SortOrder { get; init; }
|
public int SortOrder { get; init; }
|
||||||
public int WordCount { get; init; }
|
public int WordCount { get; init; }
|
||||||
}
|
}
|
||||||
@ -233,54 +97,3 @@ public sealed class OnboardingBuildCharacterRequest
|
|||||||
public string Name { get; init; } = string.Empty;
|
public string Name { get; init; } = string.Empty;
|
||||||
public int? ExistingCharacterID { get; init; }
|
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; }
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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.
|
|
||||||
|
|
||||||
@ -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.
|
|
||||||
|
|
||||||
@ -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.
|
|
||||||
|
|
||||||
@ -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
@ -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"
|
|
||||||
}
|
|
||||||
@ -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,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
|
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"ok": true,
|
|
||||||
"counts": {
|
|
||||||
"locales": 6,
|
|
||||||
"givenNames": 3067,
|
|
||||||
"relationships": 525,
|
|
||||||
"usages": 92010
|
|
||||||
},
|
|
||||||
"coverage": {
|
|
||||||
"usageGroups": 18402,
|
|
||||||
"expectedUsageGroups": 18402
|
|
||||||
},
|
|
||||||
"errors": [],
|
|
||||||
"warnings": []
|
|
||||||
}
|
|
||||||
@ -75,8 +75,6 @@ public interface IChapterRepository
|
|||||||
Task<IReadOnlyList<Chapter>> ListByBookAsync(int bookId);
|
Task<IReadOnlyList<Chapter>> ListByBookAsync(int bookId);
|
||||||
Task<Chapter?> GetAsync(int chapterId);
|
Task<Chapter?> GetAsync(int chapterId);
|
||||||
Task<int> SaveAsync(Chapter chapter);
|
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);
|
Task ArchiveAsync(int chapterId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,7 +84,7 @@ public interface ISceneRepository
|
|||||||
Task<IReadOnlyList<Scene>> ListByFloorPlanAsync(int floorPlanId);
|
Task<IReadOnlyList<Scene>> ListByFloorPlanAsync(int floorPlanId);
|
||||||
Task<Scene?> GetAsync(int sceneId);
|
Task<Scene?> GetAsync(int sceneId);
|
||||||
Task<int> SaveAsync(Scene scene);
|
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 SaveFloorPlanLinkAsync(int sceneId, int? floorPlanId, int? initialFloorPlanFloorId);
|
||||||
Task SavePurposesAsync(int sceneId, IEnumerable<int> purposeIds);
|
Task SavePurposesAsync(int sceneId, IEnumerable<int> purposeIds);
|
||||||
Task SaveMetricValuesAsync(int sceneId, IEnumerable<SceneMetricValue> metricValues);
|
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> SetActiveAsync(int metricTypeId, int projectId, bool isActive);
|
||||||
Task<bool> MoveAsync(int metricTypeId, int projectId, string direction);
|
Task<bool> MoveAsync(int metricTypeId, int projectId, string direction);
|
||||||
Task AddMissingDefaultsAsync(int projectId);
|
Task AddMissingDefaultsAsync(int projectId);
|
||||||
Task<int> BackfillDefaultValuesForProjectAsync(int projectId);
|
|
||||||
Task<bool> RemoveUnusedAsync(int metricTypeId, int projectId);
|
Task<bool> RemoveUnusedAsync(int metricTypeId, int projectId);
|
||||||
Task<bool> HasProjectMetricsAsync(int projectId);
|
Task<bool> HasProjectMetricsAsync(int projectId);
|
||||||
}
|
}
|
||||||
@ -168,14 +165,6 @@ public interface IPlotRepository
|
|||||||
Task<IReadOnlyList<SceneOption>> ListSceneOptionsAsync(int projectId);
|
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
|
public interface IAssetRepository
|
||||||
{
|
{
|
||||||
Task<AssetLookupData> GetLookupsAsync(int? projectId = null);
|
Task<AssetLookupData> GetLookupsAsync(int? projectId = null);
|
||||||
@ -218,7 +207,6 @@ public interface ICharacterRepository
|
|||||||
Task<int> AddAliasAsync(int characterId, string alias, int? sortOrder);
|
Task<int> AddAliasAsync(int characterId, string alias, int? sortOrder);
|
||||||
Task UpdateAliasAsync(int characterAliasId, string alias, int? sortOrder);
|
Task UpdateAliasAsync(int characterAliasId, string alias, int? sortOrder);
|
||||||
Task DeleteAliasAsync(int characterAliasId);
|
Task DeleteAliasAsync(int characterAliasId);
|
||||||
Task<CharacterMergeResult?> MergeAsync(int sourceCharacterId, int targetCharacterId, int? userId);
|
|
||||||
Task<IReadOnlyList<CharacterSexValue>> ListSexValuesAsync(int ownerUserId);
|
Task<IReadOnlyList<CharacterSexValue>> ListSexValuesAsync(int ownerUserId);
|
||||||
Task<CharacterSexValue?> GetSexValueAsync(int characterSexValueId, int ownerUserId);
|
Task<CharacterSexValue?> GetSexValueAsync(int characterSexValueId, int ownerUserId);
|
||||||
Task<int> GetOrCreateSexValueAsync(int ownerUserId, string sexName);
|
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)
|
public async Task HardDeleteForOwnerAsync(int projectId, int userId)
|
||||||
{
|
{
|
||||||
using var connection = connectionFactory.CreateConnection();
|
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(
|
await connection.ExecuteAsync(
|
||||||
"dbo.ProjectHardDelete_ForOwner",
|
"dbo.ProjectHardDelete_ForOwner",
|
||||||
new { ProjectID = projectId, UserID = userId },
|
new { ProjectID = projectId, UserID = userId },
|
||||||
transaction,
|
|
||||||
commandType: CommandType.StoredProcedure);
|
commandType: CommandType.StoredProcedure);
|
||||||
|
|
||||||
transaction.Commit();
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
TryRollback(transaction);
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<string>> ListHardDeleteFilePathsForAccountDeletionAsync(int projectId, int userId)
|
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)
|
public async Task HardDeleteForAccountDeletionAsync(int projectId, int userId)
|
||||||
{
|
{
|
||||||
using var connection = connectionFactory.CreateConnection();
|
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(
|
await connection.ExecuteAsync(
|
||||||
"dbo.ProjectHardDelete_ForAccountDeletion",
|
"dbo.ProjectHardDelete_ForAccountDeletion",
|
||||||
new { ProjectID = projectId, UserID = userId },
|
new { ProjectID = projectId, UserID = userId },
|
||||||
transaction,
|
|
||||||
commandType: CommandType.StoredProcedure);
|
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.BookNumber,
|
||||||
book.Description,
|
book.Description,
|
||||||
book.TargetWordCount,
|
book.TargetWordCount,
|
||||||
book.StoryEra,
|
|
||||||
book.PrimaryLocaleCode,
|
|
||||||
book.WritingStartDate,
|
book.WritingStartDate,
|
||||||
book.TargetCompletionDate,
|
book.TargetCompletionDate,
|
||||||
book.PublicationDate,
|
book.PublicationDate,
|
||||||
@ -2465,33 +2397,7 @@ public sealed class ChapterRepository(ISqlConnectionFactory connectionFactory) :
|
|||||||
using var connection = connectionFactory.CreateConnection();
|
using var connection = connectionFactory.CreateConnection();
|
||||||
return await connection.QuerySingleAsync<int>(
|
return await connection.QuerySingleAsync<int>(
|
||||||
"dbo.Chapter_Save",
|
"dbo.Chapter_Save",
|
||||||
new { chapter.ChapterID, chapter.BookID, chapter.ChapterNumber, chapter.ChapterTitle, chapter.Summary, chapter.ChapterPurposeID, chapter.RevisionStatusID, chapter.POVCharacterID },
|
new { chapter.ChapterID, chapter.BookID, chapter.ChapterNumber, chapter.ChapterTitle, chapter.Summary, chapter.ChapterPurposeID, chapter.RevisionStatusID },
|
||||||
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 },
|
|
||||||
commandType: CommandType.StoredProcedure);
|
commandType: CommandType.StoredProcedure);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2564,27 +2470,17 @@ public sealed class SceneRepository(ISqlConnectionFactory connectionFactory) : I
|
|||||||
scene.RevisionStatusID,
|
scene.RevisionStatusID,
|
||||||
scene.PrimaryLocationID,
|
scene.PrimaryLocationID,
|
||||||
scene.FloorPlanID,
|
scene.FloorPlanID,
|
||||||
scene.InitialFloorPlanFloorID,
|
scene.InitialFloorPlanFloorID
|
||||||
scene.POVCharacterID
|
|
||||||
},
|
},
|
||||||
commandType: CommandType.StoredProcedure);
|
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();
|
using var connection = connectionFactory.CreateConnection();
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
"dbo.Scene_UpdatePovCharacter",
|
"dbo.Scene_UpdatePovCharacter",
|
||||||
new
|
new { SceneID = sceneId, POVCharacterID = povCharacterId },
|
||||||
{
|
|
||||||
SceneID = sceneId,
|
|
||||||
POVCharacterID = povCharacterId,
|
|
||||||
POVSource = source,
|
|
||||||
POVConfidence = confidence,
|
|
||||||
POVEvidence = evidence,
|
|
||||||
POVSuggestedName = suggestedName,
|
|
||||||
OverwriteAuthor = overwriteAuthor
|
|
||||||
},
|
|
||||||
commandType: CommandType.StoredProcedure);
|
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);
|
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)
|
public async Task<bool> RemoveUnusedAsync(int metricTypeId, int projectId)
|
||||||
{
|
{
|
||||||
using var connection = connectionFactory.CreateConnection();
|
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 sealed class AssetRepository(ISqlConnectionFactory connectionFactory) : IAssetRepository
|
||||||
{
|
{
|
||||||
public async Task<AssetLookupData> GetLookupsAsync(int? projectId = null)
|
public async Task<AssetLookupData> GetLookupsAsync(int? projectId = null)
|
||||||
@ -3735,22 +3542,6 @@ public sealed class CharacterRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
commandType: CommandType.StoredProcedure);
|
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)
|
public async Task<IReadOnlyList<CharacterSexValue>> ListSexValuesAsync(int ownerUserId)
|
||||||
{
|
{
|
||||||
using var connection = connectionFactory.CreateConnection();
|
using var connection = connectionFactory.CreateConnection();
|
||||||
@ -3880,12 +3671,7 @@ public sealed class CharacterRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
sceneCharacter.OutfitDescription,
|
sceneCharacter.OutfitDescription,
|
||||||
sceneCharacter.PhysicalCondition,
|
sceneCharacter.PhysicalCondition,
|
||||||
sceneCharacter.EmotionalState,
|
sceneCharacter.EmotionalState,
|
||||||
sceneCharacter.KnowledgeNotes,
|
sceneCharacter.KnowledgeNotes
|
||||||
sceneCharacter.MatchSource,
|
|
||||||
sceneCharacter.MatchedText,
|
|
||||||
sceneCharacter.OccurrenceCount,
|
|
||||||
sceneCharacter.FirstOccurrenceIndex,
|
|
||||||
PreserveExistingWhenNull = string.Equals(sceneCharacter.MatchSource, "CoreImportDeterministic", StringComparison.Ordinal)
|
|
||||||
},
|
},
|
||||||
commandType: CommandType.StoredProcedure);
|
commandType: CommandType.StoredProcedure);
|
||||||
}
|
}
|
||||||
|
|||||||
117
PlotLine/Data/StoryIntelligenceIllustrationMatchingRepository.cs
Normal file
117
PlotLine/Data/StoryIntelligenceIllustrationMatchingRepository.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,7 +12,6 @@ public interface IStoryIntelligencePipelineRepository
|
|||||||
Task<IReadOnlyList<StoryIntelligenceBookPipelineState>> ListForUserAsync(int userId);
|
Task<IReadOnlyList<StoryIntelligenceBookPipelineState>> ListForUserAsync(int userId);
|
||||||
Task<StoryIntelligenceBookPipelineState> UpsertAsync(StoryIntelligenceBookPipelineSaveRequest request);
|
Task<StoryIntelligenceBookPipelineState> UpsertAsync(StoryIntelligenceBookPipelineSaveRequest request);
|
||||||
Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId);
|
Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId);
|
||||||
Task<StoryIntelligenceImportNotificationTarget?> TryBeginCompletionNotificationAsync(int bookId, int userId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class StoryIntelligencePipelineRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligencePipelineRepository
|
public sealed class StoryIntelligencePipelineRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligencePipelineRepository
|
||||||
@ -82,13 +81,4 @@ public sealed class StoryIntelligencePipelineRepository(ISqlConnectionFactory co
|
|||||||
commandType: CommandType.StoredProcedure);
|
commandType: CommandType.StoredProcedure);
|
||||||
return rows.ToList();
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,7 +34,6 @@ public interface IStoryIntelligenceResultRepository
|
|||||||
Task<StoryIntelligenceSavedRun?> GetRunAsync(int runId);
|
Task<StoryIntelligenceSavedRun?> GetRunAsync(int runId);
|
||||||
Task<StoryIntelligenceSavedChapterResult?> GetChapterResultAsync(int runId);
|
Task<StoryIntelligenceSavedChapterResult?> GetChapterResultAsync(int runId);
|
||||||
Task<IReadOnlyList<StoryIntelligenceSavedSceneResult>> ListSceneResultsAsync(int runId);
|
Task<IReadOnlyList<StoryIntelligenceSavedSceneResult>> ListSceneResultsAsync(int runId);
|
||||||
Task<IReadOnlyList<Scene>> ListChapterScenesAsync(int chapterId);
|
|
||||||
Task<StoryIntelligenceImportCommit?> GetImportCommitAsync(int runId);
|
Task<StoryIntelligenceImportCommit?> GetImportCommitAsync(int runId);
|
||||||
Task<StoryIntelligenceChapterImportReadiness> GetChapterImportReadinessAsync(int chapterId);
|
Task<StoryIntelligenceChapterImportReadiness> GetChapterImportReadinessAsync(int chapterId);
|
||||||
Task<StoryIntelligenceImportCommitResult> CommitImportAsync(StoryIntelligenceImportCommitRequest request);
|
Task<StoryIntelligenceImportCommitResult> CommitImportAsync(StoryIntelligenceImportCommitRequest request);
|
||||||
@ -518,20 +517,6 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
|
|||||||
return rows.ToList();
|
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)
|
public async Task<StoryIntelligenceImportCommit?> GetImportCommitAsync(int runId)
|
||||||
{
|
{
|
||||||
using var connection = connectionFactory.CreateConnection();
|
using var connection = connectionFactory.CreateConnection();
|
||||||
@ -581,31 +566,15 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
|
|||||||
new { request.ChapterID },
|
new { request.ChapterID },
|
||||||
transaction,
|
transaction,
|
||||||
commandType: CommandType.StoredProcedure);
|
commandType: CommandType.StoredProcedure);
|
||||||
var existingScenes = new List<Scene>();
|
|
||||||
var orderedScenes = request.Scenes.OrderBy(scene => scene.TemporarySceneNumber).ToList();
|
|
||||||
if (!readiness.CanCommit)
|
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();
|
transaction.Rollback();
|
||||||
return new StoryIntelligenceImportCommitResult
|
return new StoryIntelligenceImportCommitResult
|
||||||
{
|
{
|
||||||
Success = false,
|
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)
|
if (!string.IsNullOrWhiteSpace(request.ChapterSummary) || request.ChapterPurposeID.HasValue)
|
||||||
{
|
{
|
||||||
@ -621,6 +590,7 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
|
|||||||
commandType: CommandType.StoredProcedure);
|
commandType: CommandType.StoredProcedure);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var orderedScenes = request.Scenes.OrderBy(scene => scene.TemporarySceneNumber).ToList();
|
||||||
for (var index = 0; index < orderedScenes.Count; index++)
|
for (var index = 0; index < orderedScenes.Count; index++)
|
||||||
{
|
{
|
||||||
var item = orderedScenes[index];
|
var item = orderedScenes[index];
|
||||||
@ -642,7 +612,7 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
|
|||||||
"dbo.Scene_Save",
|
"dbo.Scene_Save",
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
SceneID = item.SceneID ?? (existingScenes.Count > index ? existingScenes[index].SceneID : (int?)null),
|
SceneID = (int?)null,
|
||||||
ChapterID = request.ChapterID,
|
ChapterID = request.ChapterID,
|
||||||
SceneNumber = Convert.ToDecimal(item.TemporarySceneNumber),
|
SceneNumber = Convert.ToDecimal(item.TemporarySceneNumber),
|
||||||
SceneTitle = sceneTitle,
|
SceneTitle = sceneTitle,
|
||||||
@ -767,7 +737,7 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
|
|||||||
}
|
}
|
||||||
|
|
||||||
var notes = new List<string>();
|
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(request.Warnings.Where(warning => !string.IsNullOrWhiteSpace(warning)));
|
||||||
notes.AddRange(commitWarnings.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,
|
CommitID = commitId,
|
||||||
ScenesCreated = scenesCreated,
|
ScenesCreated = scenesCreated,
|
||||||
MetricsCreated = metricsCreated,
|
MetricsCreated = metricsCreated,
|
||||||
Message = existingScenes.Count > 0
|
Message = $"Created {scenesCreated:N0} scene(s)."
|
||||||
? $"Enriched {scenesCreated:N0} existing scene(s)."
|
|
||||||
: $"Created {scenesCreated:N0} scene(s)."
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@ -23,7 +23,6 @@ public interface IStoryMemoryRepository
|
|||||||
Task UpsertAssignmentAsync(StoryMemoryIllustrationAssignmentSave request);
|
Task UpsertAssignmentAsync(StoryMemoryIllustrationAssignmentSave request);
|
||||||
Task InvalidateAssignmentAsync(int assignmentId, string reason);
|
Task InvalidateAssignmentAsync(int assignmentId, string reason);
|
||||||
Task<StoryMemoryIllustrationDemand> UpsertDemandAsync(StoryMemoryIllustrationDemandSave request);
|
Task<StoryMemoryIllustrationDemand> UpsertDemandAsync(StoryMemoryIllustrationDemandSave request);
|
||||||
Task<IReadOnlyList<StoryMemoryIllustrationDemand>> ListDemandsAsync(int importSessionId);
|
|
||||||
Task<string> GetAppearancePreferenceAsync(int importSessionId);
|
Task<string> GetAppearancePreferenceAsync(int importSessionId);
|
||||||
Task<StoryMemoryImportContext> GetImportContextAsync(int importSessionId);
|
Task<StoryMemoryImportContext> GetImportContextAsync(int importSessionId);
|
||||||
Task SetAppearancePreferenceAsync(int importSessionId, string preference);
|
Task SetAppearancePreferenceAsync(int importSessionId, string preference);
|
||||||
@ -364,18 +363,6 @@ public sealed class StoryMemoryRepository(ISqlConnectionFactory connectionFactor
|
|||||||
request);
|
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)
|
public async Task<string> GetAppearancePreferenceAsync(int importSessionId)
|
||||||
=> (await GetImportContextAsync(importSessionId)).AppearancePreference;
|
=> (await GetImportContextAsync(importSessionId)).AppearancePreference;
|
||||||
|
|
||||||
|
|||||||
@ -10,9 +10,9 @@ Replace the insertion blocks marked with `{{...}}` before sending.
|
|||||||
|
|
||||||
You are PlotDirector's Chapter Structure engine.
|
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
|
## 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.
|
3. Prefer slightly larger scenes over excessive fragmentation.
|
||||||
4. Do not split merely because there is a paragraph break.
|
4. Do not split merely because there is a paragraph break.
|
||||||
5. Do not try to create perfect scenes.
|
5. Do not try to create perfect scenes.
|
||||||
6. Return strict JSON only.
|
6. Return JSON only.
|
||||||
7. Do not wrap JSON in markdown fences.
|
|
||||||
8. Do not include explanatory prose before or after the JSON object.
|
|
||||||
|
|
||||||
## Boundary Guidance
|
## Boundary Guidance
|
||||||
|
|
||||||
@ -88,25 +86,9 @@ Each `startParagraph` and `endParagraph` must refer to those explicit paragraph
|
|||||||
Never invent paragraph numbers.
|
Never invent paragraph numbers.
|
||||||
|
|
||||||
Never return an `endParagraph` greater than the highest paragraph number supplied.
|
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.
|
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.
|
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:
|
Scene boundaries must:
|
||||||
@ -128,8 +110,6 @@ Return only these root properties:
|
|||||||
|
|
||||||
- `schemaVersion`
|
- `schemaVersion`
|
||||||
- `chapterSummary`
|
- `chapterSummary`
|
||||||
- `defaultPov`
|
|
||||||
- `mixedPov`
|
|
||||||
- `sceneBoundaries`
|
- `sceneBoundaries`
|
||||||
|
|
||||||
Each `sceneBoundaries` item must contain exactly:
|
Each `sceneBoundaries` item must contain exactly:
|
||||||
@ -137,27 +117,10 @@ Each `sceneBoundaries` item must contain exactly:
|
|||||||
- `sceneNumber`
|
- `sceneNumber`
|
||||||
- `startParagraph`
|
- `startParagraph`
|
||||||
- `endParagraph`
|
- `endParagraph`
|
||||||
- `suggestedTitle`
|
|
||||||
- `structuralSummary`
|
|
||||||
- `structuralSetting`
|
|
||||||
- `povOverride`
|
|
||||||
- `confidence`
|
- `confidence`
|
||||||
- `reason`
|
- `reason`
|
||||||
|
|
||||||
Use confidence values from `0.0` to `1.0`.
|
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:
|
Use short factual reasons, such as:
|
||||||
|
|
||||||
@ -170,7 +133,7 @@ Do not output:
|
|||||||
- assets;
|
- assets;
|
||||||
- relationships;
|
- relationships;
|
||||||
- metrics;
|
- metrics;
|
||||||
- interpretive scene analysis;
|
- scene summaries;
|
||||||
- observations;
|
- observations;
|
||||||
- continuity warnings;
|
- continuity warnings;
|
||||||
- plot threads.
|
- plot threads.
|
||||||
@ -181,22 +144,11 @@ Do not output:
|
|||||||
{
|
{
|
||||||
"schemaVersion": "1.0",
|
"schemaVersion": "1.0",
|
||||||
"chapterSummary": "Structural analysis of the supplied chapter.",
|
"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": [
|
"sceneBoundaries": [
|
||||||
{
|
{
|
||||||
"sceneNumber": 1,
|
"sceneNumber": 1,
|
||||||
"startParagraph": 1,
|
"startParagraph": 1,
|
||||||
"endParagraph": 3,
|
"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,
|
"confidence": 0.86,
|
||||||
"reason": "Opening scene remains in the same place and time."
|
"reason": "Opening scene remains in the same place and time."
|
||||||
}
|
}
|
||||||
|
|||||||
@ -44,7 +44,7 @@ It should not:
|
|||||||
1. Treat the manuscript as the authority.
|
1. Treat the manuscript as the authority.
|
||||||
2. Prefer omission over invention.
|
2. Prefer omission over invention.
|
||||||
3. Use uncertainty explicitly.
|
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.
|
5. Separate present characters from mentioned-only characters.
|
||||||
6. Separate named locations from generic rooms.
|
6. Separate named locations from generic rooms.
|
||||||
7. Treat assets as meaningful story objects, not ordinary props.
|
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.
|
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:
|
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`;
|
- match scene character names against `canonicalName` and `aliases`;
|
||||||
@ -154,25 +142,62 @@ Use lower confidence when:
|
|||||||
- the scene relies on context not supplied;
|
- the scene relies on context not supplied;
|
||||||
- interpretation depends on subtext rather than explicit action.
|
- 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
|
## 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`;
|
Preferred V1 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.
|
|
||||||
|
|
||||||
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:
|
Each metric must be an object:
|
||||||
|
|
||||||
@ -180,7 +205,7 @@ Each metric must be an object:
|
|||||||
{ "score": 8, "confidence": 0.75 }
|
{ "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
|
## 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
|
## 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`;
|
PlotDirector may resolve narrator identity later from chapter narrator metadata. Scene Intelligence should not force narrator identity where the scene does not provide it.
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Asset Reporting Rules
|
## 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`.
|
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
|
## Questions Answered Rules
|
||||||
|
|
||||||
Report answers supplied by the scene.
|
Report answers supplied by the scene.
|
||||||
@ -568,6 +539,11 @@ Return exactly one JSON object matching this shape:
|
|||||||
"sceneNumber": null,
|
"sceneNumber": null,
|
||||||
"sourceLabel": ""
|
"sourceLabel": ""
|
||||||
},
|
},
|
||||||
|
"summary": {
|
||||||
|
"short": "",
|
||||||
|
"detailed": "",
|
||||||
|
"confidence": 0.0
|
||||||
|
},
|
||||||
"scenePurpose": {
|
"scenePurpose": {
|
||||||
"observedFunction": "",
|
"observedFunction": "",
|
||||||
"confidence": 0.0
|
"confidence": 0.0
|
||||||
@ -674,17 +650,6 @@ Return exactly one JSON object matching this shape:
|
|||||||
"confidence": 0.0
|
"confidence": 0.0
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"narrativeArcs": [
|
|
||||||
{
|
|
||||||
"title": "",
|
|
||||||
"description": "",
|
|
||||||
"arcType": "other",
|
|
||||||
"scale": "major",
|
|
||||||
"matchingExistingPlotLineId": null,
|
|
||||||
"evidence": "",
|
|
||||||
"confidence": 0.0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"questionsRaised": [
|
"questionsRaised": [
|
||||||
{
|
{
|
||||||
"question": "",
|
"question": "",
|
||||||
@ -715,7 +680,15 @@ Return exactly one JSON object matching this shape:
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"metrics": {
|
"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": {
|
"sourceLimits": {
|
||||||
"containsAmbiguity": false,
|
"containsAmbiguity": false,
|
||||||
|
|||||||
@ -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.
|
|
||||||
@ -43,12 +43,8 @@ public sealed class StoryIntelligenceHub(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var canonicalScenes = run.ChapterID.HasValue
|
var sceneResults = await persistedRuns.ListSceneResultsAsync(runId);
|
||||||
? await persistedRuns.ListChapterScenesAsync(run.ChapterID.Value)
|
return ToRunProgress(run, "Current status", LatestSceneSummary(sceneResults));
|
||||||
: [];
|
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<StoryIntelligenceProgressViewModel?> WatchOnboardingStoryIntelligenceBatch(Guid batchId)
|
public async Task<StoryIntelligenceProgressViewModel?> WatchOnboardingStoryIntelligenceBatch(Guid batchId)
|
||||||
@ -60,14 +56,11 @@ public sealed class StoryIntelligenceHub(
|
|||||||
|
|
||||||
public static string UserGroup(int userId) => $"story-intelligence:{userId}";
|
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 completedScenes = run.CompletedScenes ?? 0;
|
||||||
var totalScenes = run.TotalDetectedScenes ?? 0;
|
var totalScenes = run.TotalDetectedScenes ?? 0;
|
||||||
var elapsedMs = run.TotalDurationMs ?? Math.Max(0, Convert.ToInt64((DateTime.UtcNow - run.StartedUtc).TotalMilliseconds));
|
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
|
return new StoryIntelligenceRunProgressEvent
|
||||||
{
|
{
|
||||||
@ -86,11 +79,7 @@ public sealed class StoryIntelligenceHub(
|
|||||||
FailedScenes = run.FailedScenes,
|
FailedScenes = run.FailedScenes,
|
||||||
TotalTokens = run.TotalTokens,
|
TotalTokens = run.TotalTokens,
|
||||||
TotalDurationMs = elapsedMs,
|
TotalDurationMs = elapsedMs,
|
||||||
CurrentSceneID = currentScene?.SceneID,
|
CurrentSceneNumber = run.CompletedScenes,
|
||||||
CurrentSceneNumber = currentSceneNumber,
|
|
||||||
CurrentSceneDisplayTitle = currentScene is null || !currentSceneNumber.HasValue
|
|
||||||
? null
|
|
||||||
: SceneTitleDisplay.Format(currentScene.SceneNumber, currentScene.SceneTitle),
|
|
||||||
ElapsedTime = FormatDuration(elapsedMs),
|
ElapsedTime = FormatDuration(elapsedMs),
|
||||||
EstimatedRemaining = EstimateRemaining(elapsedMs, completedScenes, totalScenes),
|
EstimatedRemaining = EstimateRemaining(elapsedMs, completedScenes, totalScenes),
|
||||||
LatestSceneSummary = latestSceneSummary,
|
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)
|
private static string FormatDuration(long? milliseconds)
|
||||||
{
|
{
|
||||||
var elapsed = TimeSpan.FromMilliseconds(Math.Max(0, milliseconds ?? 0));
|
var elapsed = TimeSpan.FromMilliseconds(Math.Max(0, milliseconds ?? 0));
|
||||||
|
|||||||
@ -4,7 +4,6 @@ using PlotLine.Data;
|
|||||||
using PlotLine.Models;
|
using PlotLine.Models;
|
||||||
using PlotLine.Services;
|
using PlotLine.Services;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace PlotLine.Hubs;
|
namespace PlotLine.Hubs;
|
||||||
|
|
||||||
@ -14,9 +13,7 @@ public sealed class WordCompanionFollowHub(
|
|||||||
IWritingWorkspacePresenceService writingPresence,
|
IWritingWorkspacePresenceService writingPresence,
|
||||||
IOnboardingRepository onboarding,
|
IOnboardingRepository onboarding,
|
||||||
IManuscriptScanPreviewStore scanStore,
|
IManuscriptScanPreviewStore scanStore,
|
||||||
IOnboardingService onboardingService,
|
IOnboardingService onboardingService) : Hub
|
||||||
IOnboardingBuildRepository onboardingBuilds,
|
|
||||||
ILogger<WordCompanionFollowHub> logger) : Hub
|
|
||||||
{
|
{
|
||||||
public override async Task OnConnectedAsync()
|
public override async Task OnConnectedAsync()
|
||||||
{
|
{
|
||||||
@ -44,12 +41,6 @@ public sealed class WordCompanionFollowHub(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var status = await presence.RegisterAsync(userId, Context.ConnectionId, registration);
|
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 Clients.Group(PresenceGroup(userId)).SendAsync("WordCompanionPresenceChanged", status);
|
||||||
await BroadcastWritingWorkspaceStatusAsync(userId, registration.LinkedProjectID, registration.LinkedBookID);
|
await BroadcastWritingWorkspaceStatusAsync(userId, registration.LinkedProjectID, registration.LinkedBookID);
|
||||||
return status;
|
return status;
|
||||||
@ -59,12 +50,6 @@ public sealed class WordCompanionFollowHub(
|
|||||||
{
|
{
|
||||||
var userId = RequireUserId();
|
var userId = RequireUserId();
|
||||||
var status = await presence.HeartbeatAsync(userId, Context.ConnectionId, heartbeat);
|
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 Clients.Group(PresenceGroup(userId)).SendAsync("WordCompanionPresenceChanged", status);
|
||||||
await BroadcastWritingWorkspaceStatusAsync(userId, heartbeat.LinkedProjectID, heartbeat.LinkedBookID);
|
await BroadcastWritingWorkspaceStatusAsync(userId, heartbeat.LinkedProjectID, heartbeat.LinkedBookID);
|
||||||
return status;
|
return status;
|
||||||
@ -74,11 +59,6 @@ public sealed class WordCompanionFollowHub(
|
|||||||
{
|
{
|
||||||
var userId = RequireUserId();
|
var userId = RequireUserId();
|
||||||
await Groups.AddToGroupAsync(Context.ConnectionId, PresenceGroup(userId));
|
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);
|
return await WritingWorkspaceStatusAsync(userId, scope);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -87,13 +67,6 @@ public sealed class WordCompanionFollowHub(
|
|||||||
var userId = RequireUserId();
|
var userId = RequireUserId();
|
||||||
await Groups.AddToGroupAsync(Context.ConnectionId, PresenceGroup(userId));
|
await Groups.AddToGroupAsync(Context.ConnectionId, PresenceGroup(userId));
|
||||||
var status = await writingPresence.RegisterAsync(userId, Context.ConnectionId, registration, await presence.GetStatusAsync(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);
|
await Clients.Group(PresenceGroup(userId)).SendAsync("WritingWorkspacePresenceChanged", status);
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
@ -203,7 +176,7 @@ public sealed class WordCompanionFollowHub(
|
|||||||
return scanState;
|
return scanState;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<OnboardingManuscriptBuildResult> StartOnboardingProjectBuild(Guid previewId, bool generateSceneTitles = true)
|
public async Task<OnboardingManuscriptBuildResult> StartOnboardingProjectBuild(Guid previewId)
|
||||||
{
|
{
|
||||||
var userId = RequireUserId();
|
var userId = RequireUserId();
|
||||||
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildProgress", new OnboardingBuildProgress
|
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildProgress", new OnboardingBuildProgress
|
||||||
@ -214,26 +187,9 @@ public sealed class WordCompanionFollowHub(
|
|||||||
PreviewID = previewId
|
PreviewID = previewId
|
||||||
});
|
});
|
||||||
|
|
||||||
OnboardingManuscriptBuildResult? result;
|
var result = await onboardingService.BuildApprovedStructureAsync(
|
||||||
try
|
|
||||||
{
|
|
||||||
result = await onboardingService.BuildApprovedStructureAsync(
|
|
||||||
previewId,
|
previewId,
|
||||||
generateSceneTitles,
|
|
||||||
progress => Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildProgress", progress));
|
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)
|
if (result is null)
|
||||||
{
|
{
|
||||||
throw new HubException("The reviewed scan could not be found.");
|
throw new HubException("The reviewed scan could not be found.");
|
||||||
@ -242,116 +198,13 @@ public sealed class WordCompanionFollowHub(
|
|||||||
var companionConnectionId = await presence.GetCompanionConnectionIdAsync(userId);
|
var companionConnectionId = await presence.GetCompanionConnectionIdAsync(userId);
|
||||||
if (!string.IsNullOrWhiteSpace(companionConnectionId))
|
if (!string.IsNullOrWhiteSpace(companionConnectionId))
|
||||||
{
|
{
|
||||||
await onboardingBuilds.MarkMarkerSyncSentAsync(result.BuildID);
|
|
||||||
await Clients.Client(companionConnectionId).SendAsync("UpdateOnboardingBuildMarkers", result);
|
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;
|
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)
|
public override async Task OnDisconnectedAsync(Exception? exception)
|
||||||
{
|
{
|
||||||
int? disconnectedUserId = null;
|
int? disconnectedUserId = null;
|
||||||
@ -406,14 +259,9 @@ public sealed class WordCompanionFollowHub(
|
|||||||
=> string.Equals(state.WritingSoftware, WritingSoftwareValues.MicrosoftWord, StringComparison.Ordinal)
|
=> string.Equals(state.WritingSoftware, WritingSoftwareValues.MicrosoftWord, StringComparison.Ordinal)
|
||||||
|| state.UsesWordCompanion == true;
|
|| 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)
|
private static ManuscriptScanPreview NormalisePreviewContext(ManuscriptScanPreview preview, int userId, UserOnboardingState state)
|
||||||
{
|
{
|
||||||
|
var characterCandidates = WordCompanionService.DiscoverManuscriptScanCharacterCandidates(preview);
|
||||||
return new()
|
return new()
|
||||||
{
|
{
|
||||||
PreviewID = preview.PreviewID,
|
PreviewID = preview.PreviewID,
|
||||||
@ -426,30 +274,12 @@ public sealed class WordCompanionFollowHub(
|
|||||||
CompanionDocumentIdentifier = preview.CompanionDocumentIdentifier,
|
CompanionDocumentIdentifier = preview.CompanionDocumentIdentifier,
|
||||||
TotalWordCount = preview.TotalWordCount,
|
TotalWordCount = preview.TotalWordCount,
|
||||||
ChapterCount = preview.ChapterCount,
|
ChapterCount = preview.ChapterCount,
|
||||||
SceneCount = 0,
|
SceneCount = preview.SceneCount,
|
||||||
CharacterCandidateCount = 0,
|
CharacterCandidateCount = characterCandidates.Count,
|
||||||
CreatedUtc = preview.CreatedUtc,
|
CreatedUtc = preview.CreatedUtc,
|
||||||
Chapters = preview.Chapters,
|
Chapters = preview.Chapters,
|
||||||
Scenes = [],
|
Scenes = preview.Scenes,
|
||||||
CharacterCandidates = []
|
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; }
|
|
||||||
}
|
|
||||||
|
|||||||
@ -7,8 +7,6 @@ public sealed class ChapterStructureModel
|
|||||||
{
|
{
|
||||||
public string? SchemaVersion { get; init; }
|
public string? SchemaVersion { get; init; }
|
||||||
public string? ChapterSummary { get; init; }
|
public string? ChapterSummary { get; init; }
|
||||||
public ChapterStructurePovSuggestion? DefaultPov { get; init; }
|
|
||||||
public bool? MixedPov { get; init; }
|
|
||||||
public List<ChapterSceneBoundary>? SceneBoundaries { get; init; }
|
public List<ChapterSceneBoundary>? SceneBoundaries { get; init; }
|
||||||
|
|
||||||
[JsonExtensionData]
|
[JsonExtensionData]
|
||||||
@ -20,21 +18,9 @@ public sealed class ChapterSceneBoundary
|
|||||||
public int? SceneNumber { get; init; }
|
public int? SceneNumber { get; init; }
|
||||||
public int? StartParagraph { get; init; }
|
public int? StartParagraph { get; init; }
|
||||||
public int? EndParagraph { 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 decimal? Confidence { get; init; }
|
||||||
public string? Reason { get; init; }
|
public string? Reason { get; init; }
|
||||||
|
|
||||||
[JsonExtensionData]
|
[JsonExtensionData]
|
||||||
public Dictionary<string, JsonElement>? ExtensionData { get; init; }
|
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; }
|
|
||||||
}
|
|
||||||
|
|||||||
@ -235,8 +235,6 @@ public sealed class Book
|
|||||||
public int BookNumber { get; set; }
|
public int BookNumber { get; set; }
|
||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
public int? TargetWordCount { get; set; }
|
public int? TargetWordCount { get; set; }
|
||||||
public string? StoryEra { get; set; }
|
|
||||||
public string? PrimaryLocaleCode { get; set; }
|
|
||||||
public DateTime? WritingStartDate { get; set; }
|
public DateTime? WritingStartDate { get; set; }
|
||||||
public DateTime? TargetCompletionDate { get; set; }
|
public DateTime? TargetCompletionDate { get; set; }
|
||||||
public DateTime? PublicationDate { get; set; }
|
public DateTime? PublicationDate { get; set; }
|
||||||
@ -257,53 +255,6 @@ public sealed class Book
|
|||||||
: null;
|
: 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 sealed class ManuscriptDocumentModel
|
||||||
{
|
{
|
||||||
public int ManuscriptDocumentID { get; set; }
|
public int ManuscriptDocumentID { get; set; }
|
||||||
@ -393,11 +344,6 @@ public sealed class Chapter
|
|||||||
public decimal ChapterNumber { get; set; }
|
public decimal ChapterNumber { get; set; }
|
||||||
public string ChapterTitle { get; set; } = string.Empty;
|
public string ChapterTitle { get; set; } = string.Empty;
|
||||||
public int? POVCharacterID { 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 string? Summary { get; set; }
|
public string? Summary { get; set; }
|
||||||
public int? ChapterPurposeID { get; set; }
|
public int? ChapterPurposeID { get; set; }
|
||||||
public string? ChapterPurposeName { get; set; }
|
public string? ChapterPurposeName { get; set; }
|
||||||
@ -430,16 +376,8 @@ public sealed class Scene
|
|||||||
public string? ChapterTitle { get; set; }
|
public string? ChapterTitle { get; set; }
|
||||||
public decimal SceneNumber { get; set; }
|
public decimal SceneNumber { get; set; }
|
||||||
public string SceneTitle { get; set; } = string.Empty;
|
public string SceneTitle { get; set; } = string.Empty;
|
||||||
public string? SceneTitleSource { get; set; }
|
|
||||||
public string? Summary { get; set; }
|
public string? Summary { get; set; }
|
||||||
public string? StructuralSummary { get; set; }
|
|
||||||
public string? StructuralSetting { get; set; }
|
|
||||||
public int? POVCharacterID { 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? PrimaryLocationID { get; set; }
|
||||||
public int? FloorPlanID { get; set; }
|
public int? FloorPlanID { get; set; }
|
||||||
public int? InitialFloorPlanFloorID { get; set; }
|
public int? InitialFloorPlanFloorID { get; set; }
|
||||||
@ -481,7 +419,6 @@ public sealed class Scene
|
|||||||
public int PendingCharacterSuggestionCount { get; set; }
|
public int PendingCharacterSuggestionCount { get; set; }
|
||||||
public int PendingAssetSuggestionCount { get; set; }
|
public int PendingAssetSuggestionCount { get; set; }
|
||||||
public int PendingLocationSuggestionCount { get; set; }
|
public int PendingLocationSuggestionCount { get; set; }
|
||||||
public ManuscriptTextStatistics? TextStatistics { get; set; }
|
|
||||||
|
|
||||||
public string TimeLabel
|
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 sealed class RevisionStatus
|
||||||
{
|
{
|
||||||
public int RevisionStatusID { get; set; }
|
public int RevisionStatusID { get; set; }
|
||||||
@ -1012,14 +930,6 @@ public sealed class Character
|
|||||||
public string? ArchivedReason { get; set; }
|
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 sealed class CharacterSexValue
|
||||||
{
|
{
|
||||||
public int CharacterSexValueID { get; set; }
|
public int CharacterSexValueID { get; set; }
|
||||||
@ -1087,10 +997,6 @@ public sealed class SceneCharacter
|
|||||||
public string? PhysicalCondition { get; set; }
|
public string? PhysicalCondition { get; set; }
|
||||||
public string? EmotionalState { get; set; }
|
public string? EmotionalState { get; set; }
|
||||||
public string? KnowledgeNotes { 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 decimal SceneNumber { get; set; }
|
||||||
public string SceneTitle { get; set; } = string.Empty;
|
public string SceneTitle { get; set; } = string.Empty;
|
||||||
public decimal ChapterNumber { get; set; }
|
public decimal ChapterNumber { get; set; }
|
||||||
|
|||||||
@ -34,7 +34,6 @@ public static class IllustrationLibraryStatuses
|
|||||||
public const string Failed = "Failed";
|
public const string Failed = "Failed";
|
||||||
public const string Superseded = "Superseded";
|
public const string Superseded = "Superseded";
|
||||||
public const string Disabled = "Disabled";
|
public const string Disabled = "Disabled";
|
||||||
public const string Archived = "Archived";
|
|
||||||
|
|
||||||
public static readonly IReadOnlySet<string> All = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
public static readonly IReadOnlySet<string> All = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
@ -46,8 +45,7 @@ public static class IllustrationLibraryStatuses
|
|||||||
Rejected,
|
Rejected,
|
||||||
Failed,
|
Failed,
|
||||||
Superseded,
|
Superseded,
|
||||||
Disabled,
|
Disabled
|
||||||
Archived
|
|
||||||
};
|
};
|
||||||
|
|
||||||
public static bool IsValid(string? status)
|
public static bool IsValid(string? status)
|
||||||
@ -62,13 +60,10 @@ public static class IllustrationLibraryStatuses
|
|||||||
(Generating, Failed) => true,
|
(Generating, Failed) => true,
|
||||||
(Generated, Approved) => true,
|
(Generated, Approved) => true,
|
||||||
(Generated, Rejected) => true,
|
(Generated, Rejected) => true,
|
||||||
(Generated, Archived) => true,
|
|
||||||
(Approved, Superseded) => true,
|
(Approved, Superseded) => true,
|
||||||
(Approved, Archived) => true,
|
|
||||||
(Rejected, Queued) => true,
|
(Rejected, Queued) => true,
|
||||||
(Failed, Queued) => true,
|
(Failed, Queued) => true,
|
||||||
(Approved, Disabled) => true,
|
(Approved, Disabled) => true,
|
||||||
(Disabled, Archived) => true,
|
|
||||||
(Disabled, Queued) => true,
|
(Disabled, Queued) => true,
|
||||||
_ when string.Equals(from, to, StringComparison.OrdinalIgnoreCase) => true,
|
_ when string.Equals(from, to, StringComparison.OrdinalIgnoreCase) => true,
|
||||||
_ => false
|
_ => false
|
||||||
@ -100,18 +95,6 @@ public sealed class IllustrationGenerationSpecification
|
|||||||
public IReadOnlyList<string> Palette { get; init; } = [];
|
public IReadOnlyList<string> Palette { get; init; } = [];
|
||||||
public IReadOnlyList<string> MetadataTags { get; init; } = [];
|
public IReadOnlyList<string> MetadataTags { get; init; } = [];
|
||||||
public Dictionary<string, string> Metadata { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
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()
|
public IReadOnlyList<string> Validate()
|
||||||
{
|
{
|
||||||
@ -190,28 +173,6 @@ public sealed class IllustrationLibraryItem
|
|||||||
public string? Prompt { get; set; }
|
public string? Prompt { get; set; }
|
||||||
public string? PromptTemplateVersion { get; set; }
|
public string? PromptTemplateVersion { get; set; }
|
||||||
public string? ProviderRequestID { 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 AttemptCount { get; set; }
|
||||||
public int GenerationAttemptCount { get; set; }
|
public int GenerationAttemptCount { get; set; }
|
||||||
public int? ParentItemID { get; set; }
|
public int? ParentItemID { get; set; }
|
||||||
@ -239,7 +200,6 @@ public sealed class IllustrationLibrarySummary
|
|||||||
public int FailedCount { get; set; }
|
public int FailedCount { get; set; }
|
||||||
public int SupersededCount { get; set; }
|
public int SupersededCount { get; set; }
|
||||||
public int DisabledCount { get; set; }
|
public int DisabledCount { get; set; }
|
||||||
public int ArchivedCount { get; set; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class IllustrationLibraryFilter
|
public sealed class IllustrationLibraryFilter
|
||||||
@ -269,9 +229,6 @@ public sealed record IllustrationImageGenerationResult(
|
|||||||
string? Provider = null,
|
string? Provider = null,
|
||||||
string? Model = null,
|
string? Model = null,
|
||||||
string? ProviderRequestID = null,
|
string? ProviderRequestID = null,
|
||||||
string? Quality = null,
|
|
||||||
string? Size = null,
|
|
||||||
string? OutputFormat = null,
|
|
||||||
string? ErrorMessage = null)
|
string? ErrorMessage = null)
|
||||||
{
|
{
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
|
|||||||
@ -19,7 +19,6 @@ public static class ManuscriptScanReviewStatuses
|
|||||||
public const string ReviewInProgress = "ReviewInProgress";
|
public const string ReviewInProgress = "ReviewInProgress";
|
||||||
public const string ReviewComplete = "ReviewComplete";
|
public const string ReviewComplete = "ReviewComplete";
|
||||||
public const string ReadyToImport = "ReadyToImport";
|
public const string ReadyToImport = "ReadyToImport";
|
||||||
public const string CoreImportComplete = "CoreImportComplete";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ManuscriptScanCommand
|
public sealed class ManuscriptScanCommand
|
||||||
@ -79,26 +78,6 @@ public sealed class ManuscriptScanChapterPreview
|
|||||||
public string? ChapterText { get; init; }
|
public string? ChapterText { get; init; }
|
||||||
public int? StartPosition { get; init; }
|
public int? StartPosition { get; init; }
|
||||||
public int? ExistingChapterID { 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
|
public sealed class ManuscriptScanScenePreview
|
||||||
@ -123,17 +102,6 @@ public sealed class ManuscriptScanCharacterCandidatePreview
|
|||||||
public string? Reason { get; init; }
|
public string? Reason { get; init; }
|
||||||
public bool IsExistingCharacterMatch { get; init; }
|
public bool IsExistingCharacterMatch { get; init; }
|
||||||
public string? SuggestedImportance { 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
|
public sealed class ManuscriptScanState
|
||||||
@ -154,7 +122,6 @@ public sealed class ManuscriptScanReviewDecision
|
|||||||
{
|
{
|
||||||
public Guid PreviewID { get; init; }
|
public Guid PreviewID { get; init; }
|
||||||
public string Status { get; init; } = ManuscriptScanReviewStatuses.ScanComplete;
|
public string Status { get; init; } = ManuscriptScanReviewStatuses.ScanComplete;
|
||||||
public bool GenerateSceneTitles { get; init; } = true;
|
|
||||||
public DateTime UpdatedUtc { get; init; } = DateTime.UtcNow;
|
public DateTime UpdatedUtc { get; init; } = DateTime.UtcNow;
|
||||||
public IReadOnlyList<ManuscriptScanChapterReviewDecision> Chapters { get; init; } = [];
|
public IReadOnlyList<ManuscriptScanChapterReviewDecision> Chapters { get; init; } = [];
|
||||||
public IReadOnlyList<ManuscriptScanSceneReviewDecision> Scenes { get; init; } = [];
|
public IReadOnlyList<ManuscriptScanSceneReviewDecision> Scenes { get; init; } = [];
|
||||||
@ -209,33 +176,9 @@ public sealed class OnboardingManuscriptBuildResult
|
|||||||
public IReadOnlyList<OnboardingBuildChapterMap> ChapterMappings { get; init; } = [];
|
public IReadOnlyList<OnboardingBuildChapterMap> ChapterMappings { get; init; } = [];
|
||||||
public IReadOnlyList<OnboardingBuildSceneMap> SceneMappings { get; init; } = [];
|
public IReadOnlyList<OnboardingBuildSceneMap> SceneMappings { get; init; } = [];
|
||||||
public IReadOnlyList<OnboardingBuildCharacterMap> CharacterMappings { 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 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 sealed class OnboardingBuildChapterMap
|
||||||
{
|
{
|
||||||
public string TemporaryChapterKey { get; init; } = string.Empty;
|
public string TemporaryChapterKey { get; init; } = string.Empty;
|
||||||
@ -246,8 +189,6 @@ public sealed class OnboardingBuildSceneMap
|
|||||||
{
|
{
|
||||||
public string TemporarySceneKey { get; init; } = string.Empty;
|
public string TemporarySceneKey { get; init; } = string.Empty;
|
||||||
public int SceneID { get; init; }
|
public int SceneID { get; init; }
|
||||||
public string? SceneTitle { get; init; }
|
|
||||||
public string? SceneTitleSource { get; init; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class OnboardingBuildCharacterMap
|
public sealed class OnboardingBuildCharacterMap
|
||||||
@ -257,16 +198,6 @@ public sealed class OnboardingBuildCharacterMap
|
|||||||
public bool Created { get; init; }
|
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 sealed class OnboardingBuildProgress
|
||||||
{
|
{
|
||||||
public string Status { get; init; } = "Running";
|
public string Status { get; init; } = "Running";
|
||||||
@ -277,6 +208,4 @@ public sealed class OnboardingBuildProgress
|
|||||||
public int CharactersCreated { get; init; }
|
public int CharactersCreated { get; init; }
|
||||||
public int SceneAppearancesCreated { get; init; }
|
public int SceneAppearancesCreated { get; init; }
|
||||||
public Guid? PreviewID { get; init; }
|
public Guid? PreviewID { get; init; }
|
||||||
public int? BuildID { get; init; }
|
|
||||||
public string? MarkerStatus { get; init; }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
|
||||||
@ -18,7 +18,6 @@ public sealed class SceneIntelligenceScene
|
|||||||
public List<SceneIntelligenceRelationship>? Relationships { get; init; }
|
public List<SceneIntelligenceRelationship>? Relationships { get; init; }
|
||||||
public List<SceneIntelligenceKnowledgeChange>? KnowledgeChanges { get; init; }
|
public List<SceneIntelligenceKnowledgeChange>? KnowledgeChanges { get; init; }
|
||||||
public List<SceneIntelligenceTimelineClue>? TimelineClues { get; init; }
|
public List<SceneIntelligenceTimelineClue>? TimelineClues { get; init; }
|
||||||
public List<SceneIntelligenceNarrativeArc>? NarrativeArcs { get; init; }
|
|
||||||
public List<SceneIntelligenceQuestionRaised>? QuestionsRaised { get; init; }
|
public List<SceneIntelligenceQuestionRaised>? QuestionsRaised { get; init; }
|
||||||
public List<SceneIntelligenceQuestionAnswered>? QuestionsAnswered { get; init; }
|
public List<SceneIntelligenceQuestionAnswered>? QuestionsAnswered { get; init; }
|
||||||
public List<SceneIntelligenceObservation>? Observations { get; init; }
|
public List<SceneIntelligenceObservation>? Observations { get; init; }
|
||||||
@ -208,20 +207,6 @@ public sealed class SceneIntelligenceQuestionRaised
|
|||||||
public Dictionary<string, JsonElement>? ExtensionData { get; init; }
|
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 sealed class SceneIntelligenceQuestionAnswered
|
||||||
{
|
{
|
||||||
public string? Question { get; init; }
|
public string? Question { get; init; }
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -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);
|
||||||
@ -82,9 +82,7 @@ public sealed class StoryIntelligenceRunProgressEvent
|
|||||||
public int? FailedScenes { get; init; }
|
public int? FailedScenes { get; init; }
|
||||||
public int? TotalTokens { get; init; }
|
public int? TotalTokens { get; init; }
|
||||||
public long? TotalDurationMs { get; init; }
|
public long? TotalDurationMs { get; init; }
|
||||||
public int? CurrentSceneID { get; init; }
|
|
||||||
public int? CurrentSceneNumber { get; init; }
|
public int? CurrentSceneNumber { get; init; }
|
||||||
public string? CurrentSceneDisplayTitle { get; init; }
|
|
||||||
public string ElapsedTime { get; init; } = "0 sec";
|
public string ElapsedTime { get; init; } = "0 sec";
|
||||||
public string? EstimatedRemaining { get; init; }
|
public string? EstimatedRemaining { get; init; }
|
||||||
public string? LatestSceneSummary { get; init; }
|
public string? LatestSceneSummary { get; init; }
|
||||||
@ -101,16 +99,8 @@ public sealed class StoryIntelligenceOptions
|
|||||||
public string Model { get; init; } = string.Empty;
|
public string Model { get; init; } = string.Empty;
|
||||||
public string ChapterStructureModel { get; init; } = string.Empty;
|
public string ChapterStructureModel { get; init; } = string.Empty;
|
||||||
public string SceneIntelligenceModel { 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 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 MaxOutputTokens { get; init; } = 4000;
|
||||||
public int? SceneIntelligenceMaxOutputTokens { get; init; }
|
public int? SceneIntelligenceMaxOutputTokens { get; init; }
|
||||||
public int TimeoutSeconds { get; init; } = 120;
|
public int TimeoutSeconds { get; init; } = 120;
|
||||||
|
|||||||
@ -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; }
|
|
||||||
}
|
|
||||||
@ -36,8 +36,6 @@ public static class StoryIntelligencePipelineStages
|
|||||||
public const string RelationshipImport = "RelationshipImport";
|
public const string RelationshipImport = "RelationshipImport";
|
||||||
public const string KnowledgeReview = "KnowledgeReview";
|
public const string KnowledgeReview = "KnowledgeReview";
|
||||||
public const string KnowledgeImport = "KnowledgeImport";
|
public const string KnowledgeImport = "KnowledgeImport";
|
||||||
public const string NarrativeReview = "NarrativeReview";
|
|
||||||
public const string NarrativeImport = "NarrativeImport";
|
|
||||||
public const string Complete = "Complete";
|
public const string Complete = "Complete";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,23 +66,7 @@ public sealed class StoryIntelligenceBookPipelineState
|
|||||||
|
|
||||||
public bool IsComplete
|
public bool IsComplete
|
||||||
=> string.Equals(Status, StoryIntelligencePipelineStatuses.Complete, StringComparison.OrdinalIgnoreCase)
|
=> string.Equals(Status, StoryIntelligencePipelineStatuses.Complete, StringComparison.OrdinalIgnoreCase)
|
||||||
&& string.Equals(LastCompletedStage, StoryIntelligencePipelineStages.NarrativeImport, StringComparison.OrdinalIgnoreCase);
|
&& string.Equals(LastCompletedStage, StoryIntelligencePipelineStages.KnowledgeImport, 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";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class StoryIntelligenceBookPipelineSaveRequest
|
public sealed class StoryIntelligenceBookPipelineSaveRequest
|
||||||
@ -177,7 +159,6 @@ public sealed class StoryIntelligenceKnownCharacterContext
|
|||||||
public string SexHint { get; init; } = "Unknown";
|
public string SexHint { get; init; } = "Unknown";
|
||||||
public int? ExistingCharacterID { get; init; }
|
public int? ExistingCharacterID { get; init; }
|
||||||
public IReadOnlyList<string> CandidateKeys { get; init; } = [];
|
public IReadOnlyList<string> CandidateKeys { get; init; } = [];
|
||||||
public IReadOnlyList<int> SceneIDs { get; init; } = [];
|
|
||||||
public int MentionCount { get; init; }
|
public int MentionCount { get; init; }
|
||||||
public decimal Confidence { get; init; }
|
public decimal Confidence { get; init; }
|
||||||
public string Source { get; init; } = "InitialManuscriptScan";
|
public string Source { get; init; } = "InitialManuscriptScan";
|
||||||
@ -491,7 +472,6 @@ public sealed class StoryIntelligenceImportCommit
|
|||||||
public sealed class StoryIntelligenceSceneImportItem
|
public sealed class StoryIntelligenceSceneImportItem
|
||||||
{
|
{
|
||||||
public int SceneResultID { get; init; }
|
public int SceneResultID { get; init; }
|
||||||
public int? SceneID { get; init; }
|
|
||||||
public int TemporarySceneNumber { get; init; }
|
public int TemporarySceneNumber { get; init; }
|
||||||
public int? StartParagraph { get; init; }
|
public int? StartParagraph { get; init; }
|
||||||
public int? EndParagraph { get; init; }
|
public int? EndParagraph { get; init; }
|
||||||
@ -505,8 +485,6 @@ public sealed class StoryIntelligenceSceneImportItem
|
|||||||
public string? PurposeNotes { get; init; }
|
public string? PurposeNotes { get; init; }
|
||||||
public string? OutcomeNotes { get; init; }
|
public string? OutcomeNotes { get; init; }
|
||||||
public string? ImportNoteText { get; init; }
|
public string? ImportNoteText { get; init; }
|
||||||
public string AnalysisStatus { get; init; } = "Ready";
|
|
||||||
public bool HasWarnings { get; init; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class StoryIntelligenceImportCommitRequest
|
public sealed class StoryIntelligenceImportCommitRequest
|
||||||
|
|||||||
@ -220,9 +220,6 @@ public sealed record StoryMemoryRebuildOptions
|
|||||||
public bool RebuildAll { get; init; }
|
public bool RebuildAll { get; init; }
|
||||||
public bool QueueDemand { get; init; }
|
public bool QueueDemand { get; init; }
|
||||||
public int? MaxDemandToQueue { 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 AppearancePreference { get; init; } = StoryMemoryAppearancePreferences.Balanced;
|
||||||
public string PrimaryAgeGroup { get; init; } = string.Empty;
|
public string PrimaryAgeGroup { get; init; } = string.Empty;
|
||||||
public string StoryEra { 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 bool QueueDemand { get; init; }
|
||||||
public int? MaxDemandToQueue { 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 AppearancePreference { get; init; } = StoryMemoryAppearancePreferences.Balanced;
|
||||||
public string PrimaryAgeGroup { get; init; } = string.Empty;
|
public string PrimaryAgeGroup { get; init; } = string.Empty;
|
||||||
public string StoryEra { get; init; } = string.Empty;
|
public string StoryEra { get; init; } = string.Empty;
|
||||||
|
|||||||
@ -39,7 +39,6 @@ public sealed class WordCompanionPresenceStatus
|
|||||||
public string? CurrentDocumentName { get; init; }
|
public string? CurrentDocumentName { get; init; }
|
||||||
public int? LinkedProjectID { get; init; }
|
public int? LinkedProjectID { get; init; }
|
||||||
public int? LinkedBookID { get; init; }
|
public int? LinkedBookID { get; init; }
|
||||||
public StoryPosition? CurrentStoryPosition { get; init; }
|
|
||||||
public DateTime? ConnectedUtc { get; init; }
|
public DateTime? ConnectedUtc { get; init; }
|
||||||
public DateTime? LastHeartbeatUtc { get; init; }
|
public DateTime? LastHeartbeatUtc { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,14 +25,6 @@
|
|||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||||
</Content>
|
</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>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@ -122,7 +122,6 @@ public class Program
|
|||||||
builder.Services.AddScoped<ITimelineSettingsRepository, TimelineSettingsRepository>();
|
builder.Services.AddScoped<ITimelineSettingsRepository, TimelineSettingsRepository>();
|
||||||
builder.Services.AddScoped<ISceneMetricTypeRepository, SceneMetricTypeRepository>();
|
builder.Services.AddScoped<ISceneMetricTypeRepository, SceneMetricTypeRepository>();
|
||||||
builder.Services.AddScoped<IPlotRepository, PlotRepository>();
|
builder.Services.AddScoped<IPlotRepository, PlotRepository>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceNarrativeCandidateRepository, StoryIntelligenceNarrativeCandidateRepository>();
|
|
||||||
builder.Services.AddScoped<IAssetRepository, AssetRepository>();
|
builder.Services.AddScoped<IAssetRepository, AssetRepository>();
|
||||||
builder.Services.AddScoped<ICharacterRepository, CharacterRepository>();
|
builder.Services.AddScoped<ICharacterRepository, CharacterRepository>();
|
||||||
builder.Services.AddScoped<ILocationRepository, LocationRepository>();
|
builder.Services.AddScoped<ILocationRepository, LocationRepository>();
|
||||||
@ -145,14 +144,13 @@ public class Program
|
|||||||
builder.Services.AddScoped<IWordCompanionRepository, WordCompanionRepository>();
|
builder.Services.AddScoped<IWordCompanionRepository, WordCompanionRepository>();
|
||||||
builder.Services.AddScoped<IOnboardingRepository, OnboardingRepository>();
|
builder.Services.AddScoped<IOnboardingRepository, OnboardingRepository>();
|
||||||
builder.Services.AddScoped<IOnboardingBuildRepository, OnboardingBuildRepository>();
|
builder.Services.AddScoped<IOnboardingBuildRepository, OnboardingBuildRepository>();
|
||||||
builder.Services.AddScoped<IManuscriptTextStatisticsRepository, ManuscriptTextStatisticsRepository>();
|
|
||||||
builder.Services.AddScoped<IStoryIntelligenceRepository, StoryIntelligenceRepository>();
|
builder.Services.AddScoped<IStoryIntelligenceRepository, StoryIntelligenceRepository>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceResultRepository, StoryIntelligenceResultRepository>();
|
builder.Services.AddScoped<IStoryIntelligenceResultRepository, StoryIntelligenceResultRepository>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceSourceRepository, StoryIntelligenceSourceRepository>();
|
builder.Services.AddScoped<IStoryIntelligenceSourceRepository, StoryIntelligenceSourceRepository>();
|
||||||
builder.Services.AddScoped<IStoryIntelligencePipelineRepository, StoryIntelligencePipelineRepository>();
|
builder.Services.AddScoped<IStoryIntelligencePipelineRepository, StoryIntelligencePipelineRepository>();
|
||||||
|
builder.Services.AddScoped<IStoryIntelligenceIllustrationMatchingRepository, StoryIntelligenceIllustrationMatchingRepository>();
|
||||||
builder.Services.AddScoped<IStoryMemoryRepository, StoryMemoryRepository>();
|
builder.Services.AddScoped<IStoryMemoryRepository, StoryMemoryRepository>();
|
||||||
builder.Services.AddScoped<ICharacterIntelligenceRepository, CharacterIntelligenceRepository>();
|
builder.Services.AddScoped<ICharacterIntelligenceRepository, CharacterIntelligenceRepository>();
|
||||||
builder.Services.AddScoped<INameIntelligenceRepository, NameIntelligenceRepository>();
|
|
||||||
builder.Services.AddScoped<IIllustrationLibraryRepository, IllustrationLibraryRepository>();
|
builder.Services.AddScoped<IIllustrationLibraryRepository, IllustrationLibraryRepository>();
|
||||||
builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>();
|
builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>();
|
||||||
builder.Services.AddScoped<IProjectCollaborationRepository, ProjectCollaborationRepository>();
|
builder.Services.AddScoped<IProjectCollaborationRepository, ProjectCollaborationRepository>();
|
||||||
@ -217,9 +215,6 @@ public class Program
|
|||||||
builder.Services.AddScoped<IStoryIntelligenceDryRunService, StoryIntelligenceDryRunService>();
|
builder.Services.AddScoped<IStoryIntelligenceDryRunService, StoryIntelligenceDryRunService>();
|
||||||
builder.Services.AddScoped<IChapterStructureDryRunService, ChapterStructureDryRunService>();
|
builder.Services.AddScoped<IChapterStructureDryRunService, ChapterStructureDryRunService>();
|
||||||
builder.Services.AddScoped<IChapterStoryIntelligenceDryRunService, ChapterStoryIntelligenceDryRunService>();
|
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<IStoryIntelligenceResultPersistenceService, StoryIntelligenceResultPersistenceService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceExistingChapterQueueService, StoryIntelligenceExistingChapterQueueService>();
|
builder.Services.AddScoped<IStoryIntelligenceExistingChapterQueueService, StoryIntelligenceExistingChapterQueueService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceFullChapterTestService, StoryIntelligenceFullChapterTestService>();
|
builder.Services.AddScoped<IStoryIntelligenceFullChapterTestService, StoryIntelligenceFullChapterTestService>();
|
||||||
@ -229,15 +224,11 @@ public class Program
|
|||||||
builder.Services.AddScoped<IStoryIntelligenceAssetImportService, StoryIntelligenceAssetImportService>();
|
builder.Services.AddScoped<IStoryIntelligenceAssetImportService, StoryIntelligenceAssetImportService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceRelationshipImportService, StoryIntelligenceRelationshipImportService>();
|
builder.Services.AddScoped<IStoryIntelligenceRelationshipImportService, StoryIntelligenceRelationshipImportService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceKnowledgeImportService, StoryIntelligenceKnowledgeImportService>();
|
builder.Services.AddScoped<IStoryIntelligenceKnowledgeImportService, StoryIntelligenceKnowledgeImportService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceNarrativeReviewService, StoryIntelligenceNarrativeReviewService>();
|
builder.Services.AddScoped<IStoryIntelligenceVisualisationSnapshotService, StoryIntelligenceVisualisationSnapshotService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceSceneContextService, StoryIntelligenceSceneContextService>();
|
|
||||||
builder.Services.AddScoped<IStoryMemoryService, StoryMemoryService>();
|
builder.Services.AddScoped<IStoryMemoryService, StoryMemoryService>();
|
||||||
builder.Services.AddScoped<ICharacterIntelligenceService, CharacterIntelligenceService>();
|
builder.Services.AddScoped<ICharacterIntelligenceService, CharacterIntelligenceService>();
|
||||||
builder.Services.AddScoped<INameIntelligenceService, NameIntelligenceService>();
|
|
||||||
builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>();
|
builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
|
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceEntitlementService, StoryIntelligenceEntitlementService>();
|
|
||||||
builder.Services.AddScoped<IStoryIntelligenceCompletionNotificationService, StoryIntelligenceCompletionNotificationService>();
|
|
||||||
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();
|
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();
|
||||||
builder.Services.AddScoped<IPersistedStoryIntelligenceRunner, PersistedStoryIntelligenceRunner>();
|
builder.Services.AddScoped<IPersistedStoryIntelligenceRunner, PersistedStoryIntelligenceRunner>();
|
||||||
builder.Services.AddScoped<IIllustrationPromptBuilder, IllustrationPromptBuilder>();
|
builder.Services.AddScoped<IIllustrationPromptBuilder, IllustrationPromptBuilder>();
|
||||||
@ -245,6 +236,7 @@ public class Program
|
|||||||
builder.Services.AddHttpClient<IIllustrationImageProvider, OpenAIIllustrationImageProvider>();
|
builder.Services.AddHttpClient<IIllustrationImageProvider, OpenAIIllustrationImageProvider>();
|
||||||
builder.Services.AddScoped<IIllustrationGenerationRunner, IllustrationGenerationRunner>();
|
builder.Services.AddScoped<IIllustrationGenerationRunner, IllustrationGenerationRunner>();
|
||||||
builder.Services.AddScoped<IIllustrationLibraryService, IllustrationLibraryService>();
|
builder.Services.AddScoped<IIllustrationLibraryService, IllustrationLibraryService>();
|
||||||
|
builder.Services.AddScoped<IStoryIntelligenceIllustrationMatchingService, StoryIntelligenceIllustrationMatchingService>();
|
||||||
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
|
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
|
||||||
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
||||||
builder.Services.AddScoped<IStripeBillingService, StripeBillingService>();
|
builder.Services.AddScoped<IStripeBillingService, StripeBillingService>();
|
||||||
|
|||||||
@ -50,7 +50,6 @@ public interface IEmailService
|
|||||||
Task SendFeatureRequestAdminReplyAsync(FeatureRequest request, string messagePreview, string link, string actorEmail);
|
Task SendFeatureRequestAdminReplyAsync(FeatureRequest request, string messagePreview, string link, string actorEmail);
|
||||||
Task SendFeatureRequestStatusChangedAsync(FeatureRequest request, string link, string actorEmail);
|
Task SendFeatureRequestStatusChangedAsync(FeatureRequest request, string link, string actorEmail);
|
||||||
Task SendFeatureRequestUserReplyAsync(FeatureRequest request, string messagePreview, 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
|
public interface ICurrentUserService
|
||||||
|
|||||||
@ -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)
|
private async Task QueueAsync(string toEmail, string displayName, string subject, string plainText, string? html)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(toEmail))
|
if (string.IsNullOrWhiteSpace(toEmail))
|
||||||
|
|||||||
@ -58,13 +58,7 @@ public sealed class ChapterStoryIntelligenceDryRunService(
|
|||||||
cancellationToken,
|
cancellationToken,
|
||||||
clientStatus.ChapterStructureModel);
|
clientStatus.ChapterStructureModel);
|
||||||
var chapterJson = ExtractOutputText(chapterResult.RawResponseText);
|
var chapterJson = ExtractOutputText(chapterResult.RawResponseText);
|
||||||
var chapterParse = StoryIntelligenceAiJsonParser.Parse<ChapterStructureModel>(chapterJson, JsonOptions);
|
var parsedChapter = JsonSerializer.Deserialize<ChapterStructureModel>(chapterJson, JsonOptions);
|
||||||
if (!chapterParse.Success)
|
|
||||||
{
|
|
||||||
throw new JsonException(chapterParse.ErrorMessage ?? "Chapter Structure JSON could not be parsed.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var parsedChapter = chapterParse.Parsed;
|
|
||||||
var chapterNormalisation = ChapterStructureBoundaryNormaliser.Normalise(parsedChapter, paragraphs.Count);
|
var chapterNormalisation = ChapterStructureBoundaryNormaliser.Normalise(parsedChapter, paragraphs.Count);
|
||||||
var chapterForProcessing = chapterNormalisation.ChapterStructure;
|
var chapterForProcessing = chapterNormalisation.ChapterStructure;
|
||||||
var chapterValidation = chapterValidator.Validate(chapterForProcessing, paragraphs.Count);
|
var chapterValidation = chapterValidator.Validate(chapterForProcessing, paragraphs.Count);
|
||||||
|
|||||||
@ -13,7 +13,6 @@ public static class ChapterStructureBoundaryNormaliser
|
|||||||
return new ChapterStructureBoundaryNormalisationResult(chapterStructure, []);
|
return new ChapterStructureBoundaryNormalisationResult(chapterStructure, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
var issues = new List<ValidationIssue>();
|
|
||||||
var boundaries = chapterStructure.SceneBoundaries;
|
var boundaries = chapterStructure.SceneBoundaries;
|
||||||
var finalIndex = boundaries.Count - 1;
|
var finalIndex = boundaries.Count - 1;
|
||||||
var final = boundaries[finalIndex];
|
var final = boundaries[finalIndex];
|
||||||
@ -23,47 +22,31 @@ public static class ChapterStructureBoundaryNormaliser
|
|||||||
if (IsTrailingEmptyBoundary(final, previous, paragraphCount, phantomParagraph)
|
if (IsTrailingEmptyBoundary(final, previous, paragraphCount, phantomParagraph)
|
||||||
|| IsTrailingInvalidPlaceholder(final, previous, paragraphCount, phantomParagraph))
|
|| IsTrailingInvalidPlaceholder(final, previous, paragraphCount, phantomParagraph))
|
||||||
{
|
{
|
||||||
boundaries = boundaries
|
var normalisedBoundaries = boundaries
|
||||||
.Take(finalIndex)
|
.Take(finalIndex)
|
||||||
.Select((boundary, index) => CopyBoundary(boundary, index + 1))
|
.Select((boundary, index) => CopyBoundary(boundary, index + 1))
|
||||||
.ToList();
|
.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",
|
Severity = "Warning",
|
||||||
Path = $"sceneBoundaries[{finalIndex}]",
|
Path = $"sceneBoundaries[{finalIndex}]",
|
||||||
Message = BuildNormalisationMessage(final, finalIndex, paragraphCount, phantomParagraph),
|
Message = BuildNormalisationMessage(final, finalIndex, paragraphCount, phantomParagraph),
|
||||||
SuggestedFix = "Do not return a final scene boundary unless it references an actual supplied paragraph."
|
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(normalised, [issue]);
|
||||||
return new ChapterStructureBoundaryNormalisationResult(continuity.ChapterStructure, issues);
|
}
|
||||||
|
|
||||||
|
return new ChapterStructureBoundaryNormalisationResult(chapterStructure, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void AddIssuesTo(ValidationResult validation, ChapterStructureBoundaryNormalisationResult normalisation)
|
public static void AddIssuesTo(ValidationResult validation, ChapterStructureBoundaryNormalisationResult normalisation)
|
||||||
@ -91,8 +74,6 @@ public static class ChapterStructureBoundaryNormaliser
|
|||||||
{
|
{
|
||||||
chapterStructure.SchemaVersion,
|
chapterStructure.SchemaVersion,
|
||||||
chapterStructure.ChapterSummary,
|
chapterStructure.ChapterSummary,
|
||||||
chapterStructure.DefaultPov,
|
|
||||||
chapterStructure.MixedPov,
|
|
||||||
chapterStructure.SceneBoundaries,
|
chapterStructure.SceneBoundaries,
|
||||||
Normalisation = normalisation.Issues.Select(issue => new
|
Normalisation = normalisation.Issues.Select(issue => new
|
||||||
{
|
{
|
||||||
@ -111,152 +92,11 @@ public static class ChapterStructureBoundaryNormaliser
|
|||||||
SceneNumber = sceneNumber,
|
SceneNumber = sceneNumber,
|
||||||
StartParagraph = boundary.StartParagraph,
|
StartParagraph = boundary.StartParagraph,
|
||||||
EndParagraph = boundary.EndParagraph,
|
EndParagraph = boundary.EndParagraph,
|
||||||
SuggestedTitle = boundary.SuggestedTitle,
|
|
||||||
StructuralSummary = boundary.StructuralSummary,
|
|
||||||
StructuralSetting = boundary.StructuralSetting,
|
|
||||||
PovOverride = boundary.PovOverride,
|
|
||||||
Confidence = boundary.Confidence,
|
Confidence = boundary.Confidence,
|
||||||
Reason = boundary.Reason,
|
Reason = boundary.Reason,
|
||||||
ExtensionData = boundary.ExtensionData
|
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(
|
private static bool IsTrailingEmptyBoundary(
|
||||||
ChapterSceneBoundary final,
|
ChapterSceneBoundary final,
|
||||||
ChapterSceneBoundary previous,
|
ChapterSceneBoundary previous,
|
||||||
@ -287,15 +127,6 @@ public static class ChapterStructureBoundaryNormaliser
|
|||||||
return invalidTrailingRange && HasPlaceholderSignal(final);
|
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)
|
private static bool HasPlaceholderSignal(ChapterSceneBoundary boundary)
|
||||||
{
|
{
|
||||||
if (boundary.Confidence <= 0.05m)
|
if (boundary.Confidence <= 0.05m)
|
||||||
|
|||||||
@ -119,15 +119,6 @@ public sealed class ChapterStructureValidator : IChapterStructureValidator
|
|||||||
}
|
}
|
||||||
|
|
||||||
Confidence(boundary.Confidence, $"{path}.confidence", result);
|
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);
|
RequiredString(boundary.Reason, $"{path}.reason", result);
|
||||||
Unknown(boundary.ExtensionData, path, result);
|
Unknown(boundary.ExtensionData, path, result);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
|
||||||
@ -3,7 +3,6 @@ using System.Net;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||||
using Microsoft.Data.SqlClient;
|
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using PlotLine.Data;
|
using PlotLine.Data;
|
||||||
using PlotLine.Models;
|
using PlotLine.Models;
|
||||||
@ -114,7 +113,6 @@ public interface IChapterService
|
|||||||
Task<ChapterEditViewModel?> GetCreateAsync(int bookId);
|
Task<ChapterEditViewModel?> GetCreateAsync(int bookId);
|
||||||
Task<ChapterEditViewModel?> GetEditAsync(int chapterId);
|
Task<ChapterEditViewModel?> GetEditAsync(int chapterId);
|
||||||
Task<int> SaveAsync(ChapterEditViewModel model);
|
Task<int> SaveAsync(ChapterEditViewModel model);
|
||||||
Task<int> SetScenesPovAsync(int chapterId, int? povCharacterId, bool overwriteAuthor);
|
|
||||||
Task ArchiveAsync(int chapterId);
|
Task ArchiveAsync(int chapterId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -123,7 +121,7 @@ public interface ISceneService
|
|||||||
Task<SceneEditViewModel?> GetCreateAsync(int chapterId);
|
Task<SceneEditViewModel?> GetCreateAsync(int chapterId);
|
||||||
Task<SceneEditViewModel?> GetEditAsync(int sceneId);
|
Task<SceneEditViewModel?> GetEditAsync(int sceneId);
|
||||||
Task<int> SaveAsync(SceneEditViewModel model);
|
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 ArchiveAsync(int sceneId);
|
||||||
Task MoveAsync(int sceneId, string direction);
|
Task MoveAsync(int sceneId, string direction);
|
||||||
Task MoveRelativeAsync(int sceneId, int anchorSceneId, string position);
|
Task MoveRelativeAsync(int sceneId, int anchorSceneId, string position);
|
||||||
@ -262,10 +260,8 @@ public interface ICharacterService
|
|||||||
Task<CharacterEditViewModel?> GetCreateCharacterAsync(int projectId);
|
Task<CharacterEditViewModel?> GetCreateCharacterAsync(int projectId);
|
||||||
Task<CharacterEditViewModel?> GetEditCharacterAsync(int characterId);
|
Task<CharacterEditViewModel?> GetEditCharacterAsync(int characterId);
|
||||||
Task<CharacterDetailViewModel?> GetCharacterDetailAsync(int characterId);
|
Task<CharacterDetailViewModel?> GetCharacterDetailAsync(int characterId);
|
||||||
Task<CharacterMergeViewModel?> GetMergeAsync(int sourceCharacterId);
|
|
||||||
Task<CharacterAvatarCropViewModel?> GetAvatarCropAsync(int characterImageId);
|
Task<CharacterAvatarCropViewModel?> GetAvatarCropAsync(int characterImageId);
|
||||||
Task<CharacterSaveResult> SaveCharacterAsync(CharacterEditViewModel model);
|
Task<CharacterSaveResult> SaveCharacterAsync(CharacterEditViewModel model);
|
||||||
Task<CharacterMergeResult?> MergeAsync(CharacterMergeForm form);
|
|
||||||
Task<VisualIdentityImageResult> UploadCharacterImageAsync(CharacterImageUploadViewModel model);
|
Task<VisualIdentityImageResult> UploadCharacterImageAsync(CharacterImageUploadViewModel model);
|
||||||
Task<VisualIdentityImageResult> SaveCharacterAvatarAsync(CharacterAvatarCropViewModel model);
|
Task<VisualIdentityImageResult> SaveCharacterAvatarAsync(CharacterAvatarCropViewModel model);
|
||||||
Task RemoveCharacterAvatarAsync(int characterId);
|
Task RemoveCharacterAvatarAsync(int characterId);
|
||||||
@ -313,7 +309,7 @@ public interface ISceneMetricTypeService
|
|||||||
Task<int> SaveAsync(SceneMetricTypeEditViewModel model);
|
Task<int> SaveAsync(SceneMetricTypeEditViewModel model);
|
||||||
Task<bool> SetActiveAsync(int projectId, int metricTypeId, bool isActive);
|
Task<bool> SetActiveAsync(int projectId, int metricTypeId, bool isActive);
|
||||||
Task<bool> MoveAsync(int projectId, int metricTypeId, string direction);
|
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);
|
Task<bool> RemoveUnusedAsync(int projectId, int metricTypeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -921,13 +917,6 @@ public sealed class ProjectService(
|
|||||||
userId,
|
userId,
|
||||||
filePaths.Count);
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Database hard delete failed for archived project {ProjectID} owned by user {UserID}. File cleanup was not attempted.",
|
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,
|
IProjectRepository projects,
|
||||||
IBookRepository books,
|
IBookRepository books,
|
||||||
IChapterRepository chapters,
|
IChapterRepository chapters,
|
||||||
IOnboardingBuildRepository builds,
|
|
||||||
IManuscriptDocumentRepository manuscriptDocuments,
|
IManuscriptDocumentRepository manuscriptDocuments,
|
||||||
IBookCoverService bookCovers,
|
IBookCoverService bookCovers,
|
||||||
ISubscriptionService subscriptions,
|
ISubscriptionService subscriptions,
|
||||||
@ -1186,11 +1174,6 @@ public sealed class BookService(
|
|||||||
return null;
|
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
|
return new BookDetailViewModel
|
||||||
{
|
{
|
||||||
Project = project,
|
Project = project,
|
||||||
@ -1199,8 +1182,9 @@ public sealed class BookService(
|
|||||||
? await manuscriptDocuments.GetByBookAsync(bookId, userId)
|
? await manuscriptDocuments.GetByBookAsync(bookId, userId)
|
||||||
: null,
|
: null,
|
||||||
Chapters = await chapters.ListByBookAsync(bookId),
|
Chapters = await chapters.ListByBookAsync(bookId),
|
||||||
StoryIntelligencePipeline = pipeline,
|
StoryIntelligencePipeline = currentUser.UserId is int pipelineUserId
|
||||||
StoryIntelligenceAction = BuildStoryIntelligenceAction(pipeline, hasCanonicalSceneSources)
|
? await storyIntelligencePipeline.GetForBookAsync(bookId, pipelineUserId)
|
||||||
|
: null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1220,9 +1204,7 @@ public sealed class BookService(
|
|||||||
BookNumber = existing.Count + 1,
|
BookNumber = existing.Count + 1,
|
||||||
Status = BookStatuses.Planning,
|
Status = BookStatuses.Planning,
|
||||||
CoverThumbnailPath = BookCoverService.PlaceholderPath,
|
CoverThumbnailPath = BookCoverService.PlaceholderPath,
|
||||||
StatusOptions = BuildStatusOptions(BookStatuses.Planning),
|
StatusOptions = BuildStatusOptions(BookStatuses.Planning)
|
||||||
StoryEraOptions = BuildStoryEraOptions(null),
|
|
||||||
PrimaryLocaleOptions = BuildPrimaryLocaleOptions(null)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1246,8 +1228,6 @@ public sealed class BookService(
|
|||||||
BookNumber = book.BookNumber,
|
BookNumber = book.BookNumber,
|
||||||
Description = book.Description,
|
Description = book.Description,
|
||||||
TargetWordCount = book.TargetWordCount,
|
TargetWordCount = book.TargetWordCount,
|
||||||
StoryEra = book.StoryEra,
|
|
||||||
PrimaryLocaleCode = book.PrimaryLocaleCode,
|
|
||||||
WritingStartDate = book.WritingStartDate,
|
WritingStartDate = book.WritingStartDate,
|
||||||
TargetCompletionDate = book.TargetCompletionDate,
|
TargetCompletionDate = book.TargetCompletionDate,
|
||||||
PublicationDate = book.PublicationDate,
|
PublicationDate = book.PublicationDate,
|
||||||
@ -1255,9 +1235,7 @@ public sealed class BookService(
|
|||||||
CoverThumbnailPath = book.CoverThumbnailPath ?? BookCoverService.PlaceholderPath,
|
CoverThumbnailPath = book.CoverThumbnailPath ?? BookCoverService.PlaceholderPath,
|
||||||
CoverStandardPath = book.CoverStandardPath,
|
CoverStandardPath = book.CoverStandardPath,
|
||||||
Project = await projects.GetAsync(book.ProjectID),
|
Project = await projects.GetAsync(book.ProjectID),
|
||||||
StatusOptions = BuildStatusOptions(book.Status),
|
StatusOptions = BuildStatusOptions(book.Status)
|
||||||
StoryEraOptions = BuildStoryEraOptions(book.StoryEra),
|
|
||||||
PrimaryLocaleOptions = BuildPrimaryLocaleOptions(book.PrimaryLocaleCode)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1265,8 +1243,6 @@ public sealed class BookService(
|
|||||||
{
|
{
|
||||||
model.Project = await projects.GetAsync(model.ProjectID);
|
model.Project = await projects.GetAsync(model.ProjectID);
|
||||||
model.StatusOptions = BuildStatusOptions(model.Status);
|
model.StatusOptions = BuildStatusOptions(model.Status);
|
||||||
model.StoryEraOptions = BuildStoryEraOptions(model.StoryEra);
|
|
||||||
model.PrimaryLocaleOptions = BuildPrimaryLocaleOptions(model.PrimaryLocaleCode);
|
|
||||||
if (model.BookID != 0)
|
if (model.BookID != 0)
|
||||||
{
|
{
|
||||||
var book = await books.GetAsync(model.BookID);
|
var book = await books.GetAsync(model.BookID);
|
||||||
@ -1300,8 +1276,6 @@ public sealed class BookService(
|
|||||||
BookNumber = model.BookNumber,
|
BookNumber = model.BookNumber,
|
||||||
Description = model.Description,
|
Description = model.Description,
|
||||||
TargetWordCount = model.TargetWordCount,
|
TargetWordCount = model.TargetWordCount,
|
||||||
StoryEra = TrimOptional(model.StoryEra),
|
|
||||||
PrimaryLocaleCode = TrimOptional(model.PrimaryLocaleCode),
|
|
||||||
WritingStartDate = model.WritingStartDate,
|
WritingStartDate = model.WritingStartDate,
|
||||||
TargetCompletionDate = model.TargetCompletionDate,
|
TargetCompletionDate = model.TargetCompletionDate,
|
||||||
PublicationDate = model.PublicationDate,
|
PublicationDate = model.PublicationDate,
|
||||||
@ -1327,31 +1301,6 @@ public sealed class BookService(
|
|||||||
return new(bookId, true);
|
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)
|
public Task<ManuscriptDocumentUnlinkResult?> UnlinkManuscriptAsync(int bookId)
|
||||||
{
|
{
|
||||||
return currentUser.UserId is int userId
|
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)))
|
.Select(status => new SelectListItem(status, status, string.Equals(status, selectedStatus, StringComparison.OrdinalIgnoreCase)))
|
||||||
.ToList();
|
.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)
|
public async Task ArchiveAsync(int bookId)
|
||||||
{
|
{
|
||||||
var book = await books.GetAsync(bookId);
|
var book = await books.GetAsync(bookId);
|
||||||
@ -1414,8 +1335,6 @@ public sealed class ChapterService(
|
|||||||
ILookupRepository lookups,
|
ILookupRepository lookups,
|
||||||
ISubscriptionService subscriptions,
|
ISubscriptionService subscriptions,
|
||||||
IProjectActivityService activity,
|
IProjectActivityService activity,
|
||||||
IManuscriptTextStatisticsRepository manuscriptTextStatisticsRepository,
|
|
||||||
IManuscriptTextStatisticsService manuscriptTextStatistics,
|
|
||||||
ICurrentUserService currentUser) : IChapterService
|
ICurrentUserService currentUser) : IChapterService
|
||||||
{
|
{
|
||||||
public async Task<ChapterDetailViewModel?> GetDetailAsync(int chapterId)
|
public async Task<ChapterDetailViewModel?> GetDetailAsync(int chapterId)
|
||||||
@ -1434,12 +1353,6 @@ public sealed class ChapterService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var sceneRows = await scenes.ListByChapterAsync(chapterId);
|
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)
|
if (currentUser.UserId is int userId)
|
||||||
{
|
{
|
||||||
var suggestionCounts = await characters.CountSceneCharacterSuggestionsByChapterAsync(chapterId, userId);
|
var suggestionCounts = await characters.CountSceneCharacterSuggestionsByChapterAsync(chapterId, userId);
|
||||||
@ -1462,12 +1375,9 @@ public sealed class ChapterService(
|
|||||||
Book = book,
|
Book = book,
|
||||||
Chapter = chapter,
|
Chapter = chapter,
|
||||||
Scenes = sceneRows,
|
Scenes = sceneRows,
|
||||||
TextStatistics = manuscriptTextStatistics.Aggregate(sceneRows.Select(scene => scene.TextStatistics)),
|
|
||||||
PendingCharacterSuggestionCount = sceneRows.Sum(x => x.PendingCharacterSuggestionCount),
|
PendingCharacterSuggestionCount = sceneRows.Sum(x => x.PendingCharacterSuggestionCount),
|
||||||
PendingAssetSuggestionCount = sceneRows.Sum(x => x.PendingAssetSuggestionCount),
|
PendingAssetSuggestionCount = sceneRows.Sum(x => x.PendingAssetSuggestionCount),
|
||||||
PendingLocationSuggestionCount = sceneRows.Sum(x => x.PendingLocationSuggestionCount),
|
PendingLocationSuggestionCount = sceneRows.Sum(x => x.PendingLocationSuggestionCount)
|
||||||
CharacterOptions = ToOptionalSelectList(await characters.ListCharactersAsync(project.ProjectID), x => x.CharacterID, x => x.CharacterName),
|
|
||||||
PovDisplayLabel = BuildChapterPovDisplay(chapter, sceneRows)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1490,8 +1400,7 @@ public sealed class ChapterService(
|
|||||||
ChapterNumber = existing.Count + 1,
|
ChapterNumber = existing.Count + 1,
|
||||||
RevisionStatusID = DefaultRevisionStatusId(lookupData),
|
RevisionStatusID = DefaultRevisionStatusId(lookupData),
|
||||||
RevisionStatuses = ToSelectList(lookupData.RevisionStatuses, x => x.RevisionStatusID, x => x.StatusName),
|
RevisionStatuses = ToSelectList(lookupData.RevisionStatuses, x => x.RevisionStatusID, x => x.StatusName),
|
||||||
ChapterPurposes = ToOptionalSelectList(lookupData.ChapterPurposeTypes, x => x.ChapterPurposeID, x => x.PurposeName),
|
ChapterPurposes = ToOptionalSelectList(lookupData.ChapterPurposeTypes, x => x.ChapterPurposeID, x => x.PurposeName)
|
||||||
CharacterOptions = ToOptionalSelectList(await characters.ListCharactersAsync(project.ProjectID), x => x.CharacterID, x => x.CharacterName)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1511,18 +1420,12 @@ public sealed class ChapterService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var lookupData = await lookups.GetAllAsync();
|
var lookupData = await lookups.GetAllAsync();
|
||||||
var sceneRows = await scenes.ListByChapterAsync(chapterId);
|
|
||||||
var derivedPov = DeriveUniformScenePov(sceneRows);
|
|
||||||
return new ChapterEditViewModel
|
return new ChapterEditViewModel
|
||||||
{
|
{
|
||||||
ChapterID = chapter.ChapterID,
|
ChapterID = chapter.ChapterID,
|
||||||
BookID = chapter.BookID,
|
BookID = chapter.BookID,
|
||||||
ChapterNumber = chapter.ChapterNumber,
|
ChapterNumber = chapter.ChapterNumber,
|
||||||
ChapterTitle = chapter.ChapterTitle,
|
ChapterTitle = chapter.ChapterTitle,
|
||||||
POVCharacterID = derivedPov.CharacterID,
|
|
||||||
OriginalDerivedPOVCharacterID = derivedPov.CharacterID,
|
|
||||||
HasCanonicalScenes = sceneRows.Count > 0,
|
|
||||||
PovDisplayLabel = derivedPov.DisplayLabel,
|
|
||||||
Summary = chapter.Summary,
|
Summary = chapter.Summary,
|
||||||
ChapterPurposeID = chapter.ChapterPurposeID,
|
ChapterPurposeID = chapter.ChapterPurposeID,
|
||||||
RevisionStatusID = chapter.RevisionStatusID,
|
RevisionStatusID = chapter.RevisionStatusID,
|
||||||
@ -1530,8 +1433,7 @@ public sealed class ChapterService(
|
|||||||
Book = book,
|
Book = book,
|
||||||
Project = project,
|
Project = project,
|
||||||
RevisionStatuses = ToSelectList(lookupData.RevisionStatuses, x => x.RevisionStatusID, x => x.StatusName),
|
RevisionStatuses = ToSelectList(lookupData.RevisionStatuses, x => x.RevisionStatusID, x => x.StatusName),
|
||||||
ChapterPurposes = ToOptionalSelectList(lookupData.ChapterPurposeTypes, x => x.ChapterPurposeID, x => x.PurposeName),
|
ChapterPurposes = ToOptionalSelectList(lookupData.ChapterPurposeTypes, x => x.ChapterPurposeID, x => x.PurposeName)
|
||||||
CharacterOptions = ToOptionalSelectList(await characters.ListCharactersAsync(project.ProjectID), x => x.CharacterID, x => x.CharacterName)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1543,14 +1445,12 @@ public sealed class ChapterService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var isNew = model.ChapterID == 0;
|
var isNew = model.ChapterID == 0;
|
||||||
var hasCanonicalScenes = !isNew && (model.HasCanonicalScenes || (await scenes.ListByChapterAsync(model.ChapterID)).Count > 0);
|
|
||||||
var chapterId = await chapters.SaveAsync(new Chapter
|
var chapterId = await chapters.SaveAsync(new Chapter
|
||||||
{
|
{
|
||||||
ChapterID = model.ChapterID,
|
ChapterID = model.ChapterID,
|
||||||
BookID = model.BookID,
|
BookID = model.BookID,
|
||||||
ChapterNumber = model.ChapterNumber,
|
ChapterNumber = model.ChapterNumber,
|
||||||
ChapterTitle = model.ChapterTitle,
|
ChapterTitle = model.ChapterTitle,
|
||||||
POVCharacterID = hasCanonicalScenes ? null : model.POVCharacterID,
|
|
||||||
Summary = model.Summary,
|
Summary = model.Summary,
|
||||||
ChapterPurposeID = model.ChapterPurposeID,
|
ChapterPurposeID = model.ChapterPurposeID,
|
||||||
RevisionStatusID = model.RevisionStatusID
|
RevisionStatusID = model.RevisionStatusID
|
||||||
@ -1560,27 +1460,9 @@ public sealed class ChapterService(
|
|||||||
{
|
{
|
||||||
await activity.RecordAsync(book.ProjectID, isNew ? "Created" : "Updated", "Chapter", chapterId, model.ChapterTitle);
|
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;
|
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)
|
public async Task ArchiveAsync(int chapterId)
|
||||||
{
|
{
|
||||||
var chapter = await chapters.GetAsync(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)
|
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();
|
=> 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(
|
public sealed class SceneService(
|
||||||
@ -1653,7 +1502,6 @@ public sealed class SceneService(
|
|||||||
IProjectActivityService activity,
|
IProjectActivityService activity,
|
||||||
IStorageService storage,
|
IStorageService storage,
|
||||||
IStoryStateService storyState,
|
IStoryStateService storyState,
|
||||||
IManuscriptTextStatisticsRepository manuscriptTextStatistics,
|
|
||||||
ICurrentUserService currentUser) : ISceneService
|
ICurrentUserService currentUser) : ISceneService
|
||||||
{
|
{
|
||||||
public async Task<SceneEditViewModel?> GetCreateAsync(int chapterId)
|
public async Task<SceneEditViewModel?> GetCreateAsync(int chapterId)
|
||||||
@ -1695,7 +1543,6 @@ public sealed class SceneService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var lookupData = await lookups.GetAllAsync();
|
var lookupData = await lookups.GetAllAsync();
|
||||||
var textStatistics = await manuscriptTextStatistics.GetBySceneAsync(sceneId);
|
|
||||||
return await PopulateWorkspaceDataAsync(PopulateLists(new SceneEditViewModel
|
return await PopulateWorkspaceDataAsync(PopulateLists(new SceneEditViewModel
|
||||||
{
|
{
|
||||||
SceneID = scene.SceneID,
|
SceneID = scene.SceneID,
|
||||||
@ -1719,7 +1566,6 @@ public sealed class SceneService(
|
|||||||
PrimaryLocationID = scene.PrimaryLocationID,
|
PrimaryLocationID = scene.PrimaryLocationID,
|
||||||
FloorPlanID = scene.FloorPlanID,
|
FloorPlanID = scene.FloorPlanID,
|
||||||
InitialFloorPlanFloorID = scene.InitialFloorPlanFloorID,
|
InitialFloorPlanFloorID = scene.InitialFloorPlanFloorID,
|
||||||
TextStatistics = textStatistics,
|
|
||||||
SelectedPurposeTypeIds = scene.SelectedPurposeTypeIds,
|
SelectedPurposeTypeIds = scene.SelectedPurposeTypeIds,
|
||||||
Metrics = scene.Metrics.Select(metric => new SceneMetricInputViewModel
|
Metrics = scene.Metrics.Select(metric => new SceneMetricInputViewModel
|
||||||
{
|
{
|
||||||
@ -1768,7 +1614,6 @@ public sealed class SceneService(
|
|||||||
ScenePurposeNotes = model.ScenePurposeNotes,
|
ScenePurposeNotes = model.ScenePurposeNotes,
|
||||||
SceneOutcomeNotes = model.SceneOutcomeNotes,
|
SceneOutcomeNotes = model.SceneOutcomeNotes,
|
||||||
RevisionStatusID = model.RevisionStatusID,
|
RevisionStatusID = model.RevisionStatusID,
|
||||||
POVCharacterID = model.POVCharacterID,
|
|
||||||
PrimaryLocationID = model.PrimaryLocationID,
|
PrimaryLocationID = model.PrimaryLocationID,
|
||||||
FloorPlanID = model.FloorPlanID,
|
FloorPlanID = model.FloorPlanID,
|
||||||
InitialFloorPlanFloorID = model.InitialFloorPlanFloorID
|
InitialFloorPlanFloorID = model.InitialFloorPlanFloorID
|
||||||
@ -1790,8 +1635,8 @@ public sealed class SceneService(
|
|||||||
return sceneId;
|
return sceneId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task UpdatePovCharacterAsync(int sceneId, int? povCharacterId, string source = "Author", decimal? confidence = null, string? evidence = null, string? suggestedName = null, bool overwriteAuthor = false) =>
|
public Task UpdatePovCharacterAsync(int sceneId, int? povCharacterId) =>
|
||||||
scenes.UpdatePovCharacterAsync(sceneId, povCharacterId, source, confidence, evidence, suggestedName, overwriteAuthor);
|
scenes.UpdatePovCharacterAsync(sceneId, povCharacterId);
|
||||||
|
|
||||||
public async Task ArchiveAsync(int sceneId)
|
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 Task<bool> MoveAsync(int projectId, int metricTypeId, string direction) => metricTypes.MoveAsync(metricTypeId, projectId, direction);
|
||||||
|
|
||||||
public async Task<int> AddMissingDefaultsAsync(int projectId)
|
public Task AddMissingDefaultsAsync(int projectId) => metricTypes.AddMissingDefaultsAsync(projectId);
|
||||||
{
|
|
||||||
await metricTypes.AddMissingDefaultsAsync(projectId);
|
|
||||||
return await metricTypes.BackfillDefaultValuesForProjectAsync(projectId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<bool> RemoveUnusedAsync(int projectId, int metricTypeId) => metricTypes.RemoveUnusedAsync(metricTypeId, 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)
|
public async Task<CharacterAvatarCropViewModel?> GetAvatarCropAsync(int characterImageId)
|
||||||
{
|
{
|
||||||
var image = await characters.GetCharacterImageAsync(characterImageId);
|
var image = await characters.GetCharacterImageAsync(characterImageId);
|
||||||
@ -9375,26 +9187,6 @@ public sealed class CharacterService(
|
|||||||
return new CharacterSaveResult(characterId, uploadedCharacterImageId);
|
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)
|
private async Task SyncCharacterAliasesAsync(int characterId, IEnumerable<string> postedAliases)
|
||||||
{
|
{
|
||||||
var aliases = AliasInput.Clean(postedAliases);
|
var aliases = AliasInput.Clean(postedAliases);
|
||||||
|
|||||||
@ -16,7 +16,7 @@ public interface IIllustrationPromptBuilder
|
|||||||
|
|
||||||
public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder
|
public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder
|
||||||
{
|
{
|
||||||
public const string CurrentTemplateVersion = "22C.1";
|
public const string CurrentTemplateVersion = "21J.3";
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
@ -57,7 +57,7 @@ public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder
|
|||||||
var categoryRules = CategoryRules(specification.Category);
|
var categoryRules = CategoryRules(specification.Category);
|
||||||
|
|
||||||
var prompt = $"""
|
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}
|
Category: {specification.Category}
|
||||||
Library code: {specification.Code}
|
Library code: {specification.Code}
|
||||||
Title: {specification.Title}
|
Title: {specification.Title}
|
||||||
@ -71,16 +71,16 @@ public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder
|
|||||||
Category composition rules: {categoryRules}
|
Category composition rules: {categoryRules}
|
||||||
|
|
||||||
Requirements:
|
Requirements:
|
||||||
- realistic grounded visual identifier only, not a canonical depiction of any manuscript
|
- representative 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
|
- modern cinematic digital illustration with premium narrative game concept art quality
|
||||||
- subtle 1980s UK period detail where requested, grounded wardrobe and hair, not costume-party exaggeration
|
- subdued atmospheric background treatment with contemporary polished rendering, realistic anatomy and believable proportions
|
||||||
- neutral background treatment suitable for PlotDirector UI, with believable anatomy and proportions
|
|
||||||
- balanced cinematic lighting, clear facial illumination where faces are present, and strong local contrast
|
- 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
|
- 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
|
- 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 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, 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
|
- 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
|
- 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)
|
private static string CategoryRules(string category)
|
||||||
=> category switch
|
=> 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.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; 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.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.",
|
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
|
_ => string.Empty
|
||||||
};
|
};
|
||||||
@ -333,9 +333,6 @@ public interface IIllustrationImageProvider
|
|||||||
{
|
{
|
||||||
string ProviderName { get; }
|
string ProviderName { get; }
|
||||||
string EffectiveModel { get; }
|
string EffectiveModel { get; }
|
||||||
string EffectiveQuality { get; }
|
|
||||||
string EffectiveSize { get; }
|
|
||||||
string EffectiveOutputFormat { get; }
|
|
||||||
bool IsConfigured { get; }
|
bool IsConfigured { get; }
|
||||||
IReadOnlyList<string> MissingConfiguration { get; }
|
IReadOnlyList<string> MissingConfiguration { get; }
|
||||||
Task<IllustrationImageGenerationResult> GenerateAsync(IllustrationImageGenerationRequest request, CancellationToken cancellationToken);
|
Task<IllustrationImageGenerationResult> GenerateAsync(IllustrationImageGenerationRequest request, CancellationToken cancellationToken);
|
||||||
@ -350,9 +347,6 @@ public sealed class OpenAIIllustrationImageProvider(
|
|||||||
|
|
||||||
public string ProviderName => "OpenAI";
|
public string ProviderName => "OpenAI";
|
||||||
public string EffectiveModel => string.IsNullOrWhiteSpace(_options.ImageGenerationModel) ? "not configured" : _options.ImageGenerationModel;
|
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 bool IsConfigured => MissingConfiguration.Count == 0;
|
||||||
public IReadOnlyList<string> MissingConfiguration
|
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)}.");
|
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");
|
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "https://api.openai.com/v1/images/generations");
|
||||||
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.ApiKey);
|
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);
|
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)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
var error = OpenAIImageError.FromResponseBody(responseBody);
|
var error = OpenAIImageError.FromResponseBody(body);
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
"OpenAI image generation failed for illustration {ItemID} with HTTP {StatusCode}, error code {ErrorCode}: {ErrorMessage}",
|
"OpenAI image generation failed for illustration {ItemID} with HTTP {StatusCode}, error code {ErrorCode}: {ErrorMessage}",
|
||||||
request.IllustrationLibraryItemID,
|
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()}");
|
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 requestId = response.Headers.TryGetValues("x-request-id", out var values) ? values.FirstOrDefault() : null;
|
||||||
var b64 = document.RootElement.GetProperty("data")[0].GetProperty("b64_json").GetString();
|
var b64 = document.RootElement.GetProperty("data")[0].GetProperty("b64_json").GetString();
|
||||||
if (string.IsNullOrWhiteSpace(b64))
|
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(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)
|
private static string TrimForStorage(string value)
|
||||||
@ -580,16 +560,6 @@ public sealed class IllustrationGenerationRunner(
|
|||||||
|
|
||||||
var prompt = promptBuilder.Build(specification);
|
var prompt = promptBuilder.Build(specification);
|
||||||
var request = new IllustrationImageGenerationRequest(item.IllustrationLibraryItemID, item.StableCode, item.Category, prompt.Prompt, prompt.TemplateVersion);
|
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);
|
var result = await provider.GenerateAsync(request, cancellationToken);
|
||||||
if (!result.Succeeded || !result.HasImage)
|
if (!result.Succeeded || !result.HasImage)
|
||||||
{
|
{
|
||||||
@ -649,12 +619,14 @@ public interface IIllustrationLibraryService
|
|||||||
Task<IllustrationLibraryActionResult> RegenerateAsync(int illustrationLibraryItemId, bool confirmed);
|
Task<IllustrationLibraryActionResult> RegenerateAsync(int illustrationLibraryItemId, bool confirmed);
|
||||||
Task<IllustrationLibraryActionResult> DisableAsync(int illustrationLibraryItemId);
|
Task<IllustrationLibraryActionResult> DisableAsync(int illustrationLibraryItemId);
|
||||||
Task<IllustrationLibraryActionResult> UpdateMetadataAsync(int illustrationLibraryItemId, IllustrationLibraryMetadataForm form);
|
Task<IllustrationLibraryActionResult> UpdateMetadataAsync(int illustrationLibraryItemId, IllustrationLibraryMetadataForm form);
|
||||||
|
Task ApplyApprovedIllustrationsAsync(StoryIntelligenceExperiencePrototypeViewModel prototype);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class IllustrationLibraryService(
|
public sealed class IllustrationLibraryService(
|
||||||
IIllustrationLibraryRepository repository,
|
IIllustrationLibraryRepository repository,
|
||||||
IIllustrationPromptBuilder promptBuilder,
|
IIllustrationPromptBuilder promptBuilder,
|
||||||
IIllustrationImageProvider provider) : IIllustrationLibraryService
|
IIllustrationImageProvider provider,
|
||||||
|
IUploadStorageService uploadStorage) : IIllustrationLibraryService
|
||||||
{
|
{
|
||||||
private const int MaxGenerationAttempts = 3;
|
private const int MaxGenerationAttempts = 3;
|
||||||
|
|
||||||
@ -951,4 +923,113 @@ public sealed class IllustrationLibraryService(
|
|||||||
return new(true, "Illustration metadata updated.");
|
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)));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,7 +16,6 @@ public interface IManuscriptScanPreviewStore
|
|||||||
Task<ManuscriptScanPreview?> GetPreviewAsync(int userId, Guid previewId);
|
Task<ManuscriptScanPreview?> GetPreviewAsync(int userId, Guid previewId);
|
||||||
Task<ManuscriptScanReviewDecision?> GetReviewAsync(int userId, Guid previewId);
|
Task<ManuscriptScanReviewDecision?> GetReviewAsync(int userId, Guid previewId);
|
||||||
Task<ManuscriptScanReviewDecision?> SaveReviewAsync(int userId, ManuscriptScanReviewDecision review);
|
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<OnboardingBuildProgress> BuildProgressAsync(int userId, Guid previewId, string message, int percentComplete, OnboardingManuscriptBuildResult? result = null);
|
||||||
Task<OnboardingManuscriptBuildResult?> GetBuildResultAsync(int userId, Guid previewId);
|
Task<OnboardingManuscriptBuildResult?> GetBuildResultAsync(int userId, Guid previewId);
|
||||||
Task<OnboardingManuscriptBuildResult?> SaveBuildResultAsync(int userId, OnboardingManuscriptBuildResult result);
|
Task<OnboardingManuscriptBuildResult?> SaveBuildResultAsync(int userId, OnboardingManuscriptBuildResult result);
|
||||||
@ -93,8 +92,8 @@ public sealed class ManuscriptScanPreviewStore(
|
|||||||
session.Message = "Scan complete";
|
session.Message = "Scan complete";
|
||||||
session.PercentComplete = 100;
|
session.PercentComplete = 100;
|
||||||
session.ChapterCount = stored.ChapterCount;
|
session.ChapterCount = stored.ChapterCount;
|
||||||
session.SceneCount = 0;
|
session.SceneCount = stored.SceneCount;
|
||||||
session.CharacterCandidateCount = 0;
|
session.CharacterCandidateCount = stored.CharacterCandidateCount;
|
||||||
session.TotalWordCount = stored.TotalWordCount;
|
session.TotalWordCount = stored.TotalWordCount;
|
||||||
session.PreviewID = stored.PreviewID;
|
session.PreviewID = stored.PreviewID;
|
||||||
session.UpdatedUtc = stored.CreatedUtc;
|
session.UpdatedUtc = stored.CreatedUtc;
|
||||||
@ -179,7 +178,6 @@ public sealed class ManuscriptScanPreviewStore(
|
|||||||
{
|
{
|
||||||
PreviewID = review.PreviewID,
|
PreviewID = review.PreviewID,
|
||||||
Status = review.Status,
|
Status = review.Status,
|
||||||
GenerateSceneTitles = review.GenerateSceneTitles,
|
|
||||||
UpdatedUtc = DateTime.UtcNow,
|
UpdatedUtc = DateTime.UtcNow,
|
||||||
Chapters = review.Chapters,
|
Chapters = review.Chapters,
|
||||||
Scenes = review.Scenes,
|
Scenes = review.Scenes,
|
||||||
@ -195,45 +193,6 @@ public sealed class ManuscriptScanPreviewStore(
|
|||||||
return Task.FromResult<ManuscriptScanReviewDecision?>(stored);
|
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)
|
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)
|
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)
|
private async Task<ManuscriptScanReviewDecision> BuildDefaultReviewAsync(ManuscriptScanPreview preview)
|
||||||
{
|
{
|
||||||
|
var aiHints = await GetAiSexHintsAsync(preview.CharacterCandidates);
|
||||||
return new ManuscriptScanReviewDecision
|
return new ManuscriptScanReviewDecision
|
||||||
{
|
{
|
||||||
PreviewID = preview.PreviewID,
|
PreviewID = preview.PreviewID,
|
||||||
GenerateSceneTitles = true,
|
|
||||||
Status = ManuscriptScanReviewStatuses.ScanComplete,
|
Status = ManuscriptScanReviewStatuses.ScanComplete,
|
||||||
Chapters = preview.Chapters
|
Chapters = preview.Chapters
|
||||||
.OrderBy(chapter => chapter.ChapterNumber)
|
.OrderBy(chapter => chapter.ChapterNumber)
|
||||||
@ -312,7 +271,18 @@ public sealed class ManuscriptScanPreviewStore(
|
|||||||
ChapterNumber = chapter.ChapterNumber
|
ChapterNumber = chapter.ChapterNumber
|
||||||
})
|
})
|
||||||
.ToList(),
|
.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
|
Characters = preview.CharacterCandidates
|
||||||
.Select(candidate => new ManuscriptScanCharacterReviewDecision
|
.Select(candidate => new ManuscriptScanCharacterReviewDecision
|
||||||
{
|
{
|
||||||
@ -320,7 +290,9 @@ public sealed class ManuscriptScanPreviewStore(
|
|||||||
Include = string.Equals(candidate.Category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase),
|
Include = string.Equals(candidate.Category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase),
|
||||||
Name = candidate.Name,
|
Name = candidate.Name,
|
||||||
Category = candidate.Category,
|
Category = candidate.Category,
|
||||||
SexHint = CharacterSexHintService.Suggest(candidate.Name)
|
SexHint = aiHints.TryGetValue(candidate.TemporaryCharacterKey, out var aiHint)
|
||||||
|
? aiHint
|
||||||
|
: CharacterSexHintService.Suggest(candidate.Name)
|
||||||
})
|
})
|
||||||
.ToList()
|
.ToList()
|
||||||
};
|
};
|
||||||
@ -381,11 +353,11 @@ file static class ManuscriptScanPreviewExtensions
|
|||||||
CompanionDocumentIdentifier = preview.CompanionDocumentIdentifier,
|
CompanionDocumentIdentifier = preview.CompanionDocumentIdentifier,
|
||||||
TotalWordCount = preview.TotalWordCount,
|
TotalWordCount = preview.TotalWordCount,
|
||||||
ChapterCount = preview.ChapterCount,
|
ChapterCount = preview.ChapterCount,
|
||||||
SceneCount = 0,
|
SceneCount = preview.SceneCount,
|
||||||
CharacterCandidateCount = 0,
|
CharacterCandidateCount = preview.CharacterCandidateCount,
|
||||||
CreatedUtc = preview.CreatedUtc == default ? DateTime.UtcNow : preview.CreatedUtc,
|
CreatedUtc = preview.CreatedUtc == default ? DateTime.UtcNow : preview.CreatedUtc,
|
||||||
Chapters = preview.Chapters,
|
Chapters = preview.Chapters,
|
||||||
Scenes = [],
|
Scenes = preview.Scenes,
|
||||||
CharacterCandidates = []
|
CharacterCandidates = preview.CharacterCandidates
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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; }
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
@ -24,11 +24,8 @@ public interface IOnboardingStoryIntelligenceService
|
|||||||
Task<StoryIntelligenceRelationshipImportResultViewModel?> GetRelationshipImportResultAsync(Guid batchId);
|
Task<StoryIntelligenceRelationshipImportResultViewModel?> GetRelationshipImportResultAsync(Guid batchId);
|
||||||
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportKnowledgeAsync(Guid batchId, StoryIntelligenceKnowledgeImportForm form);
|
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportKnowledgeAsync(Guid batchId, StoryIntelligenceKnowledgeImportForm form);
|
||||||
Task<StoryIntelligenceKnowledgeImportResultViewModel?> GetKnowledgeImportResultAsync(Guid batchId);
|
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<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId);
|
||||||
Task<OnboardingStoryIntelligenceResumeTarget?> ResumeBookAsync(int bookId);
|
Task<OnboardingStoryIntelligenceResumeTarget?> ResumeBookAsync(int bookId);
|
||||||
Task<OnboardingStoryIntelligenceBatch?> StartBookAsync(int bookId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class OnboardingStoryIntelligenceService(
|
public sealed class OnboardingStoryIntelligenceService(
|
||||||
@ -42,23 +39,16 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
IStoryIntelligenceAssetImportService assetImport,
|
IStoryIntelligenceAssetImportService assetImport,
|
||||||
IStoryIntelligenceRelationshipImportService relationshipImport,
|
IStoryIntelligenceRelationshipImportService relationshipImport,
|
||||||
IStoryIntelligenceKnowledgeImportService knowledgeImport,
|
IStoryIntelligenceKnowledgeImportService knowledgeImport,
|
||||||
IStoryIntelligenceNarrativeReviewService narrativeReview,
|
|
||||||
IStoryIntelligenceSourceRepository sources,
|
|
||||||
IStoryIntelligencePipelineStateService pipelineState,
|
IStoryIntelligencePipelineStateService pipelineState,
|
||||||
IStoryMemoryRepository storyMemory,
|
IStoryMemoryRepository storyMemory,
|
||||||
IBookRepository books,
|
|
||||||
IChapterRepository chapters,
|
|
||||||
ICharacterRepository characters,
|
|
||||||
IStoryIntelligenceClient client,
|
IStoryIntelligenceClient client,
|
||||||
IOnboardingStoryIntelligenceBatchStore batchStore,
|
IOnboardingStoryIntelligenceBatchStore batchStore,
|
||||||
IStoryIntelligenceProgressNotifier notifier,
|
IStoryIntelligenceProgressNotifier notifier,
|
||||||
ICurrentUserService currentUser,
|
ICurrentUserService currentUser,
|
||||||
IStoryIntelligenceEntitlementService entitlement,
|
|
||||||
ILogger<OnboardingStoryIntelligenceService> logger) : IOnboardingStoryIntelligenceService
|
ILogger<OnboardingStoryIntelligenceService> logger) : IOnboardingStoryIntelligenceService
|
||||||
{
|
{
|
||||||
private const string PromptVersionsSummary = "Chapter=Chapter-Structure-Prompt-V2; Scene=Scene-Prompt-V2";
|
private const string PromptVersionsSummary = "Chapter=Chapter-Structure-Prompt-V2; Scene=Scene-Prompt-V2";
|
||||||
private const string SourceType = "OnboardingWordCompanionChapter";
|
private const string SourceType = "OnboardingWordCompanionChapter";
|
||||||
private const string BookSourceType = "BookCanonicalSceneSource";
|
|
||||||
|
|
||||||
public async Task<StoryIntelligenceOverviewViewModel> GetOverviewAsync()
|
public async Task<StoryIntelligenceOverviewViewModel> GetOverviewAsync()
|
||||||
{
|
{
|
||||||
@ -76,9 +66,9 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
var included = IncludedChapters(preview, review);
|
var included = IncludedChapters(preview, review);
|
||||||
var existingBatch = await batchStore.GetLatestForPreviewAsync(userId, preview.PreviewID);
|
var existingBatch = await batchStore.GetLatestForPreviewAsync(userId, preview.PreviewID);
|
||||||
var missingTextCount = included.Count(chapter => string.IsNullOrWhiteSpace(chapter.Preview.ChapterText));
|
var missingTextCount = included.Count(chapter => string.IsNullOrWhiteSpace(chapter.Preview.ChapterText));
|
||||||
const int storySummaryStep = 10;
|
const int storySummaryStep = 9;
|
||||||
const int storyContextStep = 10;
|
const int storyContextStep = 10;
|
||||||
const int totalWizardSteps = 10;
|
const int totalWizardSteps = 11;
|
||||||
return new StoryIntelligenceOverviewViewModel
|
return new StoryIntelligenceOverviewViewModel
|
||||||
{
|
{
|
||||||
PreviewID = preview.PreviewID,
|
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)
|
public async Task<OnboardingStoryIntelligenceBatch?> StartAsync(string? appearancePreference = null, string? primaryAgeGroup = null, string? storyEra = null, string? storyLocation = null)
|
||||||
{
|
{
|
||||||
var userId = RequireUserId();
|
var userId = RequireUserId();
|
||||||
await entitlement.EnsureCanQueueAsync(userId);
|
|
||||||
var context = await GetContextAsync(userId);
|
var context = await GetContextAsync(userId);
|
||||||
if (context is null)
|
if (context is null)
|
||||||
{
|
{
|
||||||
@ -111,17 +100,14 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (state, preview, review, build, wizard) = context.Value;
|
var (state, preview, review, build, wizard) = context.Value;
|
||||||
var book = await books.GetAsync(preview.BookID);
|
|
||||||
var existing = await batchStore.GetLatestForPreviewAsync(userId, preview.PreviewID);
|
var existing = await batchStore.GetLatestForPreviewAsync(userId, preview.PreviewID);
|
||||||
if (existing is not null)
|
if (existing is not null)
|
||||||
{
|
{
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (build is null || build.SceneMappings.Count == 0)
|
build ??= await onboarding.BuildApprovedStructureAsync(preview.PreviewID)
|
||||||
{
|
?? throw new InvalidOperationException("The approved chapters could not be prepared for analysis.");
|
||||||
throw new InvalidOperationException("Import the manuscript structure before starting Story Intelligence.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var chapterIds = build.ChapterMappings.ToDictionary(chapter => chapter.TemporaryChapterKey, chapter => chapter.ChapterID, StringComparer.Ordinal);
|
var chapterIds = build.ChapterMappings.ToDictionary(chapter => chapter.TemporaryChapterKey, chapter => chapter.ChapterID, StringComparer.Ordinal);
|
||||||
var included = IncludedChapters(preview, review);
|
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.");
|
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 clientStatus = client.GetConfigurationStatus();
|
||||||
var modelSummary = StoryIntelligenceOptions.BuildModelSummary(
|
var modelSummary = StoryIntelligenceOptions.BuildModelSummary(
|
||||||
clientStatus.ChapterStructureModel,
|
clientStatus.ChapterStructureModel,
|
||||||
clientStatus.SceneIntelligenceModel);
|
clientStatus.SceneIntelligenceModel);
|
||||||
var batchItems = new List<OnboardingStoryIntelligenceBatchItem>();
|
var batchItems = new List<OnboardingStoryIntelligenceBatchItem>();
|
||||||
var knownCharactersJson = await BuildCanonicalKnownCharacterContextJsonAsync(preview, review, build);
|
var knownCharactersJson = BuildKnownCharacterContextJson(preview, review);
|
||||||
|
|
||||||
foreach (var chapter in included)
|
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.");
|
throw new InvalidOperationException($"PlotDirector could not prepare chapter '{chapter.Decision.Title}' for analysis.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var durableSource = await sources.GetChapterSourceAsync(chapterId);
|
var text = chapter.Preview.ChapterText!.Trim();
|
||||||
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 paragraphs = StoryIntelligenceParagraphs.Split(text);
|
var paragraphs = StoryIntelligenceParagraphs.Split(text);
|
||||||
var canonicalChapterTitle = CanonicalChapterTitle(await chapters.GetAsync(chapterId), chapter.Decision.ChapterNumber);
|
|
||||||
var runId = await runs.QueueAdminTextAsync(new StoryIntelligenceRunQueueRequest
|
var runId = await runs.QueueAdminTextAsync(new StoryIntelligenceRunQueueRequest
|
||||||
{
|
{
|
||||||
UserID = userId,
|
UserID = userId,
|
||||||
@ -171,11 +150,11 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
ChapterID = chapterId,
|
ChapterID = chapterId,
|
||||||
ChapterNumber = chapter.Decision.ChapterNumber,
|
ChapterNumber = chapter.Decision.ChapterNumber,
|
||||||
SourceType = SourceType,
|
SourceType = SourceType,
|
||||||
SourceLabel = $"{wizard.SelectedBookTitle}: {canonicalChapterTitle}",
|
SourceLabel = $"{wizard.SelectedBookTitle}: {chapter.Decision.Title}",
|
||||||
ChapterText = text,
|
ChapterText = text,
|
||||||
SourceFileName = preview.DocumentTitle,
|
SourceFileName = preview.DocumentTitle,
|
||||||
SourceWordCount = durableSource?.SourceWordCount ?? CountWords(text),
|
SourceWordCount = CountWords(text),
|
||||||
SourceCharacterCount = durableSource?.SourceCharacterCount ?? text.Length,
|
SourceCharacterCount = text.Length,
|
||||||
SourceParagraphCount = paragraphs.Count,
|
SourceParagraphCount = paragraphs.Count,
|
||||||
SourceChapterCount = 1,
|
SourceChapterCount = 1,
|
||||||
Model = modelSummary,
|
Model = modelSummary,
|
||||||
@ -187,7 +166,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
{
|
{
|
||||||
TemporaryChapterKey = chapter.Preview.TemporaryChapterKey,
|
TemporaryChapterKey = chapter.Preview.TemporaryChapterKey,
|
||||||
ChapterNumber = chapter.Decision.ChapterNumber,
|
ChapterNumber = chapter.Decision.ChapterNumber,
|
||||||
ChapterTitle = canonicalChapterTitle,
|
ChapterTitle = chapter.Decision.Title,
|
||||||
ChapterID = chapterId,
|
ChapterID = chapterId,
|
||||||
RunID = runId
|
RunID = runId
|
||||||
});
|
});
|
||||||
@ -222,12 +201,8 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
BookTitle = wizard.SelectedBookTitle,
|
BookTitle = wizard.SelectedBookTitle,
|
||||||
AppearancePreference = NormaliseAppearancePreference(appearancePreference),
|
AppearancePreference = NormaliseAppearancePreference(appearancePreference),
|
||||||
PrimaryAgeGroup = CleanContext(primaryAgeGroup),
|
PrimaryAgeGroup = CleanContext(primaryAgeGroup),
|
||||||
StoryEra = CleanContext(storyEra) is { Length: > 0 } suppliedEra
|
StoryEra = CleanContext(storyEra),
|
||||||
? suppliedEra
|
StoryLocation = CleanContext(storyLocation),
|
||||||
: CleanContext(BookContextOptions.StoryEraLabel(book?.StoryEra)),
|
|
||||||
StoryLocation = CleanContext(storyLocation) is { Length: > 0 } suppliedLocation
|
|
||||||
? suppliedLocation
|
|
||||||
: CleanContext(BookContextOptions.PrimaryLocaleLabel(book?.PrimaryLocaleCode)),
|
|
||||||
Items = batchItems
|
Items = batchItems
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -253,107 +228,6 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
return batch;
|
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)
|
private static string NormaliseAppearancePreference(string? value)
|
||||||
=> value?.Trim() switch
|
=> value?.Trim() switch
|
||||||
{
|
{
|
||||||
@ -372,106 +246,6 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
return string.IsNullOrWhiteSpace(clean) ? string.Empty : clean[..Math.Min(clean.Length, 160)];
|
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)
|
public static string? BuildKnownCharacterContextJson(ManuscriptScanPreview preview, ManuscriptScanReviewDecision review)
|
||||||
{
|
{
|
||||||
var candidates = preview.CharacterCandidates.ToDictionary(candidate => candidate.TemporaryCharacterKey, StringComparer.Ordinal);
|
var candidates = preview.CharacterCandidates.ToDictionary(candidate => candidate.TemporaryCharacterKey, StringComparer.Ordinal);
|
||||||
@ -589,17 +363,6 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
? await commits.BuildConfirmationAsync(item.RunID)
|
? await commits.BuildConfirmationAsync(item.RunID)
|
||||||
: null;
|
: null;
|
||||||
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
|
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
|
chapters.Add(new StoryIntelligenceOnboardingChapterViewModel
|
||||||
{
|
{
|
||||||
@ -622,10 +385,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
EstimatedCostGBP = run.EstimatedCostGBP,
|
EstimatedCostGBP = run.EstimatedCostGBP,
|
||||||
FailureStage = run.FailureStage,
|
FailureStage = run.FailureStage,
|
||||||
ErrorMessage = run.ErrorMessage,
|
ErrorMessage = run.ErrorMessage,
|
||||||
CurrentSceneID = currentScene?.SceneID,
|
LatestSceneSummary = LatestSceneSummary(sceneResults),
|
||||||
CurrentSceneNumber = currentSceneNumber,
|
|
||||||
CurrentSceneDisplayTitle = currentSceneDisplayTitle,
|
|
||||||
LatestSceneSummary = currentScene is null ? LatestSceneSummary(sceneResults) : canonicalSceneSummary,
|
|
||||||
HasCompletedCommit = commit is not null && string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase),
|
HasCompletedCommit = commit is not null && string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase),
|
||||||
CanCommit = confirmation?.CanCommit == true,
|
CanCommit = confirmation?.CanCommit == true,
|
||||||
ScenesCreated = commit?.ScenesCreated ?? 0,
|
ScenesCreated = commit?.ScenesCreated ?? 0,
|
||||||
@ -643,7 +403,6 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
var assetReview = await assetImport.BuildReviewAsync(batch);
|
var assetReview = await assetImport.BuildReviewAsync(batch);
|
||||||
var relationshipReview = await relationshipImport.BuildReviewAsync(batch);
|
var relationshipReview = await relationshipImport.BuildReviewAsync(batch);
|
||||||
var knowledgeReview = await knowledgeImport.BuildReviewAsync(batch);
|
var knowledgeReview = await knowledgeImport.BuildReviewAsync(batch);
|
||||||
var narrative = await narrativeReview.GetReviewAsync(batch);
|
|
||||||
var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID);
|
var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID);
|
||||||
return new StoryIntelligenceProgressViewModel
|
return new StoryIntelligenceProgressViewModel
|
||||||
{
|
{
|
||||||
@ -660,7 +419,6 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
AssetReview = assetReview,
|
AssetReview = assetReview,
|
||||||
RelationshipReview = relationshipReview,
|
RelationshipReview = relationshipReview,
|
||||||
KnowledgeReview = knowledgeReview,
|
KnowledgeReview = knowledgeReview,
|
||||||
NarrativeReview = narrative,
|
|
||||||
PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel
|
PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel
|
||||||
{
|
{
|
||||||
ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete),
|
ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete),
|
||||||
@ -679,12 +437,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
RelationshipStageComplete = batch.RelationshipStageComplete || relationshipReview.IsComplete,
|
RelationshipStageComplete = batch.RelationshipStageComplete || relationshipReview.IsComplete,
|
||||||
KnowledgeIdentified = knowledgeReview.Candidates.Count + batch.KnowledgeDecisions.Count,
|
KnowledgeIdentified = knowledgeReview.Candidates.Count + batch.KnowledgeDecisions.Count,
|
||||||
KnowledgeCreatedOrLinked = batch.KnowledgeDecisions.Count(decision => decision.CreatedOrLinked),
|
KnowledgeCreatedOrLinked = batch.KnowledgeDecisions.Count(decision => decision.CreatedOrLinked),
|
||||||
KnowledgeStageComplete = batch.KnowledgeStageComplete || knowledgeReview.IsComplete,
|
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
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -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)
|
public async Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId)
|
||||||
{
|
{
|
||||||
var progress = await GetProgressAsync(batchId);
|
var progress = await GetProgressAsync(batchId);
|
||||||
@ -975,7 +709,6 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var chapterLookup = (await chapters.ListByBookAsync(bookId)).ToDictionary(chapter => chapter.ChapterID);
|
|
||||||
var committedRuns = (await pipelineState.ListCommittedRunsByBookAsync(bookId, userId))
|
var committedRuns = (await pipelineState.ListCommittedRunsByBookAsync(bookId, userId))
|
||||||
.OrderBy(run => run.ChapterNumber)
|
.OrderBy(run => run.ChapterNumber)
|
||||||
.ThenBy(run => run.StoryIntelligenceRunID)
|
.ThenBy(run => run.StoryIntelligenceRunID)
|
||||||
@ -983,7 +716,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
{
|
{
|
||||||
TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID}",
|
TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID}",
|
||||||
ChapterNumber = Convert.ToInt32(run.ChapterNumber),
|
ChapterNumber = Convert.ToInt32(run.ChapterNumber),
|
||||||
ChapterTitle = CanonicalChapterTitle(chapterLookup.GetValueOrDefault(run.ChapterID), run.ChapterNumber),
|
ChapterTitle = run.ChapterTitle,
|
||||||
ChapterID = run.ChapterID,
|
ChapterID = run.ChapterID,
|
||||||
RunID = run.StoryIntelligenceRunID
|
RunID = run.StoryIntelligenceRunID
|
||||||
})
|
})
|
||||||
@ -998,7 +731,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
{
|
{
|
||||||
TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID ?? run.StoryIntelligenceRunID}",
|
TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID ?? run.StoryIntelligenceRunID}",
|
||||||
ChapterNumber = Convert.ToInt32(run.ChapterNumber ?? 0),
|
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,
|
ChapterID = run.ChapterID ?? 0,
|
||||||
RunID = run.StoryIntelligenceRunID
|
RunID = run.StoryIntelligenceRunID
|
||||||
})
|
})
|
||||||
@ -1030,8 +763,6 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
or StoryIntelligencePipelineStages.RelationshipImport
|
or StoryIntelligencePipelineStages.RelationshipImport
|
||||||
or StoryIntelligencePipelineStages.KnowledgeReview
|
or StoryIntelligencePipelineStages.KnowledgeReview
|
||||||
or StoryIntelligencePipelineStages.KnowledgeImport
|
or StoryIntelligencePipelineStages.KnowledgeImport
|
||||||
or StoryIntelligencePipelineStages.NarrativeReview
|
|
||||||
or StoryIntelligencePipelineStages.NarrativeImport
|
|
||||||
or StoryIntelligencePipelineStages.Complete;
|
or StoryIntelligencePipelineStages.Complete;
|
||||||
var locationStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.LocationImport)
|
var locationStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.LocationImport)
|
||||||
|| state.CurrentStage is StoryIntelligencePipelineStages.AssetReview
|
|| state.CurrentStage is StoryIntelligencePipelineStages.AssetReview
|
||||||
@ -1040,39 +771,29 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
or StoryIntelligencePipelineStages.RelationshipImport
|
or StoryIntelligencePipelineStages.RelationshipImport
|
||||||
or StoryIntelligencePipelineStages.KnowledgeReview
|
or StoryIntelligencePipelineStages.KnowledgeReview
|
||||||
or StoryIntelligencePipelineStages.KnowledgeImport
|
or StoryIntelligencePipelineStages.KnowledgeImport
|
||||||
or StoryIntelligencePipelineStages.NarrativeReview
|
|
||||||
or StoryIntelligencePipelineStages.NarrativeImport
|
|
||||||
or StoryIntelligencePipelineStages.Complete;
|
or StoryIntelligencePipelineStages.Complete;
|
||||||
var assetStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.AssetImport)
|
var assetStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.AssetImport)
|
||||||
|| state.CurrentStage is StoryIntelligencePipelineStages.RelationshipReview
|
|| state.CurrentStage is StoryIntelligencePipelineStages.RelationshipReview
|
||||||
or StoryIntelligencePipelineStages.RelationshipImport
|
or StoryIntelligencePipelineStages.RelationshipImport
|
||||||
or StoryIntelligencePipelineStages.KnowledgeReview
|
or StoryIntelligencePipelineStages.KnowledgeReview
|
||||||
or StoryIntelligencePipelineStages.KnowledgeImport
|
or StoryIntelligencePipelineStages.KnowledgeImport
|
||||||
or StoryIntelligencePipelineStages.NarrativeReview
|
|
||||||
or StoryIntelligencePipelineStages.NarrativeImport
|
|
||||||
or StoryIntelligencePipelineStages.Complete;
|
or StoryIntelligencePipelineStages.Complete;
|
||||||
var relationshipStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.RelationshipImport)
|
var relationshipStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.RelationshipImport)
|
||||||
|| state.CurrentStage is StoryIntelligencePipelineStages.KnowledgeReview
|
|| state.CurrentStage is StoryIntelligencePipelineStages.KnowledgeReview
|
||||||
or StoryIntelligencePipelineStages.KnowledgeImport
|
or StoryIntelligencePipelineStages.KnowledgeImport
|
||||||
or StoryIntelligencePipelineStages.NarrativeReview
|
|
||||||
or StoryIntelligencePipelineStages.NarrativeImport
|
|
||||||
or StoryIntelligencePipelineStages.Complete;
|
or StoryIntelligencePipelineStages.Complete;
|
||||||
var knowledgeStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.KnowledgeImport);
|
var knowledgeStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.KnowledgeImport);
|
||||||
var narrativeStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.NarrativeImport);
|
|
||||||
|
|
||||||
batch.CharacterStageComplete = characterStageComplete;
|
batch.CharacterStageComplete = characterStageComplete;
|
||||||
batch.LocationStageComplete = locationStageComplete;
|
batch.LocationStageComplete = locationStageComplete;
|
||||||
batch.AssetStageComplete = assetStageComplete;
|
batch.AssetStageComplete = assetStageComplete;
|
||||||
batch.RelationshipStageComplete = relationshipStageComplete;
|
batch.RelationshipStageComplete = relationshipStageComplete;
|
||||||
batch.KnowledgeStageComplete = knowledgeStageComplete;
|
batch.KnowledgeStageComplete = knowledgeStageComplete;
|
||||||
batch.NarrativeStageComplete = narrativeStageComplete;
|
|
||||||
|
|
||||||
await batchStore.SaveAsync(batch);
|
await batchStore.SaveAsync(batch);
|
||||||
|
|
||||||
var route = narrativeStageComplete
|
var route = knowledgeStageComplete
|
||||||
? StoryIntelligenceResumeRoutes.Complete
|
? StoryIntelligenceResumeRoutes.Complete
|
||||||
: knowledgeStageComplete
|
|
||||||
? StoryIntelligenceResumeRoutes.Narrative
|
|
||||||
: relationshipStageComplete
|
: relationshipStageComplete
|
||||||
? StoryIntelligenceResumeRoutes.Knowledge
|
? StoryIntelligenceResumeRoutes.Knowledge
|
||||||
: assetStageComplete
|
: assetStageComplete
|
||||||
@ -1087,8 +808,6 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
StoryIntelligencePipelineStages.RelationshipImport => StoryIntelligenceResumeRoutes.Relationships,
|
StoryIntelligencePipelineStages.RelationshipImport => StoryIntelligenceResumeRoutes.Relationships,
|
||||||
StoryIntelligencePipelineStages.KnowledgeReview => StoryIntelligenceResumeRoutes.Knowledge,
|
StoryIntelligencePipelineStages.KnowledgeReview => StoryIntelligenceResumeRoutes.Knowledge,
|
||||||
StoryIntelligencePipelineStages.KnowledgeImport => StoryIntelligenceResumeRoutes.Knowledge,
|
StoryIntelligencePipelineStages.KnowledgeImport => StoryIntelligenceResumeRoutes.Knowledge,
|
||||||
StoryIntelligencePipelineStages.NarrativeReview => StoryIntelligenceResumeRoutes.Narrative,
|
|
||||||
StoryIntelligencePipelineStages.NarrativeImport => StoryIntelligenceResumeRoutes.Narrative,
|
|
||||||
StoryIntelligencePipelineStages.CharacterReview => StoryIntelligenceResumeRoutes.Characters,
|
StoryIntelligencePipelineStages.CharacterReview => StoryIntelligenceResumeRoutes.Characters,
|
||||||
StoryIntelligencePipelineStages.CharacterImport => StoryIntelligenceResumeRoutes.Characters,
|
StoryIntelligencePipelineStages.CharacterImport => StoryIntelligenceResumeRoutes.Characters,
|
||||||
StoryIntelligencePipelineStages.SceneReview => StoryIntelligenceResumeRoutes.SceneReview,
|
StoryIntelligencePipelineStages.SceneReview => StoryIntelligenceResumeRoutes.SceneReview,
|
||||||
@ -1117,7 +836,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var review = await scanStore.GetReviewAsync(userId, preview.PreviewID);
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
@ -1130,19 +849,6 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
private int RequireUserId()
|
private int RequireUserId()
|
||||||
=> currentUser.UserId ?? throw new InvalidOperationException("Sign in before using Story Intelligence.");
|
=> 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)
|
private static IReadOnlyList<IncludedChapter> IncludedChapters(ManuscriptScanPreview preview, ManuscriptScanReviewDecision review)
|
||||||
{
|
{
|
||||||
var chapters = preview.Chapters.ToDictionary(chapter => chapter.TemporaryChapterKey, StringComparer.Ordinal);
|
var chapters = preview.Chapters.ToDictionary(chapter => chapter.TemporaryChapterKey, StringComparer.Ordinal);
|
||||||
@ -1158,53 +864,6 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
? 0
|
? 0
|
||||||
: value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
|
: 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)
|
private static string? LatestSceneSummary(IReadOnlyList<StoryIntelligenceSavedSceneResult> sceneResults)
|
||||||
{
|
{
|
||||||
foreach (var scene in sceneResults
|
foreach (var scene in sceneResults
|
||||||
@ -1226,14 +885,12 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
.OrderBy(scene => scene.TemporarySceneNumber)
|
.OrderBy(scene => scene.TemporarySceneNumber)
|
||||||
.Select(scene => new StoryIntelligenceOnboardingScenePreviewViewModel
|
.Select(scene => new StoryIntelligenceOnboardingScenePreviewViewModel
|
||||||
{
|
{
|
||||||
SceneID = scene.SceneID,
|
|
||||||
SceneNumber = scene.TemporarySceneNumber,
|
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,
|
Pov = scene.PovCharacterID.HasValue ? FirstConfigured(scene.ParsedScene.PointOfView?.CharacterName, scene.ParsedScene.PointOfView?.NarrativeMode) : null,
|
||||||
Setting = BuildSetting(scene.ParsedScene.Setting),
|
Setting = BuildSetting(scene.ParsedScene.Setting),
|
||||||
Confidence = scene.ParsedScene.Setting?.Confidence ?? scene.ParsedScene.PointOfView?.Confidence,
|
Confidence = scene.ParsedScene.Summary?.Confidence ?? scene.ParsedScene.Setting?.Confidence,
|
||||||
HasWarnings = scene.HasWarnings,
|
HasWarnings = false
|
||||||
AnalysisStatus = scene.AnalysisStatus
|
|
||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
@ -1407,13 +1064,11 @@ public sealed class OnboardingStoryIntelligenceBatch
|
|||||||
public StoryIntelligenceAssetImportBatchResult? LastAssetImportResult { get; set; }
|
public StoryIntelligenceAssetImportBatchResult? LastAssetImportResult { get; set; }
|
||||||
public StoryIntelligenceRelationshipImportBatchResult? LastRelationshipImportResult { get; set; }
|
public StoryIntelligenceRelationshipImportBatchResult? LastRelationshipImportResult { get; set; }
|
||||||
public StoryIntelligenceKnowledgeImportBatchResult? LastKnowledgeImportResult { get; set; }
|
public StoryIntelligenceKnowledgeImportBatchResult? LastKnowledgeImportResult { get; set; }
|
||||||
public StoryIntelligenceNarrativeImportBatchResult? LastNarrativeImportResult { get; set; }
|
|
||||||
public bool CharacterStageComplete { get; set; }
|
public bool CharacterStageComplete { get; set; }
|
||||||
public bool LocationStageComplete { get; set; }
|
public bool LocationStageComplete { get; set; }
|
||||||
public bool AssetStageComplete { get; set; }
|
public bool AssetStageComplete { get; set; }
|
||||||
public bool RelationshipStageComplete { get; set; }
|
public bool RelationshipStageComplete { get; set; }
|
||||||
public bool KnowledgeStageComplete { get; set; }
|
public bool KnowledgeStageComplete { get; set; }
|
||||||
public bool NarrativeStageComplete { get; set; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class OnboardingStoryIntelligenceBatchItem
|
public sealed class OnboardingStoryIntelligenceBatchItem
|
||||||
@ -1528,15 +1183,6 @@ public sealed class StoryIntelligenceKnowledgeImportBatchResult
|
|||||||
public int KnowledgeIgnored { get; init; }
|
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 static class StoryIntelligenceResumeRoutes
|
||||||
{
|
{
|
||||||
public const string SceneReview = "SceneReview";
|
public const string SceneReview = "SceneReview";
|
||||||
@ -1545,7 +1191,6 @@ public static class StoryIntelligenceResumeRoutes
|
|||||||
public const string Assets = "Assets";
|
public const string Assets = "Assets";
|
||||||
public const string Relationships = "Relationships";
|
public const string Relationships = "Relationships";
|
||||||
public const string Knowledge = "Knowledge";
|
public const string Knowledge = "Knowledge";
|
||||||
public const string Narrative = "Narrative";
|
|
||||||
public const string Complete = "Complete";
|
public const string Complete = "Complete";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -320,7 +320,6 @@ public sealed class StoryIntelligenceAssetImportService(
|
|||||||
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID))
|
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID))
|
||||||
.Where(scene => scene.ImportRunID == item.RunID)
|
.Where(scene => scene.ImportRunID == item.RunID)
|
||||||
.ToList();
|
.ToList();
|
||||||
var importedById = importedScenes.ToDictionary(scene => scene.SceneID, scene => scene);
|
|
||||||
var importedByRange = importedScenes
|
var importedByRange = importedScenes
|
||||||
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
|
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
|
||||||
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
|
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
|
||||||
@ -335,7 +334,7 @@ public sealed class StoryIntelligenceAssetImportService(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var importedScene = ResolveImportedScene(sceneResult, importedById, importedByRange, importedByNumber);
|
var importedScene = ResolveImportedScene(sceneResult, importedByRange, importedByNumber);
|
||||||
if (importedScene is null)
|
if (importedScene is null)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
@ -493,15 +492,9 @@ public sealed class StoryIntelligenceAssetImportService(
|
|||||||
|
|
||||||
private static Scene? ResolveImportedScene(
|
private static Scene? ResolveImportedScene(
|
||||||
StoryIntelligenceSavedSceneResult sceneResult,
|
StoryIntelligenceSavedSceneResult sceneResult,
|
||||||
IReadOnlyDictionary<int, Scene> byId,
|
|
||||||
IReadOnlyDictionary<string, Scene> byRange,
|
IReadOnlyDictionary<string, Scene> byRange,
|
||||||
IReadOnlyDictionary<int, Scene> byNumber)
|
IReadOnlyDictionary<int, Scene> byNumber)
|
||||||
{
|
{
|
||||||
if (sceneResult.SceneID.HasValue && byId.TryGetValue(sceneResult.SceneID.Value, out var idMatch))
|
|
||||||
{
|
|
||||||
return idMatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sceneResult.StartParagraph.HasValue
|
if (sceneResult.StartParagraph.HasValue
|
||||||
&& sceneResult.EndParagraph.HasValue
|
&& sceneResult.EndParagraph.HasValue
|
||||||
&& byRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var rangeMatch))
|
&& 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)
|
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)
|
return string.IsNullOrWhiteSpace(summary)
|
||||||
? $"{BuildSceneReference(scene)}: {assetName}"
|
? $"Scene {scene.SceneNumber:g}: {assetName}"
|
||||||
: $"{BuildSceneReference(scene)}: {summary}";
|
: $"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)
|
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));
|
batch.AssetDecisions.RemoveAll(decision => string.Equals(decision.Key, key, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|||||||
@ -17,9 +17,6 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
ICharacterRepository characters,
|
ICharacterRepository characters,
|
||||||
ISceneRepository scenes,
|
ISceneRepository scenes,
|
||||||
IStoryIntelligencePipelineStateService pipelineState,
|
IStoryIntelligencePipelineStateService pipelineState,
|
||||||
INameIntelligenceService nameIntelligence,
|
|
||||||
ICoreImportCharacterMappingService coreImportCharacterMapping,
|
|
||||||
ICurrentUserService currentUser,
|
|
||||||
ILogger<StoryIntelligenceCharacterImportService> logger) : IStoryIntelligenceCharacterImportService
|
ILogger<StoryIntelligenceCharacterImportService> logger) : IStoryIntelligenceCharacterImportService
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
@ -73,112 +70,27 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
var data = await BuildCandidateDataAsync(batch);
|
var data = await BuildCandidateDataAsync(batch);
|
||||||
var decidedKeys = batch.CharacterDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
var decidedKeys = batch.CharacterDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList();
|
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
|
return new StoryIntelligenceCharacterReviewViewModel
|
||||||
{
|
{
|
||||||
HasCommittedScenes = data.HasCommittedScenes,
|
HasCommittedScenes = data.HasCommittedScenes,
|
||||||
CanImport = visibleCandidates.Count > 0,
|
CanImport = visibleCandidates.Count > 0,
|
||||||
AlreadyLinkedCount = data.AlreadyLinkedCount,
|
AlreadyLinkedCount = data.AlreadyLinkedCount,
|
||||||
IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
|
IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
|
||||||
GenericReferenceCount = classified.Count(item => item.Classification == StoryIntelligenceCharacterClassifications.GenericReference),
|
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceCharacterReviewCandidateViewModel
|
||||||
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
|
|
||||||
{
|
{
|
||||||
DefaultAction = StoryIntelligenceCharacterReviewDefaults.DetermineAction(item.Candidate.DisplayName, item.Candidate.ExistingCharacterID, item.Classification),
|
Key = candidate.Key,
|
||||||
Classification = item.Classification,
|
CharacterName = candidate.DisplayName,
|
||||||
Key = item.Candidate.Key,
|
ImportName = candidate.DisplayName,
|
||||||
CharacterName = item.Candidate.DisplayName,
|
AppearsInScenes = candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Select(appearance => appearance.SceneID).Distinct().Count(),
|
||||||
ImportName = item.Candidate.DisplayName,
|
Confidence = ConfidenceBand(candidate.Confidence),
|
||||||
AppearsInScenes = item.Candidate.PresentAppearances.Concat(item.Candidate.MentionedOnlyAppearances).Select(appearance => appearance.SceneID).Distinct().Count(),
|
PossibleAliases = candidate.Aliases.OrderBy(value => value).ToList(),
|
||||||
Confidence = ConfidenceBand(item.Candidate.Confidence),
|
ExampleFirstAppearance = candidate.FirstAppearanceNote,
|
||||||
PossibleAliases = item.Candidate.Aliases.OrderBy(value => value).ToList(),
|
ExistingCharacterID = candidate.ExistingCharacterID,
|
||||||
ExampleFirstAppearance = item.Candidate.FirstAppearanceNote,
|
ExistingCharacterName = candidate.ExistingCharacterName
|
||||||
ExistingCharacterID = item.Candidate.ExistingCharacterID,
|
|
||||||
ExistingCharacterName = item.Candidate.ExistingCharacterName,
|
|
||||||
SelectedExistingCharacterID = item.Candidate.ExistingCharacterID,
|
|
||||||
ExistingCharacterOptions = existingOptions,
|
|
||||||
NameIntelligenceWarning = nameWarnings.GetValueOrDefault(item.Candidate.Key)
|
|
||||||
}).ToList()
|
}).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)
|
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceCharacterImportForm form)
|
||||||
{
|
{
|
||||||
var data = await BuildCandidateDataAsync(batch);
|
var data = await BuildCandidateDataAsync(batch);
|
||||||
@ -232,8 +144,7 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.DisplayName : requestedName;
|
var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.DisplayName : requestedName;
|
||||||
var linkExisting = string.Equals(choice.Action, StoryIntelligenceCharacterImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase);
|
var linkExisting = string.Equals(choice.Action, StoryIntelligenceCharacterImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase);
|
||||||
var matchedExistingId = linkExisting
|
var matchedExistingId = linkExisting
|
||||||
? existingIndex.Find(choice.ExistingCharacterID)?.CharacterID
|
? candidate.ExistingCharacterID
|
||||||
?? candidate.ExistingCharacterID
|
|
||||||
?? existingIndex.Find(importName)?.CharacterID
|
?? existingIndex.Find(importName)?.CharacterID
|
||||||
?? existingIndex.Find(candidate.DisplayName)?.CharacterID
|
?? existingIndex.Find(candidate.DisplayName)?.CharacterID
|
||||||
: null;
|
: null;
|
||||||
@ -288,47 +199,29 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
continue;
|
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);
|
var targetKey = Clean(choice.AliasTargetKey);
|
||||||
if (!string.IsNullOrWhiteSpace(targetKey)
|
if (string.IsNullOrWhiteSpace(targetKey)
|
||||||
&& !string.Equals(targetKey, candidate.Key, StringComparison.OrdinalIgnoreCase)
|
|| string.Equals(targetKey, candidate.Key, StringComparison.OrdinalIgnoreCase)
|
||||||
&& choices.TryGetValue(targetKey, out var targetChoice)
|
|| !choices.TryGetValue(targetKey, out var targetChoice)
|
||||||
&& !string.Equals(targetChoice.Action, StoryIntelligenceCharacterImportActions.Ignore, StringComparison.OrdinalIgnoreCase)
|
|| string.Equals(targetChoice.Action, StoryIntelligenceCharacterImportActions.Ignore, StringComparison.OrdinalIgnoreCase)
|
||||||
&& !string.Equals(targetChoice.Action, StoryIntelligenceCharacterImportActions.Alias, StringComparison.OrdinalIgnoreCase)
|
|| string.Equals(targetChoice.Action, StoryIntelligenceCharacterImportActions.Alias, StringComparison.OrdinalIgnoreCase)
|
||||||
&& candidateByKey.TryGetValue(targetKey, out targetCandidate)
|
|| !candidateByKey.TryGetValue(targetKey, out var targetCandidate)
|
||||||
&& resolvedCandidateIds.TryGetValue(targetKey, out var resolvedTargetCharacterId))
|
|| !resolvedCandidateIds.TryGetValue(targetKey, out var targetCharacterId))
|
||||||
{
|
|
||||||
targetCharacterId = resolvedTargetCharacterId;
|
|
||||||
targetName = targetCandidate.ExistingCharacterName ?? targetChoice.ImportName ?? targetCandidate.DisplayName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!targetCharacterId.HasValue)
|
|
||||||
{
|
{
|
||||||
logger.LogWarning(
|
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,
|
candidate.Key,
|
||||||
choice.AliasTargetCharacterID);
|
targetKey);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
targetName ??= $"Character {targetCharacterId.Value:N0}";
|
var targetName = targetCandidate.ExistingCharacterName ?? targetChoice.ImportName ?? targetCandidate.DisplayName;
|
||||||
if (candidate.ExistingCharacterID.HasValue && candidate.ExistingCharacterID.Value != targetCharacterId.Value)
|
await TryAddAliasAsync(targetCharacterId, candidate.DisplayName, targetName);
|
||||||
{
|
AddResolvedName(resolvedCharacters, candidate.DisplayName, targetCharacterId);
|
||||||
await characters.MergeAsync(candidate.ExistingCharacterID.Value, targetCharacterId.Value, currentUser.UserId);
|
|
||||||
}
|
|
||||||
|
|
||||||
await TryAddAliasAsync(targetCharacterId.Value, candidate.DisplayName, targetName);
|
|
||||||
AddResolvedName(resolvedCharacters, candidate.DisplayName, targetCharacterId.Value);
|
|
||||||
foreach (var alias in candidate.Aliases)
|
foreach (var alias in candidate.Aliases)
|
||||||
{
|
{
|
||||||
await TryAddAliasAsync(targetCharacterId.Value, alias, targetName);
|
await TryAddAliasAsync(targetCharacterId, alias, targetName);
|
||||||
AddResolvedName(resolvedCharacters, alias, targetCharacterId.Value);
|
AddResolvedName(resolvedCharacters, alias, targetCharacterId);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var appearance in candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Where(appearance => !appearance.AlreadyLinked))
|
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
|
await characters.SaveSceneCharacterAsync(new SceneCharacter
|
||||||
{
|
{
|
||||||
SceneID = appearance.SceneID,
|
SceneID = appearance.SceneID,
|
||||||
CharacterID = targetCharacterId.Value,
|
CharacterID = targetCharacterId,
|
||||||
RoleInSceneTypeID = MatchRole(roleTypes, appearance.RoleInScene),
|
RoleInSceneTypeID = MatchRole(roleTypes, appearance.RoleInScene),
|
||||||
PresenceTypeID = MatchPresence(presenceTypes, appearance.MentionedOnly),
|
PresenceTypeID = MatchPresence(presenceTypes, appearance.MentionedOnly),
|
||||||
AppearanceNotes = BuildAliasAppearanceNote(candidate.DisplayName, targetName, appearance.Notes)
|
AppearanceNotes = BuildAliasAppearanceNote(candidate.DisplayName, targetCandidate.DisplayName, appearance.Notes)
|
||||||
});
|
});
|
||||||
linkedAppearances++;
|
linkedAppearances++;
|
||||||
}
|
}
|
||||||
|
|
||||||
aliased++;
|
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)
|
foreach (var scene in data.SceneAnalyses)
|
||||||
@ -370,26 +263,23 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
PovLinksResolved = povLinks,
|
PovLinksResolved = povLinks,
|
||||||
ScenePeoplePanelsUpdated = linkedAppearances
|
ScenePeoplePanelsUpdated = linkedAppearances
|
||||||
};
|
};
|
||||||
var remap = await coreImportCharacterMapping.MapBookAsync(batch.ProjectID, batch.BookID);
|
|
||||||
await pipelineState.RecordCharacterImportAsync(batch.ProjectID, batch.BookID);
|
await pipelineState.RecordCharacterImportAsync(batch.ProjectID, batch.BookID);
|
||||||
|
|
||||||
logger.LogInformation(
|
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,
|
batch.BatchID,
|
||||||
created,
|
created,
|
||||||
linkedExisting,
|
linkedExisting,
|
||||||
aliased,
|
aliased,
|
||||||
ignored,
|
ignored,
|
||||||
linkedAppearances,
|
linkedAppearances,
|
||||||
povLinks,
|
povLinks);
|
||||||
remap.ScenesScanned,
|
|
||||||
remap.LinksSaved);
|
|
||||||
|
|
||||||
return new StoryIntelligenceImportCommitResult
|
return new StoryIntelligenceImportCommitResult
|
||||||
{
|
{
|
||||||
Success = true,
|
Success = true,
|
||||||
ScenesCreated = linkedAppearances,
|
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))
|
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID))
|
||||||
.Where(scene => scene.ImportRunID == item.RunID)
|
.Where(scene => scene.ImportRunID == item.RunID)
|
||||||
.ToList();
|
.ToList();
|
||||||
var importedById = importedScenes.ToDictionary(scene => scene.SceneID, scene => scene);
|
|
||||||
var importedByRange = importedScenes
|
var importedByRange = importedScenes
|
||||||
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
|
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
|
||||||
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
|
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
|
||||||
@ -441,7 +330,7 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var importedScene = ResolveImportedScene(sceneResult, importedById, importedByRange, importedByNumber);
|
var importedScene = ResolveImportedScene(sceneResult, importedByRange, importedByNumber);
|
||||||
if (importedScene is null)
|
if (importedScene is null)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
@ -543,13 +432,8 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
AddMention(groups, existingIndex, importedScene, parsed, character, existingAppearances);
|
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);
|
var povName = CleanCharacterName(parsed.PointOfView?.CharacterName);
|
||||||
if (IsCharacterNameCandidate(povName) && !importedScene.POVCharacterID.HasValue)
|
if (IsCharacterNameCandidate(povName))
|
||||||
{
|
{
|
||||||
AddMention(groups, existingIndex, importedScene, parsed, new SceneIntelligenceCharacter
|
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(
|
private static void AddObservedName(
|
||||||
Dictionary<string, CharacterCandidate> groups,
|
Dictionary<string, CharacterCandidate> groups,
|
||||||
CharacterIndex existingIndex,
|
CharacterIndex existingIndex,
|
||||||
@ -625,7 +484,7 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
IReadOnlyList<SceneCharacter> existingAppearances)
|
IReadOnlyList<SceneCharacter> existingAppearances)
|
||||||
{
|
{
|
||||||
var cleanName = CleanCharacterName(name);
|
var cleanName = CleanCharacterName(name);
|
||||||
if (!IsCharacterNameCandidate(cleanName) || IsResolvedPovReference(cleanName, importedScene))
|
if (!IsCharacterNameCandidate(cleanName))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -640,16 +499,11 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
}, existingAppearances);
|
}, 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)
|
private async Task<CharacterIndex> BuildCharacterIndexAsync(int projectId)
|
||||||
{
|
{
|
||||||
var index = new CharacterIndex();
|
var index = new CharacterIndex();
|
||||||
foreach (var character in await characters.ListCharactersAsync(projectId))
|
foreach (var character in await characters.ListCharactersAsync(projectId))
|
||||||
{
|
{
|
||||||
index.AddCharacter(character.CharacterID, character.CharacterName);
|
|
||||||
index.Add(character.CharacterName, character.CharacterID, character.CharacterName);
|
index.Add(character.CharacterName, character.CharacterID, character.CharacterName);
|
||||||
index.Add(FirstName(character.CharacterName), character.CharacterID, character.CharacterName);
|
index.Add(FirstName(character.CharacterName), character.CharacterID, character.CharacterName);
|
||||||
index.Add(character.ShortName, character.CharacterID, character.CharacterName);
|
index.Add(character.ShortName, character.CharacterID, character.CharacterName);
|
||||||
@ -682,15 +536,9 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
|
|
||||||
private static Scene? ResolveImportedScene(
|
private static Scene? ResolveImportedScene(
|
||||||
StoryIntelligenceSavedSceneResult sceneResult,
|
StoryIntelligenceSavedSceneResult sceneResult,
|
||||||
IReadOnlyDictionary<int, Scene> byId,
|
|
||||||
IReadOnlyDictionary<string, Scene> byRange,
|
IReadOnlyDictionary<string, Scene> byRange,
|
||||||
IReadOnlyDictionary<int, Scene> byNumber)
|
IReadOnlyDictionary<int, Scene> byNumber)
|
||||||
{
|
{
|
||||||
if (sceneResult.SceneID.HasValue && byId.TryGetValue(sceneResult.SceneID.Value, out var idMatch))
|
|
||||||
{
|
|
||||||
return idMatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sceneResult.StartParagraph.HasValue
|
if (sceneResult.StartParagraph.HasValue
|
||||||
&& sceneResult.EndParagraph.HasValue
|
&& sceneResult.EndParagraph.HasValue
|
||||||
&& byRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var rangeMatch))
|
&& byRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var rangeMatch))
|
||||||
@ -768,19 +616,13 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
var note = Clean(character.Notes);
|
var note = Clean(character.Notes);
|
||||||
if (!string.IsNullOrWhiteSpace(note))
|
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));
|
var summary = Clean(parsed.Summary?.Short);
|
||||||
return string.IsNullOrWhiteSpace(summary) ? $"{BuildSceneReference(scene)}: {scene.SceneTitle}" : $"{BuildSceneReference(scene)}: {summary}";
|
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)
|
private static decimal? Max(decimal? current, decimal? next)
|
||||||
=> current.HasValue && next.HasValue ? Math.Max(current.Value, next.Value) : current ?? next;
|
=> current.HasValue && next.HasValue ? Math.Max(current.Value, next.Value) : current ?? next;
|
||||||
|
|
||||||
@ -829,39 +671,6 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
return !IsGenericGroupReference(name);
|
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)
|
private static bool IsCharacterNameCandidate(string? name)
|
||||||
{
|
{
|
||||||
var clean = CleanCharacterName(name);
|
var clean = CleanCharacterName(name);
|
||||||
@ -1068,21 +877,6 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
private sealed class CharacterIndex
|
private sealed class CharacterIndex
|
||||||
{
|
{
|
||||||
private readonly Dictionary<string, CharacterMatch> byName = new(StringComparer.OrdinalIgnoreCase);
|
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)
|
public void Add(string? name, int characterId, string characterName)
|
||||||
{
|
{
|
||||||
@ -1090,20 +884,14 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
if (!string.IsNullOrWhiteSpace(key))
|
if (!string.IsNullOrWhiteSpace(key))
|
||||||
{
|
{
|
||||||
byName.TryAdd(key, new CharacterMatch(characterId, characterName));
|
byName.TryAdd(key, new CharacterMatch(characterId, characterName));
|
||||||
rows.Add(new CharacterIndexRow(name!, characterName));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public CharacterMatch? Find(string name)
|
public CharacterMatch? Find(string name)
|
||||||
=> byName.TryGetValue(Normalise(name), out var match) ? match : null;
|
=> 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 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)
|
private sealed class CharacterCandidate(string key, string displayName, int? existingCharacterId, string? existingCharacterName)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -10,21 +10,23 @@ public interface IStoryIntelligenceDevelopmentResetService
|
|||||||
|
|
||||||
public sealed class StoryIntelligenceDevelopmentResetService(
|
public sealed class StoryIntelligenceDevelopmentResetService(
|
||||||
ISqlConnectionFactory connectionFactory,
|
ISqlConnectionFactory connectionFactory,
|
||||||
IProjectRepository projects,
|
|
||||||
ILogger<StoryIntelligenceDevelopmentResetService> logger) : IStoryIntelligenceDevelopmentResetService
|
ILogger<StoryIntelligenceDevelopmentResetService> logger) : IStoryIntelligenceDevelopmentResetService
|
||||||
{
|
{
|
||||||
public async Task<StoryIntelligenceDevelopmentResetResult> ResetOwnedProjectsAndImportsAsync(int userId, CancellationToken cancellationToken = default)
|
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();
|
using var connection = connectionFactory.CreateConnection();
|
||||||
var command = new CommandDefinition(PrepareResetSql, new { UserID = userId }, cancellationToken: cancellationToken, commandTimeout: 120);
|
var command = new CommandDefinition(PrepareResetSql, new { UserID = userId }, cancellationToken: cancellationToken, commandTimeout: 120);
|
||||||
var summary = await connection.QuerySingleAsync<PreparedResetSummary>(command);
|
var summary = await connection.QuerySingleAsync<PreparedResetSummary>(command);
|
||||||
|
|
||||||
foreach (var projectId in summary.ProjectIdList)
|
foreach (var projectId in summary.ProjectIdList)
|
||||||
{
|
{
|
||||||
logger.LogWarning("Admin Story Intelligence reset hard-deleting archived project {ProjectID} for user {UserId}.", projectId, userId);
|
var deleteCommand = new CommandDefinition(
|
||||||
await projects.HardDeleteForOwnerAsync(projectId, userId);
|
"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);
|
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
|
DELETE FROM dbo.StoryMemoryImportPreferences
|
||||||
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryImportPreferences.ImportSessionID);
|
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
|
DELETE FROM dbo.StoryIntelligenceCharacterIllustrationAssignments
|
||||||
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryIntelligenceCharacterIllustrationAssignments.ImportSessionID)
|
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryIntelligenceCharacterIllustrationAssignments.ImportSessionID)
|
||||||
OR EXISTS (SELECT 1 FROM @ProjectIDs p WHERE p.ProjectID = StoryIntelligenceCharacterIllustrationAssignments.ProjectID);
|
OR EXISTS (SELECT 1 FROM @ProjectIDs p WHERE p.ProjectID = StoryIntelligenceCharacterIllustrationAssignments.ProjectID);
|
||||||
END;
|
|
||||||
|
|
||||||
DELETE FROM dbo.StoryIntelligenceIllustrationDemands
|
DELETE FROM dbo.StoryIntelligenceIllustrationDemands
|
||||||
WHERE EXISTS (SELECT 1 FROM @ProjectIDs p WHERE p.ProjectID = StoryIntelligenceIllustrationDemands.ProjectID);
|
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
|
UPDATE dbo.Projects
|
||||||
SET IsArchived = 1,
|
SET IsArchived = 1,
|
||||||
ArchivedDate = COALESCE(ArchivedDate, SYSUTCDATETIME()),
|
ArchivedDate = COALESCE(ArchivedDate, SYSUTCDATETIME()),
|
||||||
|
|||||||
@ -14,7 +14,6 @@ public sealed class StoryIntelligenceExistingChapterQueueService(
|
|||||||
IStoryIntelligenceResultRepository runs,
|
IStoryIntelligenceResultRepository runs,
|
||||||
IStoryIntelligenceClient client,
|
IStoryIntelligenceClient client,
|
||||||
IStoryIntelligencePipelineStateService pipelines,
|
IStoryIntelligencePipelineStateService pipelines,
|
||||||
IStoryIntelligenceEntitlementService entitlement,
|
|
||||||
ILogger<StoryIntelligenceExistingChapterQueueService> logger) : IStoryIntelligenceExistingChapterQueueService
|
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.";
|
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)
|
public async Task<StoryIntelligenceExistingChapterQueueViewModel> QueueAsync(int userId, StoryIntelligenceExistingChapterQueueForm form)
|
||||||
{
|
{
|
||||||
await entitlement.EnsureCanQueueAsync(userId);
|
|
||||||
if (form.ChapterID is not int chapterId)
|
if (form.ChapterID is not int chapterId)
|
||||||
{
|
{
|
||||||
return await BuildViewModelAsync(
|
return await BuildViewModelAsync(
|
||||||
|
|||||||
@ -19,7 +19,6 @@ public sealed class StoryIntelligenceFullChapterTestService(
|
|||||||
IStoryIntelligencePipelineStateService pipelines,
|
IStoryIntelligencePipelineStateService pipelines,
|
||||||
IOptions<StoryIntelligenceOptions> options,
|
IOptions<StoryIntelligenceOptions> options,
|
||||||
IOptions<StoryIntelligencePricingOptions> pricingOptions,
|
IOptions<StoryIntelligencePricingOptions> pricingOptions,
|
||||||
IStoryIntelligenceEntitlementService entitlement,
|
|
||||||
ILogger<StoryIntelligenceFullChapterTestService> logger) : IStoryIntelligenceFullChapterTestService
|
ILogger<StoryIntelligenceFullChapterTestService> logger) : IStoryIntelligenceFullChapterTestService
|
||||||
{
|
{
|
||||||
private const int LargeChapterCharacterThreshold = 40000;
|
private const int LargeChapterCharacterThreshold = 40000;
|
||||||
@ -75,7 +74,6 @@ public sealed class StoryIntelligenceFullChapterTestService(
|
|||||||
|
|
||||||
public async Task<int> QueueAsync(int userId, StoryIntelligenceFullChapterTestForm form, IFormFile? textFile)
|
public async Task<int> QueueAsync(int userId, StoryIntelligenceFullChapterTestForm form, IFormFile? textFile)
|
||||||
{
|
{
|
||||||
await entitlement.EnsureCanQueueAsync(userId);
|
|
||||||
var source = await ReadSourceAsync(form, textFile);
|
var source = await ReadSourceAsync(form, textFile);
|
||||||
if (!string.IsNullOrWhiteSpace(source.ErrorMessage))
|
if (!string.IsNullOrWhiteSpace(source.ErrorMessage))
|
||||||
{
|
{
|
||||||
|
|||||||
@ -154,37 +154,6 @@ public static class StoryIntelligenceIllustrationCompatibility
|
|||||||
FirstMetadata(metadata, "generationSource", "source") ?? string.Empty);
|
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)
|
public static IReadOnlyList<string> CharacterHardRejections(CharacterEvidenceProfile evidence, IllustrationCharacterMetadata candidate, bool isNamedCharacter, bool alreadyAssignedToAnotherSignificantCharacter)
|
||||||
{
|
{
|
||||||
var rejected = new List<string>();
|
var rejected = new List<string>();
|
||||||
@ -476,19 +445,6 @@ public static class StoryIntelligenceIllustrationCompatibility
|
|||||||
|| (requestedType == "Note" && candidateType is "Letter" or "Document")
|
|| (requestedType == "Note" && candidateType is "Letter" or "Document")
|
||||||
|| (requestedType == "Rucksack" && candidateType == "Rucksack");
|
|| (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)
|
public static bool IsGroupEntity(string? value)
|
||||||
{
|
{
|
||||||
var clean = (value ?? string.Empty).Trim().ToLowerInvariant();
|
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)
|
private static string? FirstMetadata(Dictionary<string, string>? metadata, params string[] keys)
|
||||||
=> keys.Select(key => metadata?.GetValueOrDefault(key)).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
|
=> 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)
|
private static bool BoolMetadata(Dictionary<string, string>? metadata, string key)
|
||||||
=> bool.TryParse(metadata?.GetValueOrDefault(key), out var value) && value;
|
=> bool.TryParse(metadata?.GetValueOrDefault(key), out var value) && value;
|
||||||
|
|
||||||
@ -868,8 +821,3 @@ public sealed record IllustrationCharacterMetadata(
|
|||||||
bool IsSuitableForNamedCharacter,
|
bool IsSuitableForNamedCharacter,
|
||||||
string? PromptTemplateVersion,
|
string? PromptTemplateVersion,
|
||||||
string GenerationSource);
|
string GenerationSource);
|
||||||
|
|
||||||
public sealed record IllustrationLocationMetadata(
|
|
||||||
string BaseType,
|
|
||||||
bool IsFallbackClassification,
|
|
||||||
string ReuseScope);
|
|
||||||
|
|||||||
1126
PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs
Normal file
1126
PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs
Normal file
File diff suppressed because it is too large
Load Diff
@ -134,19 +134,22 @@ public sealed class StoryIntelligenceImportCommitService(
|
|||||||
blockers.Add("No saved scene results are available to import.");
|
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))
|
if (existingCommit is not null && string.Equals(existingCommit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
blockers.Add("This Story Intelligence run has already been committed.");
|
blockers.Add("This Story Intelligence run has already been committed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var lookupData = await lookups.GetAllAsync();
|
var lookupData = await lookups.GetAllAsync();
|
||||||
if (run.ProjectID.HasValue)
|
|
||||||
{
|
|
||||||
await metricTypes.AddMissingDefaultsAsync(run.ProjectID.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
var activeMetricTypes = run.ProjectID.HasValue
|
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 noteTypes = await writerWorkspace.ListNoteTypesAsync();
|
||||||
var characterMap = run.ProjectID.HasValue
|
var characterMap = run.ProjectID.HasValue
|
||||||
@ -169,18 +172,13 @@ public sealed class StoryIntelligenceImportCommitService(
|
|||||||
var parsedChapter = TryReadChapter(chapterResult);
|
var parsedChapter = TryReadChapter(chapterResult);
|
||||||
var chapterSummary = Clean(parsedChapter?.ChapterSummary);
|
var chapterSummary = Clean(parsedChapter?.ChapterSummary);
|
||||||
var chapterPurposeId = MatchChapterPurpose(parsedChapter, chapterPurposeMap);
|
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>();
|
var importScenes = new List<StoryIntelligenceSceneImportItem>();
|
||||||
foreach (var sceneResult in sceneResults.OrderBy(scene => scene.TemporarySceneNumber))
|
foreach (var sceneResult in sceneResults.OrderBy(scene => scene.TemporarySceneNumber))
|
||||||
{
|
{
|
||||||
canonicalById.TryGetValue(sceneResult.SceneID ?? 0, out var canonicalScene);
|
|
||||||
var parsed = TryReadScene(sceneResult);
|
var parsed = TryReadScene(sceneResult);
|
||||||
if (parsed is null)
|
if (parsed is null)
|
||||||
{
|
{
|
||||||
var fallback = BuildFallbackImportScene(sceneResult, metricMap, canonicalScene);
|
var fallback = BuildFallbackImportScene(sceneResult, metricMap);
|
||||||
if (fallback is null)
|
if (fallback is null)
|
||||||
{
|
{
|
||||||
warnings.Add($"Scene {sceneResult.TemporarySceneNumber:N0} could not be prepared automatically and needs review.");
|
warnings.Add($"Scene {sceneResult.TemporarySceneNumber:N0} could not be prepared automatically and needs review.");
|
||||||
@ -192,7 +190,7 @@ public sealed class StoryIntelligenceImportCommitService(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
importScenes.Add(BuildImportScene(sceneResult, parsed, characterMap, purposeMap, metricMap, canonicalScene));
|
importScenes.Add(BuildImportScene(sceneResult, parsed, characterMap, purposeMap, metricMap));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (importScenes.Count == 0)
|
if (importScenes.Count == 0)
|
||||||
@ -200,14 +198,6 @@ public sealed class StoryIntelligenceImportCommitService(
|
|||||||
blockers.Add("No scenes are ready to create for this chapter.");
|
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 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 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;
|
var chapter = run.ChapterID.HasValue ? await chapters.GetAsync(run.ChapterID.Value) : null;
|
||||||
@ -228,12 +218,12 @@ public sealed class StoryIntelligenceImportCommitService(
|
|||||||
ChapterLabel = chapterLabel,
|
ChapterLabel = chapterLabel,
|
||||||
ExistingSceneCount = readiness?.ActiveSceneCount ?? 0,
|
ExistingSceneCount = readiness?.ActiveSceneCount ?? 0,
|
||||||
CommitMode = readiness?.CommitMode ?? string.Empty,
|
CommitMode = readiness?.CommitMode ?? string.Empty,
|
||||||
ScenesToCreate = canonicalScenes.Count > 0 ? canonicalScenes.Count : importScenes.Count,
|
ScenesToCreate = importScenes.Count,
|
||||||
MetricsToImport = importScenes.Sum(scene => scene.Metrics.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(),
|
MetricNames = importScenes.SelectMany(scene => scene.Metrics.Select(metric => metric.MetricName)).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(name => name).ToList(),
|
||||||
Warnings = warnings,
|
Warnings = warnings,
|
||||||
Blockers = blockers,
|
Blockers = blockers,
|
||||||
Scenes = BuildReviewScenes(canonicalScenes, sceneResults, importScenes)
|
Scenes = importScenes
|
||||||
},
|
},
|
||||||
importScenes,
|
importScenes,
|
||||||
timeModeId,
|
timeModeId,
|
||||||
@ -249,8 +239,7 @@ public sealed class StoryIntelligenceImportCommitService(
|
|||||||
SceneIntelligenceScene parsed,
|
SceneIntelligenceScene parsed,
|
||||||
IReadOnlyDictionary<string, int> characterMap,
|
IReadOnlyDictionary<string, int> characterMap,
|
||||||
IReadOnlyDictionary<string, int> purposeMap,
|
IReadOnlyDictionary<string, int> purposeMap,
|
||||||
IReadOnlyDictionary<string, SceneMetricType> metricMap,
|
IReadOnlyDictionary<string, SceneMetricType> metricMap)
|
||||||
Scene? canonicalScene = null)
|
|
||||||
{
|
{
|
||||||
var povName = Clean(parsed.PointOfView?.CharacterName);
|
var povName = Clean(parsed.PointOfView?.CharacterName);
|
||||||
var povCharacterId = !string.IsNullOrWhiteSpace(povName)
|
var povCharacterId = !string.IsNullOrWhiteSpace(povName)
|
||||||
@ -258,13 +247,12 @@ public sealed class StoryIntelligenceImportCommitService(
|
|||||||
&& characterMap.TryGetValue(povName, out var characterId)
|
&& characterMap.TryGetValue(povName, out var characterId)
|
||||||
? characterId
|
? characterId
|
||||||
: (int?)null;
|
: (int?)null;
|
||||||
var summary = CanonicalSummary(canonicalScene) ?? Clean(parsed.Summary?.Short);
|
var summary = Clean(parsed.Summary?.Short);
|
||||||
var sceneTitle = GenerateTitle(sceneResult.TemporarySceneNumber, summary);
|
var sceneTitle = GenerateTitle(sceneResult.TemporarySceneNumber, summary);
|
||||||
|
|
||||||
return new StoryIntelligenceSceneImportItem
|
return new StoryIntelligenceSceneImportItem
|
||||||
{
|
{
|
||||||
SceneResultID = sceneResult.SceneResultID,
|
SceneResultID = sceneResult.SceneResultID,
|
||||||
SceneID = sceneResult.SceneID,
|
|
||||||
TemporarySceneNumber = sceneResult.TemporarySceneNumber,
|
TemporarySceneNumber = sceneResult.TemporarySceneNumber,
|
||||||
StartParagraph = sceneResult.StartParagraph,
|
StartParagraph = sceneResult.StartParagraph,
|
||||||
EndParagraph = sceneResult.EndParagraph,
|
EndParagraph = sceneResult.EndParagraph,
|
||||||
@ -273,28 +261,24 @@ public sealed class StoryIntelligenceImportCommitService(
|
|||||||
PovCharacterID = povCharacterId,
|
PovCharacterID = povCharacterId,
|
||||||
PurposeTypeIDs = MatchPurposes(parsed, purposeMap),
|
PurposeTypeIDs = MatchPurposes(parsed, purposeMap),
|
||||||
Metrics = BuildMetrics(parsed, metricMap),
|
Metrics = BuildMetrics(parsed, metricMap),
|
||||||
SceneTitle = canonicalScene is not null && !string.IsNullOrWhiteSpace(canonicalScene.SceneTitle) ? canonicalScene.SceneTitle : sceneTitle,
|
SceneTitle = sceneTitle,
|
||||||
Summary = string.IsNullOrWhiteSpace(summary) ? null : summary,
|
Summary = string.IsNullOrWhiteSpace(summary) ? null : summary,
|
||||||
PurposeNotes = BuildPurposeNotes(parsed, sceneResult, povName, povCharacterId),
|
PurposeNotes = BuildPurposeNotes(parsed, sceneResult, povName, povCharacterId),
|
||||||
OutcomeNotes = canonicalScene is null && !string.IsNullOrWhiteSpace(parsed.Summary?.Detailed) ? Clean(parsed.Summary.Detailed) : null,
|
OutcomeNotes = string.IsNullOrWhiteSpace(parsed.Summary?.Detailed) ? null : Clean(parsed.Summary.Detailed),
|
||||||
ImportNoteText = BuildImportNote(parsed, povName, povCharacterId),
|
ImportNoteText = BuildImportNote(parsed, povName, povCharacterId)
|
||||||
AnalysisStatus = sceneResult.ValidationErrorsCount > 0 ? "Needs review" : "Ready",
|
|
||||||
HasWarnings = sceneResult.ValidationWarningsCount > 0 || sceneResult.ValidationErrorsCount > 0
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static StoryIntelligenceSceneImportItem? BuildFallbackImportScene(
|
private static StoryIntelligenceSceneImportItem? BuildFallbackImportScene(
|
||||||
StoryIntelligenceSavedSceneResult sceneResult,
|
StoryIntelligenceSavedSceneResult sceneResult,
|
||||||
IReadOnlyDictionary<string, SceneMetricType> metricMap,
|
IReadOnlyDictionary<string, SceneMetricType> metricMap)
|
||||||
Scene? canonicalScene = null)
|
|
||||||
{
|
{
|
||||||
if (!sceneResult.StartParagraph.HasValue || !sceneResult.EndParagraph.HasValue || sceneResult.StartParagraph > sceneResult.EndParagraph)
|
if (!sceneResult.StartParagraph.HasValue || !sceneResult.EndParagraph.HasValue || sceneResult.StartParagraph > sceneResult.EndParagraph)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var summary = CanonicalSummary(canonicalScene)
|
var summary = ExtractSummaryFromJsonText(sceneResult.OutputTextJson)
|
||||||
?? ExtractSummaryFromJsonText(sceneResult.OutputTextJson)
|
|
||||||
?? ExtractSummaryFromJsonText(sceneResult.ParsedJson)
|
?? ExtractSummaryFromJsonText(sceneResult.ParsedJson)
|
||||||
?? $"Scene {sceneResult.TemporarySceneNumber:N0} from paragraphs {DisplayRange(sceneResult.StartParagraph, sceneResult.EndParagraph)}.";
|
?? $"Scene {sceneResult.TemporarySceneNumber:N0} from paragraphs {DisplayRange(sceneResult.StartParagraph, sceneResult.EndParagraph)}.";
|
||||||
var parsed = new SceneIntelligenceScene
|
var parsed = new SceneIntelligenceScene
|
||||||
@ -324,7 +308,6 @@ public sealed class StoryIntelligenceImportCommitService(
|
|||||||
return new StoryIntelligenceSceneImportItem
|
return new StoryIntelligenceSceneImportItem
|
||||||
{
|
{
|
||||||
SceneResultID = sceneResult.SceneResultID,
|
SceneResultID = sceneResult.SceneResultID,
|
||||||
SceneID = sceneResult.SceneID,
|
|
||||||
TemporarySceneNumber = sceneResult.TemporarySceneNumber,
|
TemporarySceneNumber = sceneResult.TemporarySceneNumber,
|
||||||
StartParagraph = sceneResult.StartParagraph,
|
StartParagraph = sceneResult.StartParagraph,
|
||||||
EndParagraph = sceneResult.EndParagraph,
|
EndParagraph = sceneResult.EndParagraph,
|
||||||
@ -333,82 +316,14 @@ public sealed class StoryIntelligenceImportCommitService(
|
|||||||
PovCharacterID = null,
|
PovCharacterID = null,
|
||||||
PurposeTypeIDs = [],
|
PurposeTypeIDs = [],
|
||||||
Metrics = [],
|
Metrics = [],
|
||||||
SceneTitle = canonicalScene is not null && !string.IsNullOrWhiteSpace(canonicalScene.SceneTitle) ? canonicalScene.SceneTitle : GenerateTitle(sceneResult.TemporarySceneNumber, summary),
|
SceneTitle = GenerateTitle(sceneResult.TemporarySceneNumber, summary),
|
||||||
Summary = 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.",
|
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,
|
OutcomeNotes = null,
|
||||||
ImportNoteText = "Scene created with limited Story Intelligence detail. Review the source manuscript for POV, setting, metrics and timeline notes.",
|
ImportNoteText = "Scene created with limited Story Intelligence detail. Review the source manuscript for POV, setting, metrics and timeline notes."
|
||||||
AnalysisStatus = "Analysis failed",
|
|
||||||
HasWarnings = true
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
private async Task<Dictionary<string, int>> BuildCharacterMapAsync(int projectId)
|
||||||
{
|
{
|
||||||
var map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
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 byName = metricTypes.ToDictionary(metric => Clean(metric.MetricName), metric => metric, StringComparer.OrdinalIgnoreCase);
|
||||||
var map = new Dictionary<string, SceneMetricType>(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)
|
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);
|
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}"
|
||||||
: $"Scene {sceneNumber:N0} - {TrimTo(summary, 60)}";
|
: $"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)
|
private static string TrimTo(string value, int maxLength)
|
||||||
=> value.Length <= maxLength ? value : value[..maxLength].TrimEnd() + "...";
|
=> value.Length <= maxLength ? value : value[..maxLength].TrimEnd() + "...";
|
||||||
|
|
||||||
|
|||||||
@ -16,8 +16,7 @@ public static partial class StoryIntelligenceJsonRepair
|
|||||||
}
|
}
|
||||||
|
|
||||||
warnings.AddRange(extraction.Warnings);
|
warnings.AddRange(extraction.Warnings);
|
||||||
var numericWhitespaceRepaired = RepairWhitespaceInDecimalNumbers(extraction.Json, warnings);
|
var numericRepaired = RepairTrailingDecimalNumbers(extraction.Json, warnings);
|
||||||
var numericRepaired = RepairTrailingDecimalNumbers(numericWhitespaceRepaired, warnings);
|
|
||||||
var slashRepaired = RepairInvalidBackslashesInStrings(numericRepaired, warnings);
|
var slashRepaired = RepairInvalidBackslashesInStrings(numericRepaired, warnings);
|
||||||
|
|
||||||
if (!IsValidJson(slashRepaired, out var error))
|
if (!IsValidJson(slashRepaired, out var error))
|
||||||
@ -134,162 +133,6 @@ public static partial class StoryIntelligenceJsonRepair
|
|||||||
return repaired;
|
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)
|
private static string RepairInvalidBackslashesInStrings(string value, List<string> warnings)
|
||||||
{
|
{
|
||||||
var builder = new StringBuilder(value.Length);
|
var builder = new StringBuilder(value.Length);
|
||||||
@ -397,11 +240,7 @@ public static class StoryIntelligenceAiJsonParser
|
|||||||
var repair = StoryIntelligenceJsonRepair.Repair(rawAssistantOutput);
|
var repair = StoryIntelligenceJsonRepair.Repair(rawAssistantOutput);
|
||||||
if (!repair.Success)
|
if (!repair.Success)
|
||||||
{
|
{
|
||||||
return StoryIntelligenceAiJsonParseResult<T>.Failed(
|
return StoryIntelligenceAiJsonParseResult<T>.Failed(extraction.Json, repair.ErrorMessage ?? strictEx.Message);
|
||||||
extraction.Json,
|
|
||||||
repair.ErrorMessage ?? strictEx.Message,
|
|
||||||
strictEx.Message,
|
|
||||||
strictEx.Path);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
@ -412,13 +251,11 @@ public static class StoryIntelligenceAiJsonParser
|
|||||||
extraction.Json,
|
extraction.Json,
|
||||||
repair.RepairedJson,
|
repair.RepairedJson,
|
||||||
parsed,
|
parsed,
|
||||||
warnings,
|
warnings);
|
||||||
strictEx.Message,
|
|
||||||
strictEx.Path);
|
|
||||||
}
|
}
|
||||||
catch (JsonException repairedEx)
|
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>();
|
var warnings = new List<string>();
|
||||||
foreach (var warning in repairWarnings)
|
foreach (var warning in repairWarnings)
|
||||||
{
|
{
|
||||||
if (warning.Contains("trailing decimal", StringComparison.OrdinalIgnoreCase)
|
if (warning.Contains("trailing decimal", StringComparison.OrdinalIgnoreCase))
|
||||||
|| warning.Contains("whitespace inside decimal", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
{
|
||||||
warnings.Add(string.IsNullOrWhiteSpace(strictErrorPath)
|
warnings.Add(string.IsNullOrWhiteSpace(strictErrorPath)
|
||||||
? "Repaired malformed AI JSON numeric value."
|
? "Repaired malformed AI JSON numeric value."
|
||||||
@ -474,23 +310,15 @@ public sealed record StoryIntelligenceAiJsonParseResult<T>(
|
|||||||
string? RepairedJson,
|
string? RepairedJson,
|
||||||
T? Parsed,
|
T? Parsed,
|
||||||
IReadOnlyList<string> Warnings,
|
IReadOnlyList<string> Warnings,
|
||||||
string? ErrorMessage,
|
string? ErrorMessage)
|
||||||
string? InitialErrorMessage = null,
|
|
||||||
string? InitialErrorPath = null)
|
|
||||||
{
|
{
|
||||||
public static StoryIntelligenceAiJsonParseResult<T> Ok(
|
public static StoryIntelligenceAiJsonParseResult<T> Ok(
|
||||||
string rawJson,
|
string rawJson,
|
||||||
string? repairedJson,
|
string? repairedJson,
|
||||||
T? parsed,
|
T? parsed,
|
||||||
IReadOnlyList<string> warnings,
|
IReadOnlyList<string> warnings)
|
||||||
string? initialErrorMessage = null,
|
=> new(true, rawJson, repairedJson, parsed, warnings, null);
|
||||||
string? initialErrorPath = null)
|
|
||||||
=> new(true, rawJson, repairedJson, parsed, warnings, null, initialErrorMessage, initialErrorPath);
|
|
||||||
|
|
||||||
public static StoryIntelligenceAiJsonParseResult<T> Failed(
|
public static StoryIntelligenceAiJsonParseResult<T> Failed(string rawJson, string errorMessage)
|
||||||
string rawJson,
|
=> new(false, rawJson, null, default, [], errorMessage);
|
||||||
string errorMessage,
|
|
||||||
string? initialErrorMessage = null,
|
|
||||||
string? initialErrorPath = null)
|
|
||||||
=> new(false, rawJson, null, default, [], errorMessage, initialErrorMessage, initialErrorPath);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -79,23 +79,15 @@ public sealed class StoryIntelligenceKnowledgeImportService(
|
|||||||
SaveKnowledgeSelections(batch, choices.Values);
|
SaveKnowledgeSelections(batch, choices.Values);
|
||||||
if (choices.Count == 0)
|
if (choices.Count == 0)
|
||||||
{
|
{
|
||||||
var decidedKeys = batch.KnowledgeDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
if (data.Candidates.Count == 0)
|
||||||
var hasUndecidedCandidates = data.Candidates.Any(candidate => !decidedKeys.Contains(candidate.Key));
|
|
||||||
if (!hasUndecidedCandidates)
|
|
||||||
{
|
{
|
||||||
batch.KnowledgeStageComplete = true;
|
batch.KnowledgeStageComplete = true;
|
||||||
batch.LastKnowledgeImportResult ??= new StoryIntelligenceKnowledgeImportBatchResult
|
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))
|
|
||||||
};
|
|
||||||
await pipelineState.RecordKnowledgeImportAsync(batch.ProjectID, batch.BookID);
|
await pipelineState.RecordKnowledgeImportAsync(batch.ProjectID, batch.BookID);
|
||||||
return new StoryIntelligenceImportCommitResult
|
return new StoryIntelligenceImportCommitResult
|
||||||
{
|
{
|
||||||
Success = true,
|
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))
|
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID))
|
||||||
.Where(scene => scene.ImportRunID == item.RunID)
|
.Where(scene => scene.ImportRunID == item.RunID)
|
||||||
.ToList();
|
.ToList();
|
||||||
var importedById = importedScenes.ToDictionary(scene => scene.SceneID, scene => scene);
|
|
||||||
var importedByRange = importedScenes
|
var importedByRange = importedScenes
|
||||||
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
|
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
|
||||||
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
|
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
|
||||||
@ -252,7 +243,7 @@ public sealed class StoryIntelligenceKnowledgeImportService(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var importedScene = ResolveImportedScene(sceneResult, importedById, importedByRange, importedByNumber);
|
var importedScene = ResolveImportedScene(sceneResult, importedByRange, importedByNumber);
|
||||||
if (importedScene is null)
|
if (importedScene is null)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
@ -419,15 +410,9 @@ public sealed class StoryIntelligenceKnowledgeImportService(
|
|||||||
|
|
||||||
private static Scene? ResolveImportedScene(
|
private static Scene? ResolveImportedScene(
|
||||||
StoryIntelligenceSavedSceneResult sceneResult,
|
StoryIntelligenceSavedSceneResult sceneResult,
|
||||||
IReadOnlyDictionary<int, Scene> byId,
|
|
||||||
IReadOnlyDictionary<string, Scene> byRange,
|
IReadOnlyDictionary<string, Scene> byRange,
|
||||||
IReadOnlyDictionary<int, Scene> byNumber)
|
IReadOnlyDictionary<int, Scene> byNumber)
|
||||||
{
|
{
|
||||||
if (sceneResult.SceneID.HasValue && byId.TryGetValue(sceneResult.SceneID.Value, out var idMatch))
|
|
||||||
{
|
|
||||||
return idMatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sceneResult.StartParagraph.HasValue
|
if (sceneResult.StartParagraph.HasValue
|
||||||
&& sceneResult.EndParagraph.HasValue
|
&& sceneResult.EndParagraph.HasValue
|
||||||
&& byRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var rangeMatch))
|
&& 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)
|
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)
|
return string.IsNullOrWhiteSpace(summary)
|
||||||
? BuildSceneReference(scene)
|
? BuildSceneReference(scene)
|
||||||
: $"{BuildSceneReference(scene)}: {summary}";
|
: $"{BuildSceneReference(scene)}: {summary}";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string BuildSceneReference(Scene scene)
|
private static string BuildSceneReference(Scene scene)
|
||||||
=> $"Chapter {scene.ChapterNumber:g}, Scene {scene.SceneNumber:g}";
|
=> $"Scene {scene.SceneNumber:g}";
|
||||||
|
|
||||||
private static bool IsCharacterEntity(string? entityType)
|
private static bool IsCharacterEntity(string? entityType)
|
||||||
=> string.Equals(Clean(entityType), "Character", StringComparison.OrdinalIgnoreCase);
|
=> string.Equals(Clean(entityType), "Character", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|||||||
@ -276,7 +276,6 @@ public sealed class StoryIntelligenceLocationImportService(
|
|||||||
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID))
|
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID))
|
||||||
.Where(scene => scene.ImportRunID == item.RunID)
|
.Where(scene => scene.ImportRunID == item.RunID)
|
||||||
.ToList();
|
.ToList();
|
||||||
var importedById = importedScenes.ToDictionary(scene => scene.SceneID, scene => scene);
|
|
||||||
var importedByRange = importedScenes
|
var importedByRange = importedScenes
|
||||||
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
|
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
|
||||||
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
|
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
|
||||||
@ -291,7 +290,7 @@ public sealed class StoryIntelligenceLocationImportService(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var importedScene = ResolveImportedScene(sceneResult, importedById, importedByRange, importedByNumber);
|
var importedScene = ResolveImportedScene(sceneResult, importedByRange, importedByNumber);
|
||||||
if (importedScene is null)
|
if (importedScene is null)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
@ -435,15 +434,9 @@ public sealed class StoryIntelligenceLocationImportService(
|
|||||||
|
|
||||||
private static Scene? ResolveImportedScene(
|
private static Scene? ResolveImportedScene(
|
||||||
StoryIntelligenceSavedSceneResult sceneResult,
|
StoryIntelligenceSavedSceneResult sceneResult,
|
||||||
IReadOnlyDictionary<int, Scene> byId,
|
|
||||||
IReadOnlyDictionary<string, Scene> byRange,
|
IReadOnlyDictionary<string, Scene> byRange,
|
||||||
IReadOnlyDictionary<int, Scene> byNumber)
|
IReadOnlyDictionary<int, Scene> byNumber)
|
||||||
{
|
{
|
||||||
if (sceneResult.SceneID.HasValue && byId.TryGetValue(sceneResult.SceneID.Value, out var idMatch))
|
|
||||||
{
|
|
||||||
return idMatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sceneResult.StartParagraph.HasValue
|
if (sceneResult.StartParagraph.HasValue
|
||||||
&& sceneResult.EndParagraph.HasValue
|
&& sceneResult.EndParagraph.HasValue
|
||||||
&& byRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var rangeMatch))
|
&& 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)
|
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)
|
return string.IsNullOrWhiteSpace(summary)
|
||||||
? $"{BuildSceneReference(scene)}: {locationName}"
|
? $"Scene {scene.SceneNumber:g}: {locationName}"
|
||||||
: $"{BuildSceneReference(scene)}: {summary}";
|
: $"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)
|
private static string FirstConfigured(params string?[] values)
|
||||||
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty;
|
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty;
|
||||||
|
|
||||||
|
|||||||
@ -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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -711,15 +711,7 @@ public sealed class ChapterStructureDryRunService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static ChapterStructureModel? DeserializeChapter(string chapterJson)
|
private static ChapterStructureModel? DeserializeChapter(string chapterJson)
|
||||||
{
|
=> JsonSerializer.Deserialize<ChapterStructureModel>(chapterJson, JsonOptions);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatDuration(TimeSpan duration)
|
private static string FormatDuration(TimeSpan duration)
|
||||||
=> duration.TotalSeconds < 1
|
=> duration.TotalSeconds < 1
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -15,7 +15,6 @@ public interface IStoryIntelligencePipelineStateService
|
|||||||
Task RecordAssetImportAsync(int projectId, int bookId);
|
Task RecordAssetImportAsync(int projectId, int bookId);
|
||||||
Task RecordRelationshipImportAsync(int projectId, int bookId);
|
Task RecordRelationshipImportAsync(int projectId, int bookId);
|
||||||
Task RecordKnowledgeImportAsync(int projectId, int bookId);
|
Task RecordKnowledgeImportAsync(int projectId, int bookId);
|
||||||
Task RecordNarrativeImportAsync(int projectId, int bookId);
|
|
||||||
Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId);
|
Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -156,28 +155,13 @@ public sealed class StoryIntelligencePipelineStateService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task RecordKnowledgeImportAsync(int projectId, int bookId)
|
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
|
await pipelines.UpsertAsync(new StoryIntelligenceBookPipelineSaveRequest
|
||||||
{
|
{
|
||||||
ProjectID = projectId,
|
ProjectID = projectId,
|
||||||
BookID = bookId,
|
BookID = bookId,
|
||||||
CurrentStage = StoryIntelligencePipelineStages.Complete,
|
CurrentStage = StoryIntelligencePipelineStages.Complete,
|
||||||
LastCompletedStage = StoryIntelligencePipelineStages.NarrativeImport,
|
LastCompletedStage = StoryIntelligencePipelineStages.KnowledgeImport,
|
||||||
CurrentReviewStage = null,
|
CurrentReviewStage = null,
|
||||||
Status = StoryIntelligencePipelineStatuses.Complete,
|
Status = StoryIntelligencePipelineStatuses.Complete,
|
||||||
CompletedUtc = DateTime.UtcNow,
|
CompletedUtc = DateTime.UtcNow,
|
||||||
|
|||||||
@ -270,7 +270,6 @@ public sealed class StoryIntelligenceRelationshipImportService(
|
|||||||
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID))
|
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID))
|
||||||
.Where(scene => scene.ImportRunID == item.RunID)
|
.Where(scene => scene.ImportRunID == item.RunID)
|
||||||
.ToList();
|
.ToList();
|
||||||
var importedById = importedScenes.ToDictionary(scene => scene.SceneID, scene => scene);
|
|
||||||
var importedByRange = importedScenes
|
var importedByRange = importedScenes
|
||||||
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
|
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
|
||||||
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
|
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
|
||||||
@ -285,7 +284,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var importedScene = ResolveImportedScene(sceneResult, importedById, importedByRange, importedByNumber);
|
var importedScene = ResolveImportedScene(sceneResult, importedByRange, importedByNumber);
|
||||||
if (importedScene is null)
|
if (importedScene is null)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
@ -416,15 +415,9 @@ public sealed class StoryIntelligenceRelationshipImportService(
|
|||||||
|
|
||||||
private static Scene? ResolveImportedScene(
|
private static Scene? ResolveImportedScene(
|
||||||
StoryIntelligenceSavedSceneResult sceneResult,
|
StoryIntelligenceSavedSceneResult sceneResult,
|
||||||
IReadOnlyDictionary<int, Scene> byId,
|
|
||||||
IReadOnlyDictionary<string, Scene> byRange,
|
IReadOnlyDictionary<string, Scene> byRange,
|
||||||
IReadOnlyDictionary<int, Scene> byNumber)
|
IReadOnlyDictionary<int, Scene> byNumber)
|
||||||
{
|
{
|
||||||
if (sceneResult.SceneID.HasValue && byId.TryGetValue(sceneResult.SceneID.Value, out var idMatch))
|
|
||||||
{
|
|
||||||
return idMatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sceneResult.StartParagraph.HasValue
|
if (sceneResult.StartParagraph.HasValue
|
||||||
&& sceneResult.EndParagraph.HasValue
|
&& sceneResult.EndParagraph.HasValue
|
||||||
&& byRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var rangeMatch))
|
&& 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)
|
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)
|
return string.IsNullOrWhiteSpace(summary)
|
||||||
? BuildSceneReference(scene)
|
? BuildSceneReference(scene)
|
||||||
: $"{BuildSceneReference(scene)}: {summary}";
|
: $"{BuildSceneReference(scene)}: {summary}";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string BuildSceneReference(Scene scene)
|
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)
|
private static void AddDecision(OnboardingStoryIntelligenceBatch batch, string key, string action, int? relationshipId, string? label, bool createdOrLinked)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -18,7 +18,6 @@ public sealed class StoryIntelligenceResultPersistenceService(
|
|||||||
IStoryIntelligenceResultRepository repository,
|
IStoryIntelligenceResultRepository repository,
|
||||||
IStoryIntelligenceClient client,
|
IStoryIntelligenceClient client,
|
||||||
ISceneImportResolver importResolver,
|
ISceneImportResolver importResolver,
|
||||||
IStoryIntelligenceEntitlementService entitlement,
|
|
||||||
ILogger<StoryIntelligenceResultPersistenceService> logger) : IStoryIntelligenceResultPersistenceService
|
ILogger<StoryIntelligenceResultPersistenceService> logger) : IStoryIntelligenceResultPersistenceService
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
@ -64,7 +63,6 @@ public sealed class StoryIntelligenceResultPersistenceService(
|
|||||||
|
|
||||||
public async Task<int> QueueAdminTextRunAsync(int userId, ChapterStoryIntelligenceDryRunForm form)
|
public async Task<int> QueueAdminTextRunAsync(int userId, ChapterStoryIntelligenceDryRunForm form)
|
||||||
{
|
{
|
||||||
await entitlement.EnsureCanQueueAsync(userId);
|
|
||||||
if (string.IsNullOrWhiteSpace(form.ChapterText))
|
if (string.IsNullOrWhiteSpace(form.ChapterText))
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("Enter chapter text before queueing a persisted Story Intelligence run.");
|
throw new InvalidOperationException("Enter chapter text before queueing a persisted Story Intelligence run.");
|
||||||
|
|||||||
@ -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);
|
|
||||||
2552
PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs
Normal file
2552
PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs
Normal file
File diff suppressed because it is too large
Load Diff
@ -80,9 +80,6 @@ public sealed class StoryMemoryService(
|
|||||||
{
|
{
|
||||||
QueueDemand = options.QueueDemand,
|
QueueDemand = options.QueueDemand,
|
||||||
MaxDemandToQueue = options.MaxDemandToQueue,
|
MaxDemandToQueue = options.MaxDemandToQueue,
|
||||||
MaxCharacterDemandToQueue = options.MaxCharacterDemandToQueue,
|
|
||||||
MaxLocationDemandToQueue = options.MaxLocationDemandToQueue,
|
|
||||||
MaxAssetDemandToQueue = options.MaxAssetDemandToQueue,
|
|
||||||
AppearancePreference = options.AppearancePreference,
|
AppearancePreference = options.AppearancePreference,
|
||||||
PrimaryAgeGroup = options.PrimaryAgeGroup,
|
PrimaryAgeGroup = options.PrimaryAgeGroup,
|
||||||
StoryEra = options.StoryEra,
|
StoryEra = options.StoryEra,
|
||||||
@ -152,8 +149,6 @@ public sealed class StoryMemoryService(
|
|||||||
var assignments = await memory.ListAssignmentsAsync(importSessionId);
|
var assignments = await memory.ListAssignmentsAsync(importSessionId);
|
||||||
var catalogue = (await illustrationLibrary.ListAsync(new IllustrationLibraryFilter()))
|
var catalogue = (await illustrationLibrary.ListAsync(new IllustrationLibraryFilter()))
|
||||||
.Where(item => item.Status is IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Generated)
|
.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)
|
.Where(item => ResolveUploadUrl(item) is not null)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
@ -206,7 +201,7 @@ public sealed class StoryMemoryService(
|
|||||||
continue;
|
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));
|
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 requestedType = StoryIntelligenceIllustrationCompatibility.LocationType($"{location.DisplayName} {location.SemanticType} {location.Subtype}");
|
||||||
var chosen = catalogue
|
var chosen = catalogue
|
||||||
.Where(item => item.Category == IllustrationLibraryCategories.Location)
|
.Where(item => item.Category == IllustrationLibraryCategories.Location)
|
||||||
.Select(item => (Item: item, Metadata: StoryIntelligenceIllustrationCompatibility.LocationMetadataFrom(item)))
|
.Select(item => (Item: item, Type: StoryIntelligenceIllustrationCompatibility.LocationType($"{item.StableCode} {item.DisplayTitle} {item.ShortDescription} {item.MetadataJson} {item.SpecificationJson}")))
|
||||||
.Where(item => !item.Metadata.IsFallbackClassification)
|
.Where(item => StoryIntelligenceIllustrationCompatibility.LocationCompatible(requestedType, item.Type))
|
||||||
.Where(item => !string.Equals(item.Metadata.ReuseScope, "None", StringComparison.OrdinalIgnoreCase))
|
.OrderByDescending(item => string.Equals(item.Type, requestedType, StringComparison.OrdinalIgnoreCase) ? 100 : 50)
|
||||||
.Where(item => StoryIntelligenceIllustrationCompatibility.LocationCompatible(requestedType, item.Metadata.BaseType))
|
|
||||||
.OrderByDescending(item => string.Equals(item.Metadata.BaseType, requestedType, StringComparison.OrdinalIgnoreCase) ? 100 : 50)
|
|
||||||
.ThenBy(item => item.Item.StableCode)
|
.ThenBy(item => item.Item.StableCode)
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
if (chosen.Item is not null)
|
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
|
else
|
||||||
{
|
{
|
||||||
if (!StoryIntelligenceIllustrationCompatibility.CanAutomaticallyGenerateLocation(requestedType))
|
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, 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);
|
|
||||||
await memory.UpsertAssignmentAsync(new StoryMemoryIllustrationAssignmentSave(importSessionId, StoryMemoryEntityTypes.Location, location.StoryMemoryLocationID, demand.IllustrationLibraryItemId, demand.StableCode, demand.AssignmentStatus, true, 0, 0, demand.AssignmentReason, ProcessorVersion));
|
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
|
else
|
||||||
{
|
{
|
||||||
if (!StoryIntelligenceIllustrationCompatibility.CanAutomaticallyGenerateAsset(requestedType))
|
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, 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);
|
|
||||||
await memory.UpsertAssignmentAsync(new StoryMemoryIllustrationAssignmentSave(importSessionId, StoryMemoryEntityTypes.Asset, asset.StoryMemoryAssetID, demand.IllustrationLibraryItemId, demand.StableCode, demand.AssignmentStatus, true, 0, 0, demand.AssignmentReason, ProcessorVersion));
|
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));
|
var recorded = await memory.UpsertDemandAsync(new StoryMemoryIllustrationDemandSave(importSessionId, projectId, entityType, archetypeKey, 1, "Recorded", null, null));
|
||||||
if (!options.QueueDemand)
|
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();
|
return DemandResolution.Recorded();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -962,7 +936,6 @@ public sealed class StoryMemoryService(
|
|||||||
.FirstOrDefault(item => item.IllustrationLibraryItemID == existingId);
|
.FirstOrDefault(item => item.IllustrationLibraryItemID == existingId);
|
||||||
if (existingItem is not null
|
if (existingItem is not null
|
||||||
&& existingItem.IsActive
|
&& existingItem.IsActive
|
||||||
&& !existingItem.IsArchived
|
|
||||||
&& string.Equals(existingItem.StableCode, specification.Code, StringComparison.OrdinalIgnoreCase)
|
&& string.Equals(existingItem.StableCode, specification.Code, StringComparison.OrdinalIgnoreCase)
|
||||||
&& existingItem.Status is IllustrationLibraryStatuses.Planned
|
&& existingItem.Status is IllustrationLibraryStatuses.Planned
|
||||||
or IllustrationLibraryStatuses.Queued
|
or IllustrationLibraryStatuses.Queued
|
||||||
@ -970,70 +943,23 @@ public sealed class StoryMemoryService(
|
|||||||
or IllustrationLibraryStatuses.Generated
|
or IllustrationLibraryStatuses.Generated
|
||||||
or IllustrationLibraryStatuses.Approved)
|
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}.");
|
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)
|
if (options.MaxDemandToQueue is int maxDemandToQueue && maxDemandToQueue > 0)
|
||||||
{
|
{
|
||||||
|
var categoryCap = maxDemandToQueue;
|
||||||
var queuedDemandItems = (await illustrationLibrary.ListAsync(new IllustrationLibraryFilter()))
|
var queuedDemandItems = (await illustrationLibrary.ListAsync(new IllustrationLibraryFilter()))
|
||||||
.Count(item => string.Equals(item.Category, specification.Category, StringComparison.OrdinalIgnoreCase)
|
.Count(item => string.Equals(item.Category, specification.Category, StringComparison.OrdinalIgnoreCase)
|
||||||
&& item.IsActive
|
&& item.IsActive
|
||||||
&& !item.IsArchived
|
|
||||||
&& item.StableCode.StartsWith("demand-", StringComparison.OrdinalIgnoreCase)
|
&& item.StableCode.StartsWith("demand-", StringComparison.OrdinalIgnoreCase)
|
||||||
&& item.Status is IllustrationLibraryStatuses.Planned
|
&& item.Status is IllustrationLibraryStatuses.Planned
|
||||||
or IllustrationLibraryStatuses.Queued
|
or IllustrationLibraryStatuses.Queued
|
||||||
or IllustrationLibraryStatuses.Generating);
|
or IllustrationLibraryStatuses.Generating);
|
||||||
if (queuedDemandItems >= maxDemandToQueue)
|
if (queuedDemandItems >= categoryCap)
|
||||||
{
|
{
|
||||||
logger.LogInformation(
|
return new(null, null, "DemandRecorded", $"No compatible illustration exists; demand recorded but {specification.Category.ToLowerInvariant()} in-flight generation cap {categoryCap:N0} has been reached.");
|
||||||
"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.");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1049,29 +975,10 @@ public sealed class StoryMemoryService(
|
|||||||
"Queued",
|
"Queued",
|
||||||
planned.IllustrationLibraryItemID,
|
planned.IllustrationLibraryItemID,
|
||||||
null));
|
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}.");
|
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)
|
private static IllustrationGenerationSpecification CharacterDemandSpecification(StoryMemoryCharacter character, CharacterEvidenceProfile evidence, StoryMemoryResolveOptions options)
|
||||||
=> 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)
|
|
||||||
{
|
{
|
||||||
var code = DemandStableCode(StoryMemoryEntityTypes.Character, character.CanonicalIdentityKey, evidence.AgeBand, evidence.Presentation, evidence.HairColour, evidence.HairLength, evidence.SkinTone);
|
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));
|
var age = UnknownToDefault(evidence.AgeBand, NormaliseAgeContext(options.PrimaryAgeGroup));
|
||||||
@ -1101,22 +1008,11 @@ public sealed class StoryMemoryService(
|
|||||||
ClothingStyle = $"{era} {location} everyday naturalistic",
|
ClothingStyle = $"{era} {location} everyday naturalistic",
|
||||||
Palette = ["clear blue", "warm amber", "natural skin tones"],
|
Palette = ["clear blue", "warm amber", "natural skin tones"],
|
||||||
MetadataTags = ["character", "story-memory-demand"],
|
MetadataTags = ["character", "story-memory-demand"],
|
||||||
ImageKind = "Character",
|
Metadata = DemandMetadata("Character", character.CanonicalIdentityKey, options)
|
||||||
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)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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 code = DemandStableCode(StoryMemoryEntityTypes.Location, location.CanonicalIdentityKey, requestedType);
|
||||||
var locationType = UnknownToDefault(requestedType, "UnknownInterior");
|
var locationType = UnknownToDefault(requestedType, "UnknownInterior");
|
||||||
@ -1137,22 +1033,11 @@ public sealed class StoryMemoryService(
|
|||||||
Setting = location.InteriorExterior ?? InferInteriorExterior(locationType),
|
Setting = location.InteriorExterior ?? InferInteriorExterior(locationType),
|
||||||
Palette = ["clear navy", "warm light", "balanced natural colour"],
|
Palette = ["clear navy", "warm light", "balanced natural colour"],
|
||||||
MetadataTags = ["location", "story-memory-demand"],
|
MetadataTags = ["location", "story-memory-demand"],
|
||||||
ImageKind = "Location",
|
Metadata = DemandMetadata("Location", location.CanonicalIdentityKey, options)
|
||||||
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))
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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 code = DemandStableCode(StoryMemoryEntityTypes.Asset, asset.CanonicalIdentityKey, requestedType, requestedColour);
|
||||||
var objectType = UnknownToDefault(requestedType, "UnknownObject");
|
var objectType = UnknownToDefault(requestedType, "UnknownObject");
|
||||||
@ -1171,62 +1056,20 @@ public sealed class StoryMemoryService(
|
|||||||
Era = asset.Era ?? "1980s",
|
Era = asset.Era ?? "1980s",
|
||||||
Palette = ["clean blue", "warm light", "natural object colour"],
|
Palette = ["clean blue", "warm light", "natural object colour"],
|
||||||
MetadataTags = ["asset", "story-memory-demand"],
|
MetadataTags = ["asset", "story-memory-demand"],
|
||||||
ImageKind = requestedType.Contains("Car", StringComparison.OrdinalIgnoreCase) || requestedType is "Hatchback" or "SportsCar" or "Van" ? "Vehicle" : "Object",
|
Metadata = DemandMetadata("Asset", asset.CanonicalIdentityKey, options)
|
||||||
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))
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Dictionary<string, string> DemandMetadata(
|
private static Dictionary<string, string> DemandMetadata(string category, string entityKey, StoryMemoryResolveOptions options)
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
["category"] = category,
|
["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",
|
["generationSource"] = "Story Memory demand",
|
||||||
["libraryPurpose"] = "Live import demand fulfilment",
|
["libraryPurpose"] = "Live import demand fulfilment",
|
||||||
["privacy"] = "generic-no-manuscript-data",
|
["privacy"] = "generic-no-manuscript-data",
|
||||||
["storyMemoryEntityHash"] = ShortHash(entityKey)
|
["storyMemoryEntityHash"] = ShortHash(entityKey)
|
||||||
};
|
};
|
||||||
if (sceneId.HasValue)
|
|
||||||
{
|
|
||||||
metadata["sourceSceneId"] = sceneId.Value.ToString("N0");
|
|
||||||
}
|
|
||||||
|
|
||||||
Add("primaryAgeGroup", options.PrimaryAgeGroup);
|
Add("primaryAgeGroup", options.PrimaryAgeGroup);
|
||||||
Add("storyEra", options.StoryEra);
|
Add("storyEra", options.StoryEra);
|
||||||
Add("storyLocation", options.StoryLocation);
|
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 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";
|
: "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)
|
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";
|
=> 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";
|
||||||
|
|
||||||
|
|||||||
@ -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> 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> 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> 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");
|
private static readonly HashSet<string> RelationshipSignals = CreateSet("trust", "mistrust", "affection", "fear", "authority", "secrecy", "conflict", "dependence", "protection", "rivalry", "obligation");
|
||||||
|
|
||||||
public ValidationResult Validate(SceneIntelligenceScene? scene)
|
public ValidationResult Validate(SceneIntelligenceScene? scene)
|
||||||
@ -42,7 +43,6 @@ public sealed class StorySceneValidator : IStorySceneValidator
|
|||||||
ValidateRelationships(scene.Relationships, result);
|
ValidateRelationships(scene.Relationships, result);
|
||||||
ValidateKnowledgeChanges(scene.KnowledgeChanges, result);
|
ValidateKnowledgeChanges(scene.KnowledgeChanges, result);
|
||||||
ValidateTimelineClues(scene.TimelineClues, result);
|
ValidateTimelineClues(scene.TimelineClues, result);
|
||||||
ValidateNarrativeArcs(scene.NarrativeArcs, result);
|
|
||||||
ValidateQuestionsRaised(scene.QuestionsRaised, result);
|
ValidateQuestionsRaised(scene.QuestionsRaised, result);
|
||||||
ValidateQuestionsAnswered(scene.QuestionsAnswered, result);
|
ValidateQuestionsAnswered(scene.QuestionsAnswered, result);
|
||||||
ValidateObservations(scene.Observations, result);
|
ValidateObservations(scene.Observations, result);
|
||||||
@ -71,6 +71,7 @@ public sealed class StorySceneValidator : IStorySceneValidator
|
|||||||
}
|
}
|
||||||
|
|
||||||
RequiredObject(scene.SceneReference, "sceneReference", result);
|
RequiredObject(scene.SceneReference, "sceneReference", result);
|
||||||
|
RequiredObject(scene.Summary, "summary", result);
|
||||||
RequiredObject(scene.ScenePurpose, "scenePurpose", result);
|
RequiredObject(scene.ScenePurpose, "scenePurpose", result);
|
||||||
RequiredObject(scene.PointOfView, "pointOfView", result);
|
RequiredObject(scene.PointOfView, "pointOfView", result);
|
||||||
RequiredObject(scene.Setting, "setting", result);
|
RequiredObject(scene.Setting, "setting", result);
|
||||||
@ -80,7 +81,6 @@ public sealed class StorySceneValidator : IStorySceneValidator
|
|||||||
RequiredArray(scene.Relationships, "relationships", result);
|
RequiredArray(scene.Relationships, "relationships", result);
|
||||||
RequiredArray(scene.KnowledgeChanges, "knowledgeChanges", result);
|
RequiredArray(scene.KnowledgeChanges, "knowledgeChanges", result);
|
||||||
RequiredArray(scene.TimelineClues, "timelineClues", result);
|
RequiredArray(scene.TimelineClues, "timelineClues", result);
|
||||||
RequiredArray(scene.NarrativeArcs, "narrativeArcs", result);
|
|
||||||
RequiredArray(scene.QuestionsRaised, "questionsRaised", result);
|
RequiredArray(scene.QuestionsRaised, "questionsRaised", result);
|
||||||
RequiredArray(scene.QuestionsAnswered, "questionsAnswered", result);
|
RequiredArray(scene.QuestionsAnswered, "questionsAnswered", result);
|
||||||
RequiredArray(scene.Observations, "observations", 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)
|
private static void ValidateQuestionsAnswered(List<SceneIntelligenceQuestionAnswered>? values, ValidationResult result)
|
||||||
{
|
{
|
||||||
if (values is null)
|
if (values is null)
|
||||||
@ -421,15 +399,24 @@ public sealed class StorySceneValidator : IStorySceneValidator
|
|||||||
foreach (var (name, metric) in metrics)
|
foreach (var (name, metric) in metrics)
|
||||||
{
|
{
|
||||||
var path = $"metrics.{name}";
|
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)
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!metric.Score.HasValue)
|
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);
|
Confidence(metric.Confidence, $"{path}.confidence", result);
|
||||||
|
|||||||
@ -9,7 +9,6 @@ public interface IWordCompanionPresenceService
|
|||||||
Task<WordCompanionPresenceStatus> HeartbeatAsync(int userId, string connectionId, WordCompanionPresenceHeartbeat heartbeat);
|
Task<WordCompanionPresenceStatus> HeartbeatAsync(int userId, string connectionId, WordCompanionPresenceHeartbeat heartbeat);
|
||||||
Task<WordCompanionPresenceStatus?> DisconnectAsync(string connectionId);
|
Task<WordCompanionPresenceStatus?> DisconnectAsync(string connectionId);
|
||||||
Task<WordCompanionPresenceStatus> GetStatusAsync(int userId);
|
Task<WordCompanionPresenceStatus> GetStatusAsync(int userId);
|
||||||
Task<WordCompanionPresenceStatus> UpdateCurrentStoryPositionAsync(int userId, StoryPosition? position);
|
|
||||||
Task<bool> IsConnectedAsync(int userId);
|
Task<bool> IsConnectedAsync(int userId);
|
||||||
Task<IReadOnlyList<WordCompanionPresenceStatus>> MarkStaleOfflineAsync();
|
Task<IReadOnlyList<WordCompanionPresenceStatus>> MarkStaleOfflineAsync();
|
||||||
Task<string?> GetCompanionConnectionIdAsync(int userId);
|
Task<string?> GetCompanionConnectionIdAsync(int userId);
|
||||||
@ -69,11 +68,6 @@ public sealed class WordCompanionPresenceService : IWordCompanionPresenceService
|
|||||||
record.LinkedProjectID = Positive(heartbeat.LinkedProjectID);
|
record.LinkedProjectID = Positive(heartbeat.LinkedProjectID);
|
||||||
record.LinkedBookID = Positive(heartbeat.LinkedBookID);
|
record.LinkedBookID = Positive(heartbeat.LinkedBookID);
|
||||||
record.LastHeartbeatUtc = now;
|
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;
|
usersByConnection[connectionId] = userId;
|
||||||
return Task.FromResult(ToStatus(record, now));
|
return Task.FromResult(ToStatus(record, now));
|
||||||
@ -103,28 +97,6 @@ public sealed class WordCompanionPresenceService : IWordCompanionPresenceService
|
|||||||
return Task.FromResult(ToStatus(record, DateTime.UtcNow));
|
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)
|
public async Task<bool> IsConnectedAsync(int userId)
|
||||||
=> (await GetStatusAsync(userId)).IsConnected;
|
=> (await GetStatusAsync(userId)).IsConnected;
|
||||||
|
|
||||||
@ -184,7 +156,6 @@ public sealed class WordCompanionPresenceService : IWordCompanionPresenceService
|
|||||||
CurrentDocumentName = record.CurrentDocumentName,
|
CurrentDocumentName = record.CurrentDocumentName,
|
||||||
LinkedProjectID = record.LinkedProjectID,
|
LinkedProjectID = record.LinkedProjectID,
|
||||||
LinkedBookID = record.LinkedBookID,
|
LinkedBookID = record.LinkedBookID,
|
||||||
CurrentStoryPosition = connected ? record.CurrentStoryPosition : null,
|
|
||||||
ConnectedUtc = record.ConnectedUtc,
|
ConnectedUtc = record.ConnectedUtc,
|
||||||
LastHeartbeatUtc = record.LastHeartbeatUtc
|
LastHeartbeatUtc = record.LastHeartbeatUtc
|
||||||
};
|
};
|
||||||
@ -210,7 +181,6 @@ public sealed class WordCompanionPresenceService : IWordCompanionPresenceService
|
|||||||
public string? CurrentDocumentName { get; set; }
|
public string? CurrentDocumentName { get; set; }
|
||||||
public int? LinkedProjectID { get; set; }
|
public int? LinkedProjectID { get; set; }
|
||||||
public int? LinkedBookID { get; set; }
|
public int? LinkedBookID { get; set; }
|
||||||
public StoryPosition? CurrentStoryPosition { get; set; }
|
|
||||||
public DateTime? ConnectedUtc { get; set; }
|
public DateTime? ConnectedUtc { get; set; }
|
||||||
public DateTime? LastHeartbeatUtc { get; set; }
|
public DateTime? LastHeartbeatUtc { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,8 +40,7 @@ public interface IWordCompanionService
|
|||||||
public sealed class WordCompanionService(
|
public sealed class WordCompanionService(
|
||||||
IWordCompanionRepository repository,
|
IWordCompanionRepository repository,
|
||||||
IManuscriptDocumentRepository manuscriptDocuments,
|
IManuscriptDocumentRepository manuscriptDocuments,
|
||||||
ICurrentUserService currentUser,
|
ICurrentUserService currentUser) : IWordCompanionService
|
||||||
INameIntelligenceService nameIntelligence) : IWordCompanionService
|
|
||||||
{
|
{
|
||||||
private const int CharacterDiscoveryMinimumMentions = 2;
|
private const int CharacterDiscoveryMinimumMentions = 2;
|
||||||
private const int CharacterCreationSelectionLimit = 250;
|
private const int CharacterCreationSelectionLimit = 250;
|
||||||
@ -540,8 +539,7 @@ public sealed class WordCompanionService(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var candidates = await DecorateNameIntelligenceEvidenceAsync(
|
var candidates = DiscoverCharacterCandidates(request.DocumentText ?? string.Empty, request.IncludeExcluded);
|
||||||
DiscoverCharacterCandidates(request.DocumentText ?? string.Empty, request.IncludeExcluded));
|
|
||||||
return new WordCompanionDiscoverCharactersResponse
|
return new WordCompanionDiscoverCharactersResponse
|
||||||
{
|
{
|
||||||
ProjectId = request.ProjectId,
|
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)
|
public Task<WordCompanionCreateDiscoveredCharactersResponse?> CreateDiscoveredCharactersAsync(WordCompanionCreateDiscoveredCharactersRequest request)
|
||||||
{
|
{
|
||||||
if (request.ProjectId <= 0 || request.CandidateNames.Count == 0)
|
if (request.ProjectId <= 0 || request.CandidateNames.Count == 0)
|
||||||
@ -723,88 +677,6 @@ public sealed class WordCompanionService(
|
|||||||
.ToList();
|
.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)
|
private static WordCompanionCharacterCandidateDto? BuildCandidateDto(CharacterCandidateEvidence evidence)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(evidence.Text) || evidence.Text.Length > 200)
|
if (string.IsNullOrWhiteSpace(evidence.Text) || evidence.Text.Length > 200)
|
||||||
|
|||||||
@ -147,7 +147,6 @@ public sealed class WritingWorkspacePresenceService : IWritingWorkspacePresenceS
|
|||||||
WordDocumentOpen = wordStatus.DocumentOpen,
|
WordDocumentOpen = wordStatus.DocumentOpen,
|
||||||
WordProjectID = wordStatus.LinkedProjectID,
|
WordProjectID = wordStatus.LinkedProjectID,
|
||||||
WordBookID = wordStatus.LinkedBookID,
|
WordBookID = wordStatus.LinkedBookID,
|
||||||
CurrentStoryPosition = wordStatus.CurrentStoryPosition,
|
|
||||||
Windows = windows,
|
Windows = windows,
|
||||||
Launcher = LatestRole(windows, WritingWorkspaceRoles.Launcher),
|
Launcher = LatestRole(windows, WritingWorkspaceRoles.Launcher),
|
||||||
Context = LatestRole(windows, WritingWorkspaceRoles.Context),
|
Context = LatestRole(windows, WritingWorkspaceRoles.Context),
|
||||||
|
|||||||
@ -25,25 +25,6 @@ AS
|
|||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON;
|
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)
|
IF NOT EXISTS (SELECT 1 FROM dbo.ProjectTimelineSettings WHERE ProjectID = @ProjectID)
|
||||||
BEGIN
|
BEGIN
|
||||||
INSERT dbo.ProjectTimelineSettings
|
INSERT dbo.ProjectTimelineSettings
|
||||||
|
|||||||
@ -111,18 +111,6 @@ BEGIN
|
|||||||
WHERE consent.ProjectID = @ProjectID
|
WHERE consent.ProjectID = @ProjectID
|
||||||
OR EXISTS (SELECT 1 FROM @BookIDs b WHERE b.BookID = consent.BookID);
|
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 FROM dbo.ProjectActivity WHERE ProjectID = @ProjectID;
|
||||||
DELETE prh
|
DELETE prh
|
||||||
FROM dbo.ProjectRestoreHistory prh
|
FROM dbo.ProjectRestoreHistory prh
|
||||||
|
|||||||
@ -118,14 +118,6 @@ BEGIN
|
|||||||
|
|
||||||
DELETE FROM dbo.StoryIntelligenceConsents WHERE BookID = @BookID;
|
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
|
DELETE petpl
|
||||||
FROM dbo.PlotEventTargetPlotLines petpl
|
FROM dbo.PlotEventTargetPlotLines petpl
|
||||||
INNER JOIN dbo.ThreadEvents te ON te.ThreadEventID = petpl.PlotEventID
|
INNER JOIN dbo.ThreadEvents te ON te.ThreadEventID = petpl.PlotEventID
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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
Loading…
x
Reference in New Issue
Block a user